feat(toolintent): read-before-write gate (anti-hallucination)

An LLM that edits or overwrites a file it never read is acting on hallucinated
contents. This plane-2 ToolCallRule blocks it: dispatching on FILE_WRITE (covers
file_write and file_edit), a write to a path that exists on disk but was not
file_read earlier this session is BLOCKED. A brand-new file passes — you can't
read what doesn't exist, and creating is legitimate.

- ReadFilesProjection folds the session's file_read events (request + completion,
  so a failed read doesn't count) into the set of paths read, mirroring
  EgressAllowlistProjection; threaded into ToolCallAssessmentInput.sessionReadPaths
  via SessionOrchestrator.resolveSessionReadPaths.
- Read paths and the write target are both resolved through the probe (toRealPath),
  so spelling differences and symlinks can't dodge the gate.
- The orchestrator already feeds a block's rationale back as a tool result, so the
  agent reads the file and retries; the rejected-event reason now carries that
  rationale too (specific audit trail instead of a generic string).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 18:13:06 +00:00
parent 5d48c26ec8
commit 693e38ffac
7 changed files with 313 additions and 1 deletions
@@ -69,6 +69,7 @@ import com.correx.core.events.events.ToolReceipt
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.ReadFilesProjection
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallAssessor
import com.correx.core.toolintent.WorkspacePolicy
@@ -697,7 +698,10 @@ abstract class SessionOrchestrator(
sessionId = sessionId,
toolName = toolCall.function.name,
tier = tier,
reason = "blocked by tool-call policy",
// Record the specific rule rationale, not a generic string, so the audit
// trail (and task history) shows why a call was blocked.
reason = assessment.rationale.joinToString("; ")
.ifBlank { "blocked by tool-call policy" },
),
)
val sourceId = toolCall.id ?: invocationId.value
@@ -965,6 +969,7 @@ abstract class SessionOrchestrator(
paramRoles = tool?.paramRoles ?: emptyMap(),
writeManifest = writeManifest,
sessionEgressHosts = sessionEgressHosts,
sessionReadPaths = resolveSessionReadPaths(sessionId),
),
)
emit(
@@ -994,6 +999,18 @@ abstract class SessionOrchestrator(
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
}
/**
* Folds this session's file_read tool events through [ReadFilesProjection] into the set of paths
* it has successfully read — the input to [com.correx.core.toolintent.rules.ReadBeforeWriteRule].
* Replay-safe (reads the log, never live state); empty before any read completes.
*/
internal fun resolveSessionReadPaths(sessionId: SessionId): Set<String> {
val projection = ReadFilesProjection(sessionId)
return eventStore.read(sessionId)
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
.readPaths
}
private suspend fun buildSchemaEntries(
responseFormat: ResponseFormat,
stageId: StageId,