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
@@ -299,6 +299,9 @@ object ConfigLoader {
fileReadEnabled = fileReadEnabled,
fileWriteEnabled = fileWriteEnabled,
fileEditEnabled = fileEditEnabled,
workspaceRoot = asString(toolsSection["workspace_root"], ""),
privilegedLocations = asStringList(toolsSection["privileged_locations"])
.ifEmpty { ToolsConfig.DEFAULT_PRIVILEGED_LOCATIONS },
)
val providers = providersList.mapNotNull { providerMap ->
@@ -39,7 +39,16 @@ data class ToolsConfig(
val fileReadEnabled: Boolean = true,
val fileWriteEnabled: Boolean = true,
val fileEditEnabled: Boolean = true,
)
val workspaceRoot: String = "",
val privilegedLocations: List<String> = DEFAULT_PRIVILEGED_LOCATIONS,
) {
companion object {
val DEFAULT_PRIVILEGED_LOCATIONS: List<String> = listOf(
"/etc", "/usr", "/bin", "/sbin", "/boot", "/lib", "/lib64",
"/sys", "/proc", "/dev", "/root",
)
}
}
@Serializable
data class ProviderConfig(
@@ -0,0 +1,55 @@
package com.correx.core.config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class WorkspaceConfigTest {
@Test
fun `defaults when keys absent`() {
val tools = ToolsConfig()
assertEquals("", tools.workspaceRoot)
assertTrue(tools.privilegedLocations.contains("/etc"))
assertTrue(tools.privilegedLocations.contains("/usr"))
}
@Test
fun `default privileged locations includes system paths`() {
val defaults = ToolsConfig.DEFAULT_PRIVILEGED_LOCATIONS
assertTrue(defaults.contains("/etc"))
assertTrue(defaults.contains("/usr"))
assertTrue(defaults.contains("/bin"))
assertTrue(defaults.contains("/root"))
}
@Test
fun `parseToml parses workspace_root from tools section`() {
val toml = """
[tools]
workspace_root = "/my/workspace"
privileged_locations = ["/etc", "/usr", "/custom"]
""".trimIndent()
val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals("/my/workspace", result.tools.workspaceRoot)
assertEquals(listOf("/etc", "/usr", "/custom"), result.tools.privilegedLocations)
}
@Test
fun `parseToml uses default privileged locations when key absent`() {
val toml = """
[tools]
workspace_root = "/x"
""".trimIndent()
val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(ToolsConfig.DEFAULT_PRIVILEGED_LOCATIONS, result.tools.privilegedLocations)
}
}
+1
View File
@@ -16,6 +16,7 @@ dependencies {
implementation project(':core:artifacts')
implementation project(':core:artifacts-store')
implementation project(':core:risk')
implementation project(':core:toolintent')
implementation "org.slf4j:slf4j-api:2.0.16"
}
tasks.named("koverVerify").configure { enabled = false }
@@ -4,6 +4,10 @@ import com.correx.core.approvals.domain.ApprovalEngine
import com.correx.core.context.builder.ContextPackBuilder
import com.correx.core.inference.InferenceRouter
import com.correx.core.risk.RiskAssessor
import com.correx.core.toolintent.FileSystemWorldProbe
import com.correx.core.toolintent.ToolCallAssessor
import com.correx.core.toolintent.WorkspacePolicy
import com.correx.core.toolintent.WorldProbe
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.transitions.evaluation.PromptResolver
@@ -20,4 +24,7 @@ data class OrchestratorEngines(
val promptResolver: PromptResolver = PromptResolver { "" },
val toolExecutor: ToolExecutor? = null,
val toolRegistry: ToolRegistry? = null,
val toolCallAssessor: ToolCallAssessor? = null,
val workspacePolicy: WorkspacePolicy? = null,
val worldProbe: WorldProbe = FileSystemWorldProbe(),
)
@@ -23,6 +23,7 @@ import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.EventMetadata
@@ -39,6 +40,14 @@ import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
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.risk.RiskSummary
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallAssessor
import com.correx.core.toolintent.WorkspacePolicy
import com.correx.core.toolintent.WorldProbe
import com.correx.core.toolintent.toAssessedIssues
import com.correx.core.toolintent.toRiskSummary
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
@@ -128,6 +137,9 @@ abstract class SessionOrchestrator(
private val promptResolver: PromptResolver = engines.promptResolver
private val toolExecutor: ToolExecutor? = engines.toolExecutor
private val toolRegistry: ToolRegistry? = engines.toolRegistry
private val toolCallAssessor: ToolCallAssessor? = engines.toolCallAssessor
private val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
private val worldProbe: WorldProbe = engines.worldProbe
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
protected open val tokenizer: Tokenizer? = null
@@ -365,7 +377,46 @@ abstract class SessionOrchestrator(
request = request,
),
)
if (tier.isAtMost(Tier.T1)) {
val plane2Risk: RiskSummary? = runPlane2Assessment(
sessionId, stageId, invocationId, toolCall.function.name, request,
)?.let { assessment ->
if (assessment.recommendedAction == RiskAction.BLOCK) {
emit(
sessionId,
ToolExecutionRejectedEvent(
invocationId = invocationId,
sessionId = sessionId,
toolName = toolCall.function.name,
tier = tier,
reason = "blocked by tool-call policy",
),
)
val sourceId = toolCall.id ?: invocationId.value
return@flatMap listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "assistantToolCall",
sourceId = sourceId,
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
tokenEstimate = estimateTokens(toolCall.function.arguments),
role = EntryRole.ASSISTANT,
),
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
sourceType = "toolResult",
sourceId = sourceId,
content = "BLOCKED: ${assessment.rationale.joinToString("; ")}",
tokenEstimate = estimateTokens(assessment.rationale.joinToString("; ")),
role = EntryRole.TOOL,
),
)
}
assessment
}
val plane2Prompts = plane2Risk?.recommendedAction == RiskAction.PROMPT_USER
if (tier.isAtMost(Tier.T1) && !plane2Prompts) {
// no approval needed
} else {
val approvalState = approvalRepository.getApprovalState(sessionId)
@@ -433,6 +484,7 @@ abstract class SessionOrchestrator(
tier = tier,
validationReportId = domainRequest.validationReportId,
riskSummaryId = null,
riskSummary = plane2Risk,
sessionId = sessionId,
stageId = stageId,
projectId = null,
@@ -538,6 +590,40 @@ abstract class SessionOrchestrator(
}
}
private suspend fun runPlane2Assessment(
sessionId: SessionId,
stageId: StageId,
invocationId: ToolInvocationId,
toolName: String,
request: ToolRequest,
): RiskSummary? {
val assessor = toolCallAssessor ?: return null
val policy = workspacePolicy ?: return null
val capabilities = toolRegistry?.resolve(toolName)?.requiredCapabilities ?: emptySet()
val assessment = assessor.assess(
ToolCallAssessmentInput(
request = request,
capabilities = capabilities,
workspace = policy,
probe = worldProbe,
),
)
emit(
sessionId,
ToolCallAssessedEvent(
invocationId = invocationId,
sessionId = sessionId,
stageId = stageId,
toolName = toolName,
issues = assessment.toAssessedIssues(),
observations = assessment.observations,
disposition = assessment.disposition,
timestampMs = Clock.System.now().toEpochMilliseconds(),
),
)
return assessment.toRiskSummary()
}
private suspend fun buildSchemaEntries(
responseFormat: ResponseFormat,
stageId: StageId,
+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)
}
}
@@ -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)
}
}