From 7d7e5247563e5dbe5d7f5b73eb58913b4dae42df Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 2 Jun 2026 19:57:03 +0400 Subject: [PATCH] feat(workspace): WorkspaceResolver trust pipeline + allowed_workspace_roots Resolve a client-supplied workspace dir safely: canonicalize via toRealPath, reject privileged locations, clamp to [tools] allowed_workspace_roots (permissive when unset, logged), fall back to the boot default on any rejection. PathJail made non-internal so the server can reuse its symlink-safe containment. (Axis 2 Phase A, task 4.) --- .../server/workspace/WorkspaceResolver.kt | 66 +++++++++ .../server/workspace/WorkspaceResolverTest.kt | 140 ++++++++++++++++++ .../com/correx/core/config/ConfigLoader.kt | 1 + .../com/correx/core/config/CorrexConfig.kt | 1 + .../correx/core/config/ConfigLoaderTest.kt | 17 +++ .../tools/filesystem/PathJail.kt | 2 +- 6 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceResolver.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceResolverTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceResolver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceResolver.kt new file mode 100644 index 00000000..333507c6 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceResolver.kt @@ -0,0 +1,66 @@ +package com.correx.apps.server.workspace + +import com.correx.core.kernel.orchestration.WorkspaceContext +import com.correx.infrastructure.tools.filesystem.PathJail +import java.nio.file.Files +import java.nio.file.Path + +sealed interface WorkspaceResolution { + data class Bound(val workspace: WorkspaceContext) : WorkspaceResolution + data class Rejected(val reason: String, val fallback: WorkspaceContext) : WorkspaceResolution +} + +class WorkspaceResolver( + private val bootDefault: WorkspaceContext, + private val privilegedLocations: List, + private val allowedWorkspaceRoots: List, +) { + fun resolve(clientDir: String?): WorkspaceResolution { + clientDir ?: return WorkspaceResolution.Bound(bootDefault) + + val canonical = runCatching { Path.of(clientDir).toRealPath() } + .getOrElse { + return WorkspaceResolution.Rejected( + "workspace path does not exist or is not accessible: $clientDir", + bootDefault, + ) + } + + if (!Files.isDirectory(canonical)) { + return WorkspaceResolution.Rejected( + "workspace path is not a directory: $canonical", + bootDefault, + ) + } + + val isPrivileged = privilegedLocations.any { priv -> + val realPriv = runCatching { priv.toRealPath() }.getOrDefault(priv) + canonical.startsWith(realPriv) + } + if (isPrivileged) { + return WorkspaceResolution.Rejected( + "workspace path is a privileged location: $canonical", + bootDefault, + ) + } + + if (allowedWorkspaceRoots.isNotEmpty()) { + val contained = PathJail.isContained(canonical, allowedWorkspaceRoots.toSet()) + if (!contained) { + return WorkspaceResolution.Rejected( + "workspace path not under an allowed root: $canonical", + bootDefault, + ) + } + } + + return WorkspaceResolution.Bound( + WorkspaceContext( + workspaceRoot = canonical, + workingDir = canonical, + allowedPaths = setOf(canonical), + privilegedLocations = privilegedLocations, + ) + ) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceResolverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceResolverTest.kt new file mode 100644 index 00000000..48b54876 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceResolverTest.kt @@ -0,0 +1,140 @@ +package com.correx.apps.server.workspace + +import com.correx.core.kernel.orchestration.WorkspaceContext +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class WorkspaceResolverTest { + + private fun bootDefault(root: Path) = WorkspaceContext( + workspaceRoot = root, + workingDir = root, + allowedPaths = setOf(root), + ) + + @Test + fun `null clientDir returns Bound with bootDefault`(@TempDir base: Path) { + val default = bootDefault(base) + val resolver = WorkspaceResolver(default, emptyList(), emptyList()) + + val result = resolver.resolve(null) + + assertIs(result) + assertEquals(default, result.workspace) + } + + @Test + fun `existing non-privileged dir with empty allow-list returns Bound`(@TempDir base: Path) { + val clientDir = Files.createDirectory(base.resolve("project")) + val canonical = clientDir.toRealPath() + val default = bootDefault(base) + val resolver = WorkspaceResolver(default, emptyList(), emptyList()) + + val result = resolver.resolve(clientDir.toString()) + + assertIs(result) + assertEquals(canonical, result.workspace.workspaceRoot) + assertEquals(canonical, result.workspace.workingDir) + assertEquals(setOf(canonical), result.workspace.allowedPaths) + } + + @Test + fun `nonexistent path returns Rejected with reason mentioning does not exist`(@TempDir base: Path) { + val missing = base.resolve("does-not-exist") + val default = bootDefault(base) + val resolver = WorkspaceResolver(default, emptyList(), emptyList()) + + val result = resolver.resolve(missing.toString()) + + assertIs(result) + assertTrue(result.reason.contains("does not exist"), "reason was: ${result.reason}") + assertEquals(default, result.fallback) + } + + @Test + fun `file path returns Rejected with reason mentioning not a directory`(@TempDir base: Path) { + val file = Files.createFile(base.resolve("regular-file.txt")) + val default = bootDefault(base) + val resolver = WorkspaceResolver(default, emptyList(), emptyList()) + + val result = resolver.resolve(file.toString()) + + assertIs(result) + assertTrue(result.reason.contains("not a directory"), "reason was: ${result.reason}") + assertEquals(default, result.fallback) + } + + @Test + fun `path under privileged location returns Rejected mentioning privileged`(@TempDir base: Path) { + val privilegedRoot = Files.createDirectory(base.resolve("system")) + val clientDir = Files.createDirectory(privilegedRoot.resolve("secret")) + val default = bootDefault(base) + val resolver = WorkspaceResolver(default, listOf(privilegedRoot), emptyList()) + + val result = resolver.resolve(clientDir.toString()) + + assertIs(result) + assertTrue(result.reason.contains("privileged"), "reason was: ${result.reason}") + assertEquals(default, result.fallback) + } + + @Test + fun `symlink inside allowed area pointing to privileged dir is Rejected`(@TempDir base: Path) { + val privilegedRoot = Files.createDirectory(base.resolve("privileged")) + val allowedRoot = Files.createDirectory(base.resolve("allowed")) + val link = allowedRoot.resolve("escape-link") + Files.createSymbolicLink(link, privilegedRoot) + val default = bootDefault(base) + val resolver = WorkspaceResolver(default, listOf(privilegedRoot), listOf(allowedRoot)) + + val result = resolver.resolve(link.toString()) + + assertIs(result) + assertTrue(result.reason.contains("privileged"), "reason was: ${result.reason}") + } + + @Test + fun `path under an allowed root with non-empty allow-list returns Bound`(@TempDir base: Path) { + val allowedRoot = Files.createDirectory(base.resolve("projects")) + val clientDir = Files.createDirectory(allowedRoot.resolve("my-app")) + val default = bootDefault(base) + val resolver = WorkspaceResolver(default, emptyList(), listOf(allowedRoot)) + + val result = resolver.resolve(clientDir.toString()) + + assertIs(result) + assertEquals(clientDir.toRealPath(), result.workspace.workspaceRoot) + } + + @Test + fun `path outside all allowed roots returns Rejected mentioning not under an allowed root`(@TempDir base: Path) { + val allowedRoot = Files.createDirectory(base.resolve("projects")) + val outsideDir = Files.createDirectory(base.resolve("outside")) + val default = bootDefault(base) + val resolver = WorkspaceResolver(default, emptyList(), listOf(allowedRoot)) + + val result = resolver.resolve(outsideDir.toString()) + + assertIs(result) + assertTrue(result.reason.contains("not under an allowed root"), "reason was: ${result.reason}") + assertEquals(default, result.fallback) + } + + @Test + fun `privilegedLocations are propagated to bound WorkspaceContext`(@TempDir base: Path) { + val privileged = listOf(base.resolve("sys")) + val clientDir = Files.createDirectory(base.resolve("project")) + val default = bootDefault(base) + val resolver = WorkspaceResolver(default, privileged, emptyList()) + + val result = resolver.resolve(clientDir.toString()) + + assertIs(result) + assertEquals(privileged, result.workspace.privilegedLocations) + } +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index ed5385f3..e2fbfa8d 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -331,6 +331,7 @@ object ConfigLoader { .ifEmpty { ToolsConfig.DEFAULT_INTERPRETERS }, networkAllowedHosts = asStringList(toolsSection["network_allowed_hosts"]), networkDeniedHosts = asStringList(toolsSection["network_denied_hosts"]), + allowedWorkspaceRoots = asStringList(toolsSection["allowed_workspace_roots"]), ) val providers = providersList.mapNotNull { providerMap -> diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index c76d92fb..fa1eb6b1 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -61,6 +61,7 @@ data class ToolsConfig( val interpreterExecutables: List = DEFAULT_INTERPRETERS, val networkAllowedHosts: List = emptyList(), val networkDeniedHosts: List = emptyList(), + val allowedWorkspaceRoots: List = emptyList(), ) { companion object { val DEFAULT_PRIVILEGED_LOCATIONS: List = listOf( diff --git a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt index 93bf417e..0a58c989 100644 --- a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt +++ b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt @@ -12,6 +12,7 @@ class ConfigLoaderTest { assertEquals("dark", config.tui.theme) assertEquals(5, config.tui.sessionListLimit) assertEquals("human", config.cli.defaultOutput) + assertEquals(emptyList(), config.tools.allowedWorkspaceRoots) } @Test @@ -398,4 +399,20 @@ class ConfigLoaderTest { assertEquals("/models/local.gguf", result.models[0].modelPath) assertEquals(8192, result.models[0].contextSize) } + + @Test + fun `parseToml parses tools allowed_workspace_roots`() { + val toml = """ + [tools] + allowed_workspace_roots = ["/home/user/projects", "/tmp/work"] + """.trimIndent() + + val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java) + parseTomlMethod.isAccessible = true + val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig + + assertEquals(2, result.tools.allowedWorkspaceRoots.size) + assertEquals("/home/user/projects", result.tools.allowedWorkspaceRoots[0]) + assertEquals("/tmp/work", result.tools.allowedWorkspaceRoots[1]) + } } diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/PathJail.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/PathJail.kt index 1ee19977..cda504e1 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/PathJail.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/PathJail.kt @@ -10,7 +10,7 @@ import java.nio.file.Path * cannot smuggle access to a target outside it. Fails CLOSED: an empty * allow-list contains nothing. */ -internal object PathJail { +object PathJail { /** * Resolve [path] to a symlink-resolved absolute path. If the target does not