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:
2026-05-31 04:22:58 +04:00
parent 2964849bb2
commit 545068d222
24 changed files with 931 additions and 3 deletions
+12
View File
@@ -0,0 +1,12 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation project(':core:events')
implementation project(':core:tools')
implementation project(':core:validation')
testImplementation "org.jetbrains.kotlin:kotlin-test"
}
@@ -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() }
}
@@ -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)
}
}