From 545068d2224f954809ed26457cc534f37e9a79ff Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 31 May 2026 04:22:58 +0400 Subject: [PATCH] feat: plane-2 tool-call intent validation (path containment slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/server/build.gradle | 1 + .../kotlin/com/correx/apps/server/Main.kt | 13 + .../com/correx/core/config/ConfigLoader.kt | 3 + .../com/correx/core/config/CorrexConfig.kt | 11 +- .../correx/core/config/WorkspaceConfigTest.kt | 55 ++++ core/kernel/build.gradle | 1 + .../orchestration/OrchestratorEngines.kt | 7 + .../orchestration/SessionOrchestrator.kt | 88 +++++- core/toolintent/build.gradle | 12 + .../com/correx/core/toolintent/RiskMapping.kt | 26 ++ .../core/toolintent/ToolCallAssessor.kt | 25 ++ .../correx/core/toolintent/ToolCallRule.kt | 30 ++ .../correx/core/toolintent/WorkspacePolicy.kt | 13 + .../com/correx/core/toolintent/WorldProbe.kt | 19 ++ .../toolintent/rules/PathContainmentRule.kt | 89 ++++++ .../toolintent/PathContainmentRuleTest.kt | 88 ++++++ .../core/toolintent/ToolCallAssessorTest.kt | 44 +++ .../correx/core/tools/DefaultToolReducer.kt | 11 + .../tools/state/ToolCallAssessmentRecord.kt | 13 + .../core/tools/state/ToolInvocationRecord.kt | 3 +- .../tools/ToolCallAssessmentReducerTest.kt | 101 +++++++ settings.gradle | 1 + testing/integration/build.gradle | 3 + .../src/test/kotlin/ToolCallGateTest.kt | 277 ++++++++++++++++++ 24 files changed, 931 insertions(+), 3 deletions(-) create mode 100644 core/config/src/test/kotlin/com/correx/core/config/WorkspaceConfigTest.kt create mode 100644 core/toolintent/build.gradle create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/RiskMapping.kt create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallAssessor.kt create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorkspacePolicy.kt create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorldProbe.kt create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/PathContainmentRuleTest.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/ToolCallAssessorTest.kt create mode 100644 core/tools/src/main/kotlin/com/correx/core/tools/state/ToolCallAssessmentRecord.kt create mode 100644 core/tools/src/test/kotlin/com/correx/core/tools/ToolCallAssessmentReducerTest.kt create mode 100644 testing/integration/src/test/kotlin/ToolCallGateTest.kt diff --git a/apps/server/build.gradle b/apps/server/build.gradle index 6a436bf7..bfac1f8e 100644 --- a/apps/server/build.gradle +++ b/apps/server/build.gradle @@ -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') diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index e075fb29..184b5928 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -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, diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index 5446b534..e1aaa272 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -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 -> diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index e1d83287..89b3d031 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -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 = DEFAULT_PRIVILEGED_LOCATIONS, +) { + companion object { + val DEFAULT_PRIVILEGED_LOCATIONS: List = listOf( + "/etc", "/usr", "/bin", "/sbin", "/boot", "/lib", "/lib64", + "/sys", "/proc", "/dev", "/root", + ) + } +} @Serializable data class ProviderConfig( diff --git a/core/config/src/test/kotlin/com/correx/core/config/WorkspaceConfigTest.kt b/core/config/src/test/kotlin/com/correx/core/config/WorkspaceConfigTest.kt new file mode 100644 index 00000000..cb4267a8 --- /dev/null +++ b/core/config/src/test/kotlin/com/correx/core/config/WorkspaceConfigTest.kt @@ -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) + } +} diff --git a/core/kernel/build.gradle b/core/kernel/build.gradle index 81cd747c..6ecf2db5 100644 --- a/core/kernel/build.gradle +++ b/core/kernel/build.gradle @@ -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 } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt index 9e7441b2..4363278c 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt @@ -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(), ) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 774da8a5..7eba7bfa 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -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, diff --git a/core/toolintent/build.gradle b/core/toolintent/build.gradle new file mode 100644 index 00000000..f788ef3d --- /dev/null +++ b/core/toolintent/build.gradle @@ -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" +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/RiskMapping.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/RiskMapping.kt new file mode 100644 index 00000000..ece0ce84 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/RiskMapping.kt @@ -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 = + issues.map { AssessedIssue(it.code, it.message, it.severity.name) } diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallAssessor.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallAssessor.kt new file mode 100644 index 00000000..5901c0b2 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallAssessor.kt @@ -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) { + + 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 +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt new file mode 100644 index 00000000..8944abd0 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt @@ -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): Boolean + fun assess(input: ToolCallAssessmentInput): ToolCallAssessment +} + +data class ToolCallAssessmentInput( + val request: ToolRequest, + val capabilities: Set, + val workspace: WorkspacePolicy, + val probe: WorldProbe, +) + +data class ToolCallAssessment( + val issues: List = emptyList(), + val observations: List = emptyList(), + val disposition: RiskAction = RiskAction.PROCEED, +) diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorkspacePolicy.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorkspacePolicy.kt new file mode 100644 index 00000000..52d070db --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorkspacePolicy.kt @@ -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 = emptyList(), +) diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorldProbe.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorldProbe.kt new file mode 100644 index 00000000..d68a6e38 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/WorldProbe.kt @@ -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() } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt new file mode 100644 index 00000000..e6e70507 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt @@ -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): 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() + val observations = mutableListOf() + 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 = + input.request.parameters.values + .filterIsInstance() + .filter { it.looksLikePath() } + + private fun String.looksLikePath(): Boolean = + isNotBlank() && (startsWith("/") || startsWith("~") || contains("/") || contains("..")) + + private companion object { + const val RULE_CODE = "PATH_CONTAINMENT" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/PathContainmentRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/PathContainmentRuleTest.kt new file mode 100644 index 00000000..5e6c8bf3 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/PathContainmentRuleTest.kt @@ -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 = 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 = 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()) + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ToolCallAssessorTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ToolCallAssessorTest.kt new file mode 100644 index 00000000..ec1ad68b --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ToolCallAssessorTest.kt @@ -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) = 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) + } +} diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt b/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt index 944c7157..44f04b51 100644 --- a/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt +++ b/core/tools/src/main/kotlin/com/correx/core/tools/DefaultToolReducer.kt @@ -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 } } diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolCallAssessmentRecord.kt b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolCallAssessmentRecord.kt new file mode 100644 index 00000000..0564d8dc --- /dev/null +++ b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolCallAssessmentRecord.kt @@ -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 = emptyList(), + val observations: List = emptyList(), + val disposition: RiskAction, +) diff --git a/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt index 00dcc958..97398e0f 100644 --- a/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt +++ b/core/tools/src/main/kotlin/com/correx/core/tools/state/ToolInvocationRecord.kt @@ -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, ) diff --git a/core/tools/src/test/kotlin/com/correx/core/tools/ToolCallAssessmentReducerTest.kt b/core/tools/src/test/kotlin/com/correx/core/tools/ToolCallAssessmentReducerTest.kt new file mode 100644 index 00000000..0f2f5ffd --- /dev/null +++ b/core/tools/src/test/kotlin/com/correx/core/tools/ToolCallAssessmentReducerTest.kt @@ -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) + } +} diff --git a/settings.gradle b/settings.gradle index bc992771..4b3daed3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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' diff --git a/testing/integration/build.gradle b/testing/integration/build.gradle index bffaf03e..ca53504c 100644 --- a/testing/integration/build.gradle +++ b/testing/integration/build.gradle @@ -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")) diff --git a/testing/integration/src/test/kotlin/ToolCallGateTest.kt b/testing/integration/src/test/kotlin/ToolCallGateTest.kt new file mode 100644 index 00000000..6e8ac8d9 --- /dev/null +++ b/testing/integration/src/test/kotlin/ToolCallGateTest.kt @@ -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 = 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 = 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 = setOf(CapabilityScore(ModelCapability.General, 1.0)) + } + + private fun ruleReturning(action: RiskAction): ToolCallRule = object : ToolCallRule { + override fun appliesTo(capabilities: Set) = 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 { + 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): InferenceProvider = + provider + } + + val approvalRepository = DefaultApprovalRepository( + DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())), + ) + + val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = InferenceRepository(object : EventReplayer { + 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 = 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") + } +}