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:
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
@@ -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 },
|
.ifEmpty { ToolsConfig.DEFAULT_INTERPRETERS },
|
||||||
networkAllowedHosts = asStringList(toolsSection["network_allowed_hosts"]),
|
networkAllowedHosts = asStringList(toolsSection["network_allowed_hosts"]),
|
||||||
networkDeniedHosts = asStringList(toolsSection["network_denied_hosts"]),
|
networkDeniedHosts = asStringList(toolsSection["network_denied_hosts"]),
|
||||||
|
allowedWorkspaceRoots = asStringList(toolsSection["allowed_workspace_roots"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
val providers = providersList.mapNotNull { providerMap ->
|
val providers = providersList.mapNotNull { providerMap ->
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ data class ToolsConfig(
|
|||||||
val interpreterExecutables: List<String> = DEFAULT_INTERPRETERS,
|
val interpreterExecutables: List<String> = DEFAULT_INTERPRETERS,
|
||||||
val networkAllowedHosts: List<String> = emptyList(),
|
val networkAllowedHosts: List<String> = emptyList(),
|
||||||
val networkDeniedHosts: List<String> = emptyList(),
|
val networkDeniedHosts: List<String> = emptyList(),
|
||||||
|
val allowedWorkspaceRoots: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val DEFAULT_PRIVILEGED_LOCATIONS: List<String> = listOf(
|
val DEFAULT_PRIVILEGED_LOCATIONS: List<String> = listOf(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class ConfigLoaderTest {
|
|||||||
assertEquals("dark", config.tui.theme)
|
assertEquals("dark", config.tui.theme)
|
||||||
assertEquals(5, config.tui.sessionListLimit)
|
assertEquals(5, config.tui.sessionListLimit)
|
||||||
assertEquals("human", config.cli.defaultOutput)
|
assertEquals("human", config.cli.defaultOutput)
|
||||||
|
assertEquals(emptyList<String>(), config.tools.allowedWorkspaceRoots)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -398,4 +399,20 @@ class ConfigLoaderTest {
|
|||||||
assertEquals("/models/local.gguf", result.models[0].modelPath)
|
assertEquals("/models/local.gguf", result.models[0].modelPath)
|
||||||
assertEquals(8192, result.models[0].contextSize)
|
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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import java.nio.file.Path
|
|||||||
* cannot smuggle access to a target outside it. Fails CLOSED: an empty
|
* cannot smuggle access to a target outside it. Fails CLOSED: an empty
|
||||||
* allow-list contains nothing.
|
* allow-list contains nothing.
|
||||||
*/
|
*/
|
||||||
internal object PathJail {
|
object PathJail {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve [path] to a symlink-resolved absolute path. If the target does not
|
* Resolve [path] to a symlink-resolved absolute path. If the target does not
|
||||||
|
|||||||
Reference in New Issue
Block a user