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:
+36
@@ -406,6 +406,42 @@ abstract class SessionOrchestrator(
|
|||||||
request = request,
|
request = request,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if (toolCall.function.name != STAGE_COMPLETE_TOOL &&
|
||||||
|
toolCall.function.name !in stageConfig.allowedTools
|
||||||
|
) {
|
||||||
|
val denyReason = "tool '${toolCall.function.name}' is not permitted in stage '${stageId.value}'"
|
||||||
|
emit(
|
||||||
|
sessionId,
|
||||||
|
ToolExecutionRejectedEvent(
|
||||||
|
invocationId = invocationId,
|
||||||
|
sessionId = sessionId,
|
||||||
|
toolName = toolCall.function.name,
|
||||||
|
tier = tier,
|
||||||
|
reason = denyReason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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 = "ERROR: $denyReason",
|
||||||
|
tokenEstimate = estimateTokens(denyReason),
|
||||||
|
role = EntryRole.TOOL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
val plane2Risk: RiskSummary? = runPlane2Assessment(
|
val plane2Risk: RiskSummary? = runPlane2Assessment(
|
||||||
sessionId, stageId, invocationId, toolCall.function.name, request, tool, effectives,
|
sessionId, stageId, invocationId, toolCall.function.name, request, tool, effectives,
|
||||||
)?.let { assessment ->
|
)?.let { assessment ->
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.correx.core.artifacts.DefaultArtifactReducer
|
|||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.ToolCallAssessedEvent
|
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
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.events.ToolRequest
|
||||||
import com.correx.core.events.execution.RetryPolicy
|
import com.correx.core.events.execution.RetryPolicy
|
||||||
import com.correx.core.events.risk.RiskAction
|
import com.correx.core.events.risk.RiskAction
|
||||||
@@ -371,4 +372,57 @@ class ToolCallGateTest {
|
|||||||
val toolEntry = secondPack.layers.values.flatten().first { it.sourceType == "toolResult" }
|
val toolEntry = secondPack.layers.values.flatten().first { it.sourceType == "toolResult" }
|
||||||
assertEquals("line1\nline2\nline3", toolEntry.content)
|
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}",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user