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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user