feat: plane-2 tool-call intent validation (path containment slice)
Implements the full vertical slice for invariant #9 tool-call assessment: - core:toolintent — new module with ToolCallRule seam, ToolCallAssessor, WorldProbe/FileSystemWorldProbe, WorkspacePolicy, PathContainmentRule, and RiskMapping (assessment → RiskSummary / AssessedIssue) - core:tools — ToolCallAssessmentRecord + ToolInvocationRecord.assessment field; DefaultToolReducer handles ToolCallAssessedEvent (replay proof) - core:config — ToolsConfig gains workspaceRoot + privilegedLocations; ConfigLoader parses both; DEFAULT_PRIVILEGED_LOCATIONS built-in - core:kernel — OrchestratorEngines gains toolCallAssessor/workspacePolicy/ worldProbe fields; SessionOrchestrator.dispatchToolCalls runs runPlane2Assessment before the tier gate: BLOCK → hard-reject without executing; PROMPT_USER → elevates tier into approval path with plane2Risk in the ApprovalRequestedEvent - apps/server — constructs PathContainmentRule + ToolCallAssessor + WorkspacePolicy from config and wires them into OrchestratorEngines Assessment is recorded as ToolCallAssessedEvent (environment observed once, facts stored, replay reads events — invariant #9). Assessor and WorldProbe are only invoked on the live orchestrator path, never in replay.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
package com.correx.core.toolintent
|
||||
|
||||
import com.correx.core.events.events.AssessedIssue
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.risk.RiskLevel
|
||||
import com.correx.core.events.risk.RiskSignal
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
|
||||
fun ToolCallAssessment.toRiskSummary(): RiskSummary {
|
||||
val errorCount = issues.count { it.severity == ValidationSeverity.ERROR }
|
||||
val level = when (disposition) {
|
||||
RiskAction.BLOCK -> RiskLevel.CRITICAL
|
||||
RiskAction.PROMPT_USER -> RiskLevel.MEDIUM
|
||||
RiskAction.PROCEED -> RiskLevel.LOW
|
||||
}
|
||||
return RiskSummary(
|
||||
level = level,
|
||||
signals = if (errorCount > 0) listOf(RiskSignal.ValidationErrors(errorCount)) else emptyList(),
|
||||
recommendedAction = disposition,
|
||||
rationale = issues.map { "[${it.code}] ${it.message}" },
|
||||
)
|
||||
}
|
||||
|
||||
fun ToolCallAssessment.toAssessedIssues(): List<AssessedIssue> =
|
||||
issues.map { AssessedIssue(it.code, it.message, it.severity.name) }
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.correx.core.toolintent
|
||||
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
|
||||
/** Open, config-driven registry of plane-2 rules. New rules plug in here. */
|
||||
class ToolCallAssessor(private val rules: List<ToolCallRule>) {
|
||||
|
||||
fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
||||
val results = rules.filter { it.appliesTo(input.capabilities) }.map { it.assess(input) }
|
||||
return ToolCallAssessment(
|
||||
issues = results.flatMap { it.issues },
|
||||
observations = results.flatMap { it.observations },
|
||||
disposition = results.fold(RiskAction.PROCEED) { acc, r -> maxAction(acc, r.disposition) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun maxAction(a: RiskAction, b: RiskAction): RiskAction =
|
||||
if (a.severityRank() >= b.severityRank()) a else b
|
||||
|
||||
private fun RiskAction.severityRank(): Int = when (this) {
|
||||
RiskAction.PROCEED -> 0
|
||||
RiskAction.PROMPT_USER -> 1
|
||||
RiskAction.BLOCK -> 2
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.correx.core.toolintent
|
||||
|
||||
import com.correx.core.events.events.ToolCallObservation
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
|
||||
/**
|
||||
* Plane-2 rule. Dispatches on declared tool [ToolCapability]s (never tool names),
|
||||
* assesses the proposed call against bounded real-world state, and records the
|
||||
* facts it observed so replay reads them back (invariant #9).
|
||||
*/
|
||||
interface ToolCallRule {
|
||||
fun appliesTo(capabilities: Set<ToolCapability>): Boolean
|
||||
fun assess(input: ToolCallAssessmentInput): ToolCallAssessment
|
||||
}
|
||||
|
||||
data class ToolCallAssessmentInput(
|
||||
val request: ToolRequest,
|
||||
val capabilities: Set<ToolCapability>,
|
||||
val workspace: WorkspacePolicy,
|
||||
val probe: WorldProbe,
|
||||
)
|
||||
|
||||
data class ToolCallAssessment(
|
||||
val issues: List<ValidationIssue> = emptyList(),
|
||||
val observations: List<ToolCallObservation> = emptyList(),
|
||||
val disposition: RiskAction = RiskAction.PROCEED,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.correx.core.toolintent
|
||||
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Workspace containment policy shared with the future in-place write-seam
|
||||
* (sandboxing thread). [workspaceRoot] is the only writable/allowed tree;
|
||||
* [privilegedLocations] are hard-deny targets (system dirs).
|
||||
*/
|
||||
data class WorkspacePolicy(
|
||||
val workspaceRoot: Path,
|
||||
val privilegedLocations: List<Path> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.correx.core.toolintent
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
/** Abstracts filesystem observation so rules are testable and replay never re-stats. */
|
||||
interface WorldProbe {
|
||||
fun exists(path: Path): Boolean
|
||||
|
||||
/** Symlink-resolved real path if it exists; otherwise the normalized absolute path. */
|
||||
fun resolveReal(path: Path): Path
|
||||
}
|
||||
|
||||
class FileSystemWorldProbe : WorldProbe {
|
||||
override fun exists(path: Path): Boolean = Files.exists(path)
|
||||
|
||||
override fun resolveReal(path: Path): Path =
|
||||
runCatching { path.toRealPath() }.getOrElse { path.toAbsolutePath().normalize() }
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.correx.core.toolintent.rules
|
||||
|
||||
import com.correx.core.events.events.ToolCallObservation
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.toolintent.ToolCallAssessment
|
||||
import com.correx.core.toolintent.ToolCallAssessmentInput
|
||||
import com.correx.core.toolintent.ToolCallRule
|
||||
import com.correx.core.toolintent.maxAction
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.validation.model.ValidationIssue
|
||||
import com.correx.core.validation.model.ValidationSeverity
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Effect-based path containment. Dispatches on FILE_READ / FILE_WRITE. For every
|
||||
* path-like string argument, records {path, resolved, exists, inWorkspace, privileged}
|
||||
* and raises: privileged → BLOCK, outside-workspace → PROMPT_USER.
|
||||
* Symlink escapes are caught because resolution goes through WorldProbe.resolveReal
|
||||
* (toRealPath). Fail-closed: anything that doesn't resolve into the workspace is
|
||||
* treated as outside.
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
class PathContainmentRule : ToolCallRule {
|
||||
|
||||
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
|
||||
ToolCapability.FILE_WRITE in capabilities || ToolCapability.FILE_READ in capabilities
|
||||
|
||||
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
||||
val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot)
|
||||
val privilegedReal = input.workspace.privilegedLocations.map { input.probe.resolveReal(it) }
|
||||
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
val observations = mutableListOf<ToolCallObservation>()
|
||||
var disposition = RiskAction.PROCEED
|
||||
|
||||
for (raw in candidatePaths(input)) {
|
||||
val candidate = Path.of(raw)
|
||||
val resolvedInput = if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
|
||||
val resolvedReal = input.probe.resolveReal(resolvedInput)
|
||||
val exists = input.probe.exists(resolvedInput)
|
||||
val privileged = privilegedReal.any { resolvedReal.startsWith(it) }
|
||||
val inWorkspace = resolvedReal.startsWith(workspaceReal)
|
||||
|
||||
observations += ToolCallObservation(
|
||||
ruleCode = RULE_CODE,
|
||||
facts = mapOf(
|
||||
"path" to raw,
|
||||
"resolved" to resolvedReal.toString(),
|
||||
"exists" to exists.toString(),
|
||||
"inWorkspace" to inWorkspace.toString(),
|
||||
"privileged" to privileged.toString(),
|
||||
),
|
||||
)
|
||||
|
||||
when {
|
||||
privileged -> {
|
||||
issues += ValidationIssue(
|
||||
code = "PRIVILEGED_LOCATION",
|
||||
message = "Tool '${input.request.toolName}' targets privileged location: $raw",
|
||||
severity = ValidationSeverity.ERROR,
|
||||
)
|
||||
disposition = maxAction(disposition, RiskAction.BLOCK)
|
||||
}
|
||||
!inWorkspace -> {
|
||||
issues += ValidationIssue(
|
||||
code = "PATH_OUTSIDE_WORKSPACE",
|
||||
message = "Tool '${input.request.toolName}' targets path outside workspace: $raw",
|
||||
severity = ValidationSeverity.WARNING,
|
||||
)
|
||||
disposition = maxAction(disposition, RiskAction.PROMPT_USER)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
|
||||
}
|
||||
|
||||
private fun candidatePaths(input: ToolCallAssessmentInput): List<String> =
|
||||
input.request.parameters.values
|
||||
.filterIsInstance<String>()
|
||||
.filter { it.looksLikePath() }
|
||||
|
||||
private fun String.looksLikePath(): Boolean =
|
||||
isNotBlank() && (startsWith("/") || startsWith("~") || contains("/") || contains(".."))
|
||||
|
||||
private companion object {
|
||||
const val RULE_CODE = "PATH_CONTAINMENT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.correx.core.toolintent
|
||||
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.toolintent.rules.PathContainmentRule
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import java.nio.file.Path
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PathContainmentRuleTest {
|
||||
|
||||
private val workspace = Path.of("/work/project")
|
||||
private val rule = PathContainmentRule()
|
||||
|
||||
/** Fake probe: resolveReal returns the normalized absolute path, except for the
|
||||
* symlink test where a specific path is redirected to a privileged target. */
|
||||
private class FakeProbe(val redirect: Map<Path, Path> = emptyMap()) : WorldProbe {
|
||||
override fun exists(path: Path) = true
|
||||
override fun resolveReal(path: Path): Path =
|
||||
redirect[path.toAbsolutePath().normalize()] ?: path.toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
private fun input(
|
||||
pathArg: String,
|
||||
probe: WorldProbe,
|
||||
privileged: List<Path> = listOf(Path.of("/etc"), Path.of("/usr")),
|
||||
) = ToolCallAssessmentInput(
|
||||
request = ToolRequest(
|
||||
ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write",
|
||||
mapOf("path" to pathArg, "mode" to "0644"),
|
||||
),
|
||||
capabilities = setOf(ToolCapability.FILE_WRITE),
|
||||
workspace = WorkspacePolicy(workspace, privileged),
|
||||
probe = probe,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `applies only to file capabilities`() {
|
||||
assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_READ)))
|
||||
assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_WRITE)))
|
||||
assertFalse(rule.appliesTo(setOf(ToolCapability.NETWORK_ACCESS)))
|
||||
assertFalse(rule.appliesTo(emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in-workspace path proceeds with no issues`() {
|
||||
val r = rule.assess(input("/work/project/src/A.kt", FakeProbe()))
|
||||
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||
assertTrue(r.issues.isEmpty())
|
||||
assertEquals("true", r.observations.single().facts["inWorkspace"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `outside-workspace path prompts the user`() {
|
||||
val r = rule.assess(input("/tmp/scratch.txt", FakeProbe()))
|
||||
assertEquals(RiskAction.PROMPT_USER, r.disposition)
|
||||
assertEquals("PATH_OUTSIDE_WORKSPACE", r.issues.single().code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `privileged location is blocked`() {
|
||||
val r = rule.assess(input("/etc/passwd", FakeProbe()))
|
||||
assertEquals(RiskAction.BLOCK, r.disposition)
|
||||
assertEquals("PRIVILEGED_LOCATION", r.issues.single().code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `symlink inside workspace that resolves to privileged is blocked`() {
|
||||
val link = Path.of("/work/project/evil")
|
||||
val probe = FakeProbe(redirect = mapOf(link to Path.of("/etc/shadow")))
|
||||
val r = rule.assess(input("/work/project/evil", probe))
|
||||
assertEquals(RiskAction.BLOCK, r.disposition)
|
||||
assertEquals("true", r.observations.single().facts["privileged"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-path string parameters are ignored`() {
|
||||
val r = rule.assess(input("0644", FakeProbe())) // not path-like; "mode" also ignored
|
||||
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||
assertTrue(r.observations.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.correx.core.toolintent
|
||||
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import java.nio.file.Path
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ToolCallAssessorTest {
|
||||
|
||||
private fun ruleReturning(action: RiskAction, applies: Boolean) = object : ToolCallRule {
|
||||
override fun appliesTo(capabilities: Set<ToolCapability>) = applies
|
||||
override fun assess(input: ToolCallAssessmentInput) = ToolCallAssessment(disposition = action)
|
||||
}
|
||||
|
||||
private val input = ToolCallAssessmentInput(
|
||||
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "t", emptyMap()),
|
||||
capabilities = setOf(ToolCapability.FILE_WRITE),
|
||||
workspace = WorkspacePolicy(Path.of("/work")),
|
||||
probe = FileSystemWorldProbe(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `folds to the strictest disposition across applicable rules`() {
|
||||
val assessor = ToolCallAssessor(
|
||||
listOf(
|
||||
ruleReturning(RiskAction.PROCEED, applies = true),
|
||||
ruleReturning(RiskAction.BLOCK, applies = true),
|
||||
ruleReturning(RiskAction.PROMPT_USER, applies = true),
|
||||
),
|
||||
)
|
||||
assertEquals(RiskAction.BLOCK, assessor.assess(input).disposition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-applicable rules are skipped`() {
|
||||
val assessor = ToolCallAssessor(listOf(ruleReturning(RiskAction.BLOCK, applies = false)))
|
||||
assertEquals(RiskAction.PROCEED, assessor.assess(input).disposition)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user