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:
+18
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user