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
@@ -406,6 +406,42 @@ abstract class SessionOrchestrator(
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(
sessionId, stageId, invocationId, toolCall.function.name, request, tool, effectives,
)?.let { assessment ->