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
@@ -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,