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 ed7cf871..9b1e6bd5 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 @@ -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 -> diff --git a/testing/integration/src/test/kotlin/ToolCallGateTest.kt b/testing/integration/src/test/kotlin/ToolCallGateTest.kt index 763d067a..b5b4f2c3 100644 --- a/testing/integration/src/test/kotlin/ToolCallGateTest.kt +++ b/testing/integration/src/test/kotlin/ToolCallGateTest.kt @@ -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}", + ) + } }