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
@@ -1,12 +1,14 @@
package com.correx.core.tools
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolExecutionStartedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.state.ToolCallAssessmentRecord
import com.correx.core.tools.state.ToolInvocationRecord
import com.correx.core.tools.state.ToolInvocationStatus
import com.correx.core.tools.state.ToolState
@@ -40,6 +42,15 @@ class DefaultToolReducer : ToolReducer {
is ToolExecutionRejectedEvent -> state.updateRecord(p.invocationId) {
it.copy(status = ToolInvocationStatus.REJECTED, completedAt = event.metadata.timestamp)
}
is ToolCallAssessedEvent -> state.updateRecord(p.invocationId) {
it.copy(
assessment = ToolCallAssessmentRecord(
issues = p.issues,
observations = p.observations,
disposition = p.disposition,
),
)
}
else -> state
}
}
@@ -0,0 +1,13 @@
package com.correx.core.tools.state
import com.correx.core.events.events.AssessedIssue
import com.correx.core.events.events.ToolCallObservation
import com.correx.core.events.risk.RiskAction
import kotlinx.serialization.Serializable
@Serializable
data class ToolCallAssessmentRecord(
val issues: List<AssessedIssue> = emptyList(),
val observations: List<ToolCallObservation> = emptyList(),
val disposition: RiskAction,
)
@@ -16,5 +16,6 @@ data class ToolInvocationRecord(
val status: ToolInvocationStatus,
val receipt: ToolReceipt? = null,
val requestedAt: Instant,
val completedAt: Instant? = null
val completedAt: Instant? = null,
val assessment: ToolCallAssessmentRecord? = null,
)
@@ -0,0 +1,101 @@
package com.correx.core.tools
import com.correx.core.approvals.Tier
import com.correx.core.events.events.AssessedIssue
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolCallObservation
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.EventId
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.state.ToolState
import kotlinx.datetime.Clock
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ToolCallAssessmentReducerTest {
private val reducer = DefaultToolReducer()
private val inv = ToolInvocationId("inv-1")
private val session = SessionId("s-1")
private val stage = StageId("st-1")
private fun stored(payload: com.correx.core.events.events.EventPayload, seq: Long) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = session,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private val requested = ToolInvocationRequestedEvent(
invocationId = inv,
sessionId = session,
stageId = stage,
toolName = "file_write",
tier = Tier.T1,
request = ToolRequest(inv, session, stage, "file_write", mapOf("path" to "/etc/passwd")),
)
private val assessed = ToolCallAssessedEvent(
invocationId = inv,
sessionId = session,
stageId = stage,
toolName = "file_write",
issues = listOf(AssessedIssue("PRIVILEGED_LOCATION", "privileged: /etc/passwd", "ERROR")),
observations = listOf(ToolCallObservation("PATH_CONTAINMENT", mapOf("privileged" to "true"))),
disposition = RiskAction.BLOCK,
timestampMs = 1L,
)
@Test
fun `assessment is reduced onto the matching invocation record`() {
var state = ToolState()
state = reducer.reduce(state, stored(requested, 0))
state = reducer.reduce(state, stored(assessed, 1))
val record = state.invocations.single()
val assessment = assertNotNull(record.assessment)
assertEquals(RiskAction.BLOCK, assessment.disposition)
assertEquals("PRIVILEGED_LOCATION", assessment.issues.single().code)
assertEquals("true", assessment.observations.single().facts["privileged"])
}
@Test
fun `assessment for unknown invocation is a no-op`() {
val state = reducer.reduce(ToolState(), stored(assessed, 0))
assertEquals(0, state.invocations.size)
}
@Test
fun `assessment record fields are correctly mapped from event`() {
var state = ToolState()
state = reducer.reduce(state, stored(requested, 0))
state = reducer.reduce(state, stored(assessed, 1))
val assessment = assertNotNull(state.invocations.single().assessment)
assertEquals(1, assessment.issues.size)
assertEquals(1, assessment.observations.size)
assertEquals("PATH_CONTAINMENT", assessment.observations.single().ruleCode)
}
@Test
fun `record without assessment has null assessment`() {
var state = ToolState()
state = reducer.reduce(state, stored(requested, 0))
assertNull(state.invocations.single().assessment)
}
}