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)
}
}