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.)
This commit is contained in:
2026-06-02 19:57:03 +04:00
parent d405f9d2c1
commit 7d7e524756
6 changed files with 226 additions and 1 deletions
@@ -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<Path>,
private val allowedWorkspaceRoots: List<Path>,
) {
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,
)
)
}
}
@@ -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<WorkspaceResolution.Bound>(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<WorkspaceResolution.Bound>(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<WorkspaceResolution.Rejected>(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<WorkspaceResolution.Rejected>(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<WorkspaceResolution.Rejected>(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<WorkspaceResolution.Rejected>(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<WorkspaceResolution.Bound>(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<WorkspaceResolution.Rejected>(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<WorkspaceResolution.Bound>(result)
assertEquals(privileged, result.workspace.privilegedLocations)
}
}
@@ -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 ->
@@ -61,6 +61,7 @@ data class ToolsConfig(
val interpreterExecutables: List<String> = DEFAULT_INTERPRETERS,
val networkAllowedHosts: List<String> = emptyList(),
val networkDeniedHosts: List<String> = emptyList(),
val allowedWorkspaceRoots: List<String> = emptyList(),
) {
companion object {
val DEFAULT_PRIVILEGED_LOCATIONS: List<String> = listOf(
@@ -12,6 +12,7 @@ class ConfigLoaderTest {
assertEquals("dark", config.tui.theme)
assertEquals(5, config.tui.sessionListLimit)
assertEquals("human", config.cli.defaultOutput)
assertEquals(emptyList<String>(), 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])
}
}
@@ -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