diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt index d089262156..d3fda6fa8e 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridge.kt @@ -1,55 +1,125 @@ package ai.rever.boss.kernel.services +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.proto.Empty import ai.rever.boss.ipc.proto.services.* import ai.rever.boss.plugin.api.SplitViewOperations +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException +/** + * Host-side split-view operations for authenticated child processes (BossConsole#53). + * + * Every unary RPC requires the process identity verified by the kernel's interceptor before + * accessing the provider. Implemented mutations log the operation and caller, without exposing + * URLs or file paths. This is process authentication, not a per-plugin capability or path policy. + * Other kernel services require their own guards; this bridge does not secure the entire server. + */ +// One method per RPC the generated service base class declares, plus small identity and audit helpers. +@Suppress("TooManyFunctions") class SplitViewServiceBridge( private val provider: SplitViewOperations, ) : SplitViewServiceGrpcKt.SplitViewServiceCoroutineImplBase() { override suspend fun openUrlInActivePanel(request: SplitViewOpenUrlRequest): Empty { + val caller = authenticatedCallerOrRefuse("openUrlInActivePanel") + logMutation("openUrlInActivePanel", caller) provider.openUrlInActivePanel(request.url, request.title, request.forceNewTab) return Empty.getDefaultInstance() } override suspend fun openFileInActivePanel(request: SplitViewOpenFileRequest): Empty { + val caller = authenticatedCallerOrRefuse("openFileInActivePanel") + logMutation("openFileInActivePanel", caller) provider.openFileInActivePanel(request.filePath, request.fileName) return Empty.getDefaultInstance() } override suspend fun openFileInBrowser(request: SplitViewOpenFileRequest): Empty { + val caller = authenticatedCallerOrRefuse("openFileInBrowser") + logMutation("openFileInBrowser", caller) provider.openFileInBrowser(request.filePath, request.fileName) return Empty.getDefaultInstance() } override suspend fun openFileInEditor(request: SplitViewOpenFileRequest): Empty { + val caller = authenticatedCallerOrRefuse("openFileInEditor") + logMutation("openFileInEditor", caller) provider.openFileInEditor(request.filePath, request.fileName) return Empty.getDefaultInstance() } override suspend fun openFileAtPosition(request: SplitViewOpenFileAtPositionRequest): Empty { + val caller = authenticatedCallerOrRefuse("openFileAtPosition") + logMutation("openFileAtPosition", caller) provider.openFileAtPosition(request.filePath, request.fileName, request.line, request.column) return Empty.getDefaultInstance() } override suspend fun setActivePanel(request: SplitViewPanelIdRequest): Empty { + val caller = authenticatedCallerOrRefuse("setActivePanel") + logMutation("setActivePanel", caller) provider.setActivePanel(request.panelId) return Empty.getDefaultInstance() } override suspend fun preserveCurrentState(request: SplitViewPreserveStateRequest): Empty { + val caller = authenticatedCallerOrRefuse("preserveCurrentState") + logMutation("preserveCurrentState", caller) provider.preserveCurrentState(request.workspaceId, request.workspaceName) return Empty.getDefaultInstance() } override suspend fun selectTabInPanel(request: SplitViewSelectTabInPanelRequest): Empty { + val caller = authenticatedCallerOrRefuse("selectTabInPanel") + logMutation("selectTabInPanel", caller) provider.selectTabInPanel(request.tabId, request.panelId) return Empty.getDefaultInstance() } override suspend fun applyWorkspace(request: SplitViewApplyWorkspaceRequest): Empty { + authenticatedCallerOrRefuse("applyWorkspace") + // Add mutation auditing when this stub gains provider dispatch. // Workspace JSON needs to be deserialized on the host side // For now, log the request — full implementation depends on LayoutWorkspace serialization return Empty.getDefaultInstance() } + + /** + * Unary RPCs only: this identity is a per-call snapshot. Streaming RPCs must revalidate + * with `CURRENT_IDENTITY`, as PluginUIServiceBridge.streamUI does. + * The verified identity, or a thrown `PERMISSION_DENIED` when there is none. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: refuseIdentity(rpc) + + private fun logMutation( + rpc: String, + caller: String, + ) { + logger.info( + LogCategory.AUTH, + "Authenticated split-view mutation requested", + mapOf("rpc" to rpc, "caller" to caller), + ) + } + + private fun refuseIdentity(rpc: String): Nothing { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no current verified process identity on this call", + mapOf("rpc" to rpc), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + + private companion object { + val logger = BossLogger.forComponent("SplitViewServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt new file mode 100644 index 0000000000..14393669bd --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/SplitViewServiceBridgeTest.kt @@ -0,0 +1,367 @@ +package ai.rever.boss.kernel.services + +import ai.rever.boss.ipc.BossIpcServer +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenRegistry +import ai.rever.boss.ipc.proto.Empty +import ai.rever.boss.ipc.proto.services.SplitViewApplyWorkspaceRequest +import ai.rever.boss.ipc.proto.services.SplitViewOpenFileAtPositionRequest +import ai.rever.boss.ipc.proto.services.SplitViewOpenFileRequest +import ai.rever.boss.ipc.proto.services.SplitViewOpenUrlRequest +import ai.rever.boss.ipc.proto.services.SplitViewPanelIdRequest +import ai.rever.boss.ipc.proto.services.SplitViewPreserveStateRequest +import ai.rever.boss.ipc.proto.services.SplitViewSelectTabInPanelRequest +import ai.rever.boss.ipc.proto.services.SplitViewServiceGrpcKt +import ai.rever.boss.plugin.api.SplitViewOperations +import ai.rever.boss.plugin.api.TabInfo +import ai.rever.boss.plugin.api.TabSplitMode +import ai.rever.boss.plugin.api.TabsComponent +import ai.rever.boss.plugin.workspace.LayoutWorkspace +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Metadata +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.ServerCall +import io.grpc.ServerCallHandler +import io.grpc.ServerInterceptor +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The split-view bridge, exercised over a real gRPC server (BossConsole#53). + * + * Before this bridge checked identity, any process able to open a connection to the kernel IPC + * server - not only the plugins the host itself loaded - could force an arbitrary window's active + * panel to navigate to an attacker-chosen URL via `openUrlInActivePanel`, open an arbitrary file + * path with no confinement via `openFileInActivePanel`/`openFileInEditor`/`openFileInBrowser`/ + * `openFileAtPosition`, or overwrite a workspace snapshot under an attacker-chosen name via + * `preserveCurrentState`. + */ +class SplitViewServiceBridgeTest { + private val exercisedRpcs = java.util.Collections.synchronizedSet(mutableSetOf()) + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var provider: FakeSplitViewOperations + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: SplitViewServiceGrpcKt.SplitViewServiceCoroutineStub + private lateinit var anonymous: SplitViewServiceGrpcKt.SplitViewServiceCoroutineStub + + @BeforeTest + fun setUp() { + tokenRegistry = ProcessTokenRegistry() + provider = FakeSplitViewOperations() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .intercept( + object : ServerInterceptor { + override fun interceptCall( + call: ServerCall, + headers: Metadata, + next: ServerCallHandler, + ): ServerCall.Listener { + exercisedRpcs += call.methodDescriptor.fullMethodName + return next.startCall(call, headers) + } + }, + ).addService(SplitViewServiceBridge(provider)) + .build() + .start() + authenticatedChannel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + anonymousChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + authenticated = SplitViewServiceGrpcKt.SplitViewServiceCoroutineStub(authenticatedChannel) + anonymous = SplitViewServiceGrpcKt.SplitViewServiceCoroutineStub(anonymousChannel) + } + + @AfterTest + fun tearDown() { + authenticatedChannel.shutdownNow() + authenticatedChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + anonymousChannel.shutdownNow() + anonymousChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + @Test + fun `every RPC is refused with no credential, and never reaches the provider`() = + runBlocking { + assertRefused { + anonymous.openUrlInActivePanel( + SplitViewOpenUrlRequest.newBuilder().setUrl("https://evil.example").build(), + ) + } + assertRefused { + anonymous.openFileInActivePanel( + SplitViewOpenFileRequest.newBuilder().setFilePath("/etc/passwd").build(), + ) + } + assertRefused { + anonymous.openFileInBrowser(SplitViewOpenFileRequest.newBuilder().setFilePath("/etc/passwd").build()) + } + assertRefused { + anonymous.openFileInEditor(SplitViewOpenFileRequest.newBuilder().setFilePath("/etc/passwd").build()) + } + assertRefused { + anonymous.openFileAtPosition( + SplitViewOpenFileAtPositionRequest.newBuilder().setFilePath("/etc/passwd").build(), + ) + } + assertRefused { anonymous.setActivePanel(SplitViewPanelIdRequest.newBuilder().setPanelId("p1").build()) } + assertRefused { + anonymous.preserveCurrentState( + SplitViewPreserveStateRequest + .newBuilder() + .setWorkspaceId("w1") + .setWorkspaceName("evil") + .build(), + ) + } + assertRefused { + anonymous.selectTabInPanel( + SplitViewSelectTabInPanelRequest + .newBuilder() + .setTabId("t1") + .setPanelId("p1") + .build(), + ) + } + assertRefused { + anonymous.applyWorkspace(SplitViewApplyWorkspaceRequest.newBuilder().setWorkspaceJson("{}").build()) + } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the provider") + assertEquals( + SplitViewServiceBridge(provider) + .bindService() + .methods + .map { it.methodDescriptor.fullMethodName } + .toSet(), + exercisedRpcs.toSet(), + "Every declared RPC must be exercised by the refusal test", + ) + } + + @Test + fun `an authenticated caller can open a URL, files and position`() = + runBlocking { + authenticated.openUrlInActivePanel( + SplitViewOpenUrlRequest + .newBuilder() + .setUrl("https://example.com") + .setTitle("t") + .setForceNewTab(true) + .build(), + ) + authenticated.openFileInActivePanel( + SplitViewOpenFileRequest + .newBuilder() + .setFilePath("/tmp/a") + .setFileName("a") + .build(), + ) + authenticated.openFileInBrowser( + SplitViewOpenFileRequest + .newBuilder() + .setFilePath("/tmp/a") + .setFileName("a") + .build(), + ) + authenticated.openFileInEditor( + SplitViewOpenFileRequest + .newBuilder() + .setFilePath("/tmp/a") + .setFileName("a") + .build(), + ) + authenticated.openFileAtPosition( + SplitViewOpenFileAtPositionRequest + .newBuilder() + .setFilePath("/tmp/a") + .setFileName("a") + .setLine(1) + .setColumn(2) + .build(), + ) + + assertEquals( + listOf( + "openUrlInActivePanel|https://example.com|t|true", + "openFileInActivePanel|/tmp/a|a", + "openFileInBrowser|/tmp/a|a", + "openFileInEditor|/tmp/a|a", + "openFileAtPosition|/tmp/a|a|1|2", + ), + provider.calls, + ) + } + + @Test + fun `an authenticated caller can drive panel and workspace state`() = + runBlocking { + authenticated.setActivePanel(SplitViewPanelIdRequest.newBuilder().setPanelId("p1").build()) + authenticated.preserveCurrentState( + SplitViewPreserveStateRequest + .newBuilder() + .setWorkspaceId("w1") + .setWorkspaceName("n") + .build(), + ) + authenticated.selectTabInPanel( + SplitViewSelectTabInPanelRequest + .newBuilder() + .setTabId("t1") + .setPanelId("p1") + .build(), + ) + authenticated.applyWorkspace( + SplitViewApplyWorkspaceRequest.newBuilder().setWorkspaceJson("{}").build(), + ) + + assertEquals( + listOf("setActivePanel|p1", "preserveCurrentState|w1|n", "selectTabInPanel|t1|p1"), + provider.calls, + ) + } + + @Test + fun `revoked caller cannot invoke a unary RPC`() = + runBlocking { + tokenRegistry.revoke(CALLER) + assertRefused { + authenticated.setActivePanel(SplitViewPanelIdRequest.newBuilder().setPanelId("p1").build()) + } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `late registered production bridge rejects anonymous and accepts authenticated calls`() = + runBlocking { + val lateServer = BossIpcServer("tcp://localhost:0", tokenRegistry).start() + val channel = ManagedChannelBuilder.forAddress("localhost", lateServer.port).usePlaintext().build() + try { + lateServer.addService(SplitViewServiceBridge(provider)) + val stub = SplitViewServiceGrpcKt.SplitViewServiceCoroutineStub(channel) + val request = SplitViewPanelIdRequest.newBuilder().setPanelId("late-panel").build() + assertRefused { stub.setActivePanel(request) } + assertTrue(provider.calls.isEmpty()) + stub + .withInterceptors(ProcessTokenClientInterceptor(tokenRegistry.issue("late-caller"))) + .setActivePanel(request) + assertEquals(listOf("setActivePanel|late-panel"), provider.calls) + } finally { + channel.shutdownNow() + channel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + lateServer.stop(SHUTDOWN_TIMEOUT_MS) + } + } + + private suspend fun assertRefused(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ + private class FakeSplitViewOperations : SplitViewOperations { + val calls = mutableListOf() + + override fun openUrlInActivePanel( + url: String, + title: String, + forceNewTab: Boolean, + ) { + calls += "openUrlInActivePanel|$url|$title|$forceNewTab" + } + + override fun openFileInActivePanel( + filePath: String, + fileName: String, + ) { + calls += "openFileInActivePanel|$filePath|$fileName" + } + + override fun openFileInBrowser( + filePath: String, + fileName: String, + ) { + calls += "openFileInBrowser|$filePath|$fileName" + } + + override fun openFileInEditor( + filePath: String, + fileName: String, + ) { + calls += "openFileInEditor|$filePath|$fileName" + } + + override fun openFileAtPosition( + filePath: String, + fileName: String, + line: Int, + column: Int, + ) { + calls += "openFileAtPosition|$filePath|$fileName|$line|$column" + } + + override fun setActivePanel(panelId: String) { + calls += "setActivePanel|$panelId" + } + + override fun preserveCurrentState( + workspaceId: String, + workspaceName: String, + ) { + calls += "preserveCurrentState|$workspaceId|$workspaceName" + } + + override fun getActiveTabsComponent(): TabsComponent? = null + + override fun applyWorkspace(workspace: LayoutWorkspace) = Unit + + override fun selectTabInPanel( + tabId: String, + panelId: String, + ) { + calls += "selectTabInPanel|$tabId|$panelId" + } + + override fun openTab(tabInfo: TabInfo) = Unit + + override fun openTabInSplit( + tabInfo: TabInfo, + mode: TabSplitMode, + ) = Unit + + override val supportsOpenPanelAsTab: Boolean = false + + override fun openPanelAsTab(panelId: ai.rever.boss.plugin.api.PanelId) = Unit + + override fun openUrlInSplit( + url: String, + title: String, + mode: TabSplitMode, + ) = Unit + } + + private companion object { + const val CALLER = "split-view-plugin" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +}