feat(kernel): enforce stage allowedTools at tool-call dispatch

A stage's allowedTools only shaped which tool definitions were offered to
the model; the dispatch path resolved returned tool calls straight from the
full registry and executed them without checking stage membership. A
hallucinated, jailbroken, or edited-transcript tool call for any registered
tool would run unchecked (violating invariant #5 / policy-is-absolute).

dispatchToolCalls now rejects any tool call whose name is not in the stage's
allowedTools (STAGE_COMPLETE_TOOL exempt), emitting ToolExecutionRejectedEvent
and feeding an error ContextEntry back to the model instead of executing.
Empty allowedTools means deny-all domain tools, consistent with offering
only STAGE_COMPLETE_TOOL at inference time.
This commit is contained in:
2026-06-03 13:16:47 +04:00
parent 5721ed21bb
commit 57cf6f09f4
2 changed files with 90 additions and 0 deletions
@@ -15,6 +15,7 @@ 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.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.risk.RiskAction
@@ -371,4 +372,57 @@ class ToolCallGateTest {
val toolEntry = secondPack.layers.values.flatten().first { it.sourceType == "toolResult" }
assertEquals("line1\nline2\nline3", toolEntry.content)
}
@Test
fun `tool not in allowedTools is not executed and emits ToolExecutionRejectedEvent`(): Unit = runBlocking {
val executor = RecordingExecutor()
// Stage only allows "allowed_tool"; the LLM returns a call to "file_write" (not in the set).
val (orchestrator, eventStore, _) = buildOrchestrator(executor, FakeFileWriteTool(), assessorRule = null)
val sessionId = SessionId("gate-stage-deny")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
val graph = singleStageGraph(allowedTools = setOf("allowed_tool"))
orchestrator.run(sessionId, graph, config)
val events = eventStore.read(sessionId)
assertTrue(!executor.executeCalled.get(), "executor must NOT be called for a tool outside allowedTools")
// Denial is recorded in the event log (invariant #5).
val rejected = events.firstNotNullOfOrNull { it.payload as? ToolExecutionRejectedEvent }
assertNotNull(rejected, "ToolExecutionRejectedEvent must be emitted when tool is not in allowedTools")
assertEquals("file_write", rejected!!.toolName)
assertTrue(
rejected.reason.contains("not permitted"),
"rejection reason should mention 'not permitted', got: ${rejected.reason}",
)
// ToolInvocationRequestedEvent is still emitted so the request itself is in the log.
assertTrue(
events.any { (it.payload as? ToolInvocationRequestedEvent)?.toolName == "file_write" },
"ToolInvocationRequestedEvent must be emitted to record the denied request",
)
}
@Test
fun `empty allowedTools denies all domain tools and emits ToolExecutionRejectedEvent`(): Unit = runBlocking {
val executor = RecordingExecutor()
// Stage has allowedTools = emptySet() — only stage_complete is permitted.
val (orchestrator, eventStore, _) = buildOrchestrator(executor, FakeFileWriteTool(), assessorRule = null)
val sessionId = SessionId("gate-empty-allowed")
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
val graph = singleStageGraph(allowedTools = emptySet())
orchestrator.run(sessionId, graph, config)
val events = eventStore.read(sessionId)
assertTrue(!executor.executeCalled.get(), "executor must NOT be called when allowedTools is empty")
val rejected = events.firstNotNullOfOrNull { it.payload as? ToolExecutionRejectedEvent }
assertNotNull(rejected, "ToolExecutionRejectedEvent must be emitted when allowedTools is empty")
assertEquals("file_write", rejected!!.toolName)
assertTrue(
rejected.reason.contains("not permitted"),
"rejection reason should mention 'not permitted', got: ${rejected.reason}",
)
}
}