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
View File
@@ -37,6 +37,7 @@ dependencies {
implementation project(':infrastructure:inference:commons')
implementation project(':core:router')
implementation project(':core:tools')
implementation project(':core:toolintent')
implementation project(':infrastructure:tools')
implementation project(':infrastructure:tools:filesystem')
@@ -38,6 +38,9 @@ import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.core.validation.semantic.SemanticValidator
import com.correx.core.validation.semantic.rules.CycleExitRule
import com.correx.core.validation.semantic.rules.MissingToolRule
import com.correx.core.toolintent.ToolCallAssessor
import com.correx.core.toolintent.WorkspacePolicy
import com.correx.core.toolintent.rules.PathContainmentRule
import com.correx.infrastructure.InfrastructureModule
import com.correx.infrastructure.artifactscas.DefaultMaterializingArtifactWriter
import com.correx.infrastructure.inference.DefaultProviderRegistry
@@ -104,6 +107,14 @@ fun main() {
logToolInfo(sandboxRoot, workingDir, shellAllowedExecutables, toolRegistry)
val workspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT")
?.let { Path.of(it) }
?: toolsConfig.workspaceRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: workingDir
val privilegedLocations = toolsConfig.privilegedLocations.map { Path.of(it) }
val workspacePolicy = WorkspacePolicy(workspaceRoot, privilegedLocations)
val toolCallAssessor = ToolCallAssessor(rules = listOf(PathContainmentRule()))
val inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy())
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
@@ -125,6 +136,8 @@ fun main() {
promptResolver = { path -> InfrastructureModule.createPromptLoader().load(path) },
toolRegistry = toolRegistry,
toolExecutor = toolExecutor,
toolCallAssessor = toolCallAssessor,
workspacePolicy = workspacePolicy,
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
@@ -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)
}
}
+1
View File
@@ -23,6 +23,7 @@ include ':core:artifacts-store'
include ':core:validation'
include ':core:approvals'
include ':core:tools'
include ':core:toolintent'
include ':core:router'
include ':core:sessions'
include ':core:config'
+3
View File
@@ -14,7 +14,10 @@ dependencies {
testImplementation(project(":core:inference"))
testImplementation(project(":core:kernel"))
testImplementation(project(":core:risk"))
testImplementation(project(":core:tools"))
testImplementation(project(":core:toolintent"))
testImplementation(project(":infrastructure:persistence"))
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation(project(":core:artifacts"))
testImplementation(project(":core:artifacts-store"))
testImplementation(project(":testing:fixtures"))
@@ -0,0 +1,277 @@
import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.approvals.Tier
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceResponse
import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.InferenceState
import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.Tokenizer
import com.correx.core.inference.ToolCallFunction
import com.correx.core.inference.ToolCallRequest
import com.correx.core.inference.TokenUsage
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.core.toolintent.ToolCallAssessment
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallAssessor
import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.WorkspacePolicy
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
import com.correx.testing.fixtures.context.ContextFixtures
import com.correx.testing.fixtures.cyclePolicyMissingValidator
import com.correx.testing.fixtures.inference.MockTokenizer
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@Suppress("LongMethod")
class ToolCallGateTest {
private inner class FakeFileWriteTool(override val tier: Tier = Tier.T1) : Tool {
override val name = "file_write"
override val description = "fake file write"
override val parametersSchema: JsonObject = buildJsonObject {}
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
}
private inner class FakeToolRegistry(private val tool: Tool) : ToolRegistry {
override fun resolve(name: String): Tool? = if (name == tool.name) tool else null
override fun all(): List<Tool> = listOf(tool)
}
private inner class RecordingExecutor : ToolExecutor {
val executeCalled = AtomicBoolean(false)
override suspend fun execute(request: ToolRequest): ToolResult {
executeCalled.set(true)
return ToolResult.Success(invocationId = request.invocationId, output = "ok", exitCode = 0)
}
}
private inner class ToolCallingProvider(private val toolName: String) : InferenceProvider {
override val id = ProviderId("tool-caller")
override val name = "tool-caller"
override val tokenizer: Tokenizer = MockTokenizer()
private var callCount = 0
override suspend fun infer(request: InferenceRequest): InferenceResponse {
callCount++
return if (callCount == 1) {
InferenceResponse(
requestId = request.requestId,
text = "",
finishReason = FinishReason.ToolCall,
tokensUsed = TokenUsage(10, 5),
latencyMs = 0,
toolCalls = listOf(
ToolCallRequest(
id = "tc-1",
function = ToolCallFunction(name = toolName, arguments = "{}"),
),
),
)
} else {
InferenceResponse(
requestId = request.requestId,
text = "done",
finishReason = FinishReason.Stop,
tokensUsed = TokenUsage(10, 5),
latencyMs = 0,
)
}
}
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
}
private fun ruleReturning(action: RiskAction): ToolCallRule = object : ToolCallRule {
override fun appliesTo(capabilities: Set<ToolCapability>) = true
override fun assess(input: ToolCallAssessmentInput) = ToolCallAssessment(disposition = action)
}
private val workspace = Path.of("/work")
private fun buildOrchestrator(
executor: ToolExecutor,
tool: Tool,
assessorRule: ToolCallRule?,
): Pair<DefaultSessionOrchestrator, InMemoryEventStore> {
val eventStore = InMemoryEventStore()
val artifactStore = NoopArtifactStore()
val toolRegistry = FakeToolRegistry(tool)
val assessor = assessorRule?.let { ToolCallAssessor(listOf(it)) }
val policy = if (assessor != null) WorkspacePolicy(workspace) else null
val provider = ToolCallingProvider(tool.name)
val inferenceRouter = object : InferenceRouter {
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider =
provider
}
val approvalRepository = DefaultApprovalRepository(
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
)
val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(object : EventReplayer<InferenceState> {
override fun rebuild(sessionId: SessionId) = InferenceState()
}),
orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
),
sessionRepository = DefaultSessionRepository(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
),
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
approvalRepository = approvalRepository,
)
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { _, _ -> true },
contextPackBuilder = ContextFixtures.simpleBuilder(),
inferenceRouter = inferenceRouter,
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
approvalEngine = DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
toolExecutor = executor,
toolRegistry = toolRegistry,
toolCallAssessor = assessor,
workspacePolicy = policy,
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
)
return Pair(orchestrator, eventStore)
}
private fun singleStageGraph(allowedTools: Set<String> = setOf("file_write")): WorkflowGraph =
WorkflowGraph(
id = "gate-test",
stages = mapOf(
StageId("A") to StageConfig(allowedTools = allowedTools),
),
transitions = setOf(
TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
),
start = StageId("A"),
)
@Test
fun `BLOCK rule prevents executor from being called and emits rejected event`(): Unit = runBlocking {
val executor = RecordingExecutor()
val (orchestrator, eventStore) = buildOrchestrator(executor, FakeFileWriteTool(), ruleReturning(RiskAction.BLOCK))
val sessionId = SessionId("gate-block")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
orchestrator.run(sessionId, singleStageGraph(), config)
val events = eventStore.read(sessionId)
assertTrue(!executor.executeCalled.get(), "executor must NOT be called when BLOCK")
assertNotNull(events.find { it.payload is ToolCallAssessedEvent }, "ToolCallAssessedEvent must be emitted")
assertNotNull(events.find { it.payload is ToolExecutionRejectedEvent }, "ToolExecutionRejectedEvent must be emitted")
}
@Test
fun `PROMPT_USER rule on T2 tool triggers approval with plane2 risk summary`(): Unit = runBlocking {
val executor = RecordingExecutor()
// Use T2 so the engine does not auto-approve (PROMPT mode auto-approves up to T1 only)
val (orchestrator, eventStore) = buildOrchestrator(
executor, FakeFileWriteTool(Tier.T2), ruleReturning(RiskAction.PROMPT_USER),
)
val sessionId = SessionId("gate-prompt")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
// Run in background; orchestrator will pause waiting for approval
val job = launch { orchestrator.run(sessionId, singleStageGraph(), config) }
// Wait for ApprovalRequestedEvent
withTimeout(5_000) {
while (eventStore.read(sessionId).none { it.payload is ApprovalRequestedEvent }) {
yield()
}
}
val approval = eventStore.read(sessionId).firstNotNullOfOrNull { it.payload as? ApprovalRequestedEvent }
assertNotNull(approval, "ApprovalRequestedEvent must be emitted for PROMPT_USER plane-2 rule on T1 tool")
assertEquals(RiskAction.PROMPT_USER, approval?.riskSummary?.recommendedAction)
job.cancel()
job.join()
}
@Test
fun `null assessor means executor is called normally (regression guard)`(): Unit = runBlocking {
val executor = RecordingExecutor()
val (orchestrator, eventStore) = buildOrchestrator(executor, FakeFileWriteTool(), assessorRule = null)
val sessionId = SessionId("gate-null")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
orchestrator.run(sessionId, singleStageGraph(), config)
val events = eventStore.read(sessionId)
assertTrue(executor.executeCalled.get(), "executor must be called when assessor is null")
assertNull(events.find { it.payload is ToolCallAssessedEvent }, "ToolCallAssessedEvent must NOT be emitted when assessor is null")
}
}