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
@@ -61,6 +61,7 @@ import com.correx.core.toolintent.rules.ExecInterpreterRule
import com.correx.core.toolintent.rules.ManifestContainmentRule import com.correx.core.toolintent.rules.ManifestContainmentRule
import com.correx.core.toolintent.rules.NetworkHostRule import com.correx.core.toolintent.rules.NetworkHostRule
import com.correx.core.toolintent.rules.PathContainmentRule import com.correx.core.toolintent.rules.PathContainmentRule
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.apps.server.freestyle.FreestyleDriver import com.correx.apps.server.freestyle.FreestyleDriver
import com.correx.apps.server.inference.summarizeWithInference import com.correx.apps.server.inference.summarizeWithInference
import com.correx.core.kernel.orchestration.JournalCompactionService import com.correx.core.kernel.orchestration.JournalCompactionService
@@ -261,6 +262,7 @@ fun main() {
val toolCallAssessor = ToolCallAssessor( val toolCallAssessor = ToolCallAssessor(
rules = listOf( rules = listOf(
PathContainmentRule(), PathContainmentRule(),
ReadBeforeWriteRule(),
ManifestContainmentRule(), ManifestContainmentRule(),
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
NetworkHostRule( NetworkHostRule(
@@ -69,6 +69,7 @@ import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest import com.correx.core.events.events.ToolRequest
import com.correx.core.events.risk.RiskAction import com.correx.core.events.risk.RiskAction
import com.correx.core.events.risk.RiskSummary import com.correx.core.events.risk.RiskSummary
import com.correx.core.toolintent.ReadFilesProjection
import com.correx.core.toolintent.ToolCallAssessmentInput import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallAssessor import com.correx.core.toolintent.ToolCallAssessor
import com.correx.core.toolintent.WorkspacePolicy import com.correx.core.toolintent.WorkspacePolicy
@@ -697,7 +698,10 @@ abstract class SessionOrchestrator(
sessionId = sessionId, sessionId = sessionId,
toolName = toolCall.function.name, toolName = toolCall.function.name,
tier = tier, 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 val sourceId = toolCall.id ?: invocationId.value
@@ -965,6 +969,7 @@ abstract class SessionOrchestrator(
paramRoles = tool?.paramRoles ?: emptyMap(), paramRoles = tool?.paramRoles ?: emptyMap(),
writeManifest = writeManifest, writeManifest = writeManifest,
sessionEgressHosts = sessionEgressHosts, sessionEgressHosts = sessionEgressHosts,
sessionReadPaths = resolveSessionReadPaths(sessionId),
), ),
) )
emit( emit(
@@ -994,6 +999,18 @@ abstract class SessionOrchestrator(
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) } .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( private suspend fun buildSchemaEntries(
responseFormat: ResponseFormat, responseFormat: ResponseFormat,
stageId: StageId, stageId: StageId,
@@ -0,0 +1,54 @@
package com.correx.core.toolintent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.Projection
import com.correx.core.toolintent.rules.candidatePathStrings
/** Reads still awaiting their completion event, plus the paths confirmed read so far. */
data class ReadFilesState(
val pending: Map<String, List<String>> = emptyMap(),
val readPaths: Set<String> = emptySet(),
)
/**
* Folds one session's tool events into the set of file paths it has successfully read. A `file_read`
* counts only once its [ToolExecutionCompletedEvent] lands — a read that failed (file not found)
* never enters the set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule].
* Paths are kept as the raw arguments; the rule resolves both sides through the probe, so
* `./Foo.kt` read and `Foo.kt` written still match. The same projection precedent as
* [com.correx.core.sessions.projections.EgressAllowlistProjection].
*/
class ReadFilesProjection(private val sessionId: SessionId) : Projection<ReadFilesState> {
override fun initial(): ReadFilesState = ReadFilesState()
override fun apply(state: ReadFilesState, event: StoredEvent): ReadFilesState =
when (val payload = event.payload) {
is ToolInvocationRequestedEvent -> recordPending(state, payload)
is ToolExecutionCompletedEvent -> confirmRead(state, payload)
else -> state
}
private fun recordPending(state: ReadFilesState, payload: ToolInvocationRequestedEvent): ReadFilesState {
if (payload.sessionId != sessionId || payload.toolName != FILE_READ_TOOL) return state
val paths = candidatePathStrings(emptyMap(), payload.request.parameters)
if (paths.isEmpty()) return state
return state.copy(pending = state.pending + (payload.invocationId.value to paths))
}
private fun confirmRead(state: ReadFilesState, payload: ToolExecutionCompletedEvent): ReadFilesState {
val paths = state.pending[payload.invocationId.value]
if (payload.sessionId != sessionId || paths == null) return state
return state.copy(
pending = state.pending - payload.invocationId.value,
readPaths = state.readPaths + paths,
)
}
private companion object {
const val FILE_READ_TOOL = "file_read"
}
}
@@ -29,6 +29,10 @@ data class ToolCallAssessmentInput(
// EgressAllowlistProjection). Unioned with the static allow-list at the network gate; empty // EgressAllowlistProjection). Unioned with the static allow-list at the network gate; empty
// means "no per-session grants", which never narrows the static allow-list. // means "no per-session grants", which never narrows the static allow-list.
val sessionEgressHosts: Set<String> = emptySet(), val sessionEgressHosts: Set<String> = emptySet(),
// Paths this session has successfully file_read (folded via ReadFilesProjection). The input to
// ReadBeforeWriteRule; empty means nothing has been read yet (so any write to an existing file
// is unread). Raw argument strings — the rule resolves both sides through the probe.
val sessionReadPaths: Set<String> = emptySet(),
) )
data class ToolCallAssessment( data class ToolCallAssessment(
@@ -0,0 +1,72 @@
package com.correx.core.toolintent.rules
import com.correx.core.events.events.ToolCallObservation
import com.correx.core.events.risk.RiskAction
import com.correx.core.toolintent.ToolCallAssessment
import com.correx.core.toolintent.ToolCallAssessmentInput
import com.correx.core.toolintent.ToolCallRule
import com.correx.core.toolintent.maxAction
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.validation.model.ValidationIssue
import com.correx.core.validation.model.ValidationSeverity
import java.nio.file.Path
/**
* Read-before-write gate (anti-hallucination). Dispatches on FILE_WRITE, covering both file_write
* and file_edit. A write to a path that exists on disk but was not file_read earlier this session
* is BLOCKED — the classic "edited a file whose contents it never saw" failure. A brand-new file
* (not on disk) passes: you cannot read what does not exist, and creating one is legitimate.
*
* The block message names the path; the orchestrator feeds the rationale back as a tool result, so
* the agent reads the file and retries. Read paths and the write target are both resolved through
* the probe (toRealPath), so `./Foo.kt` read then `Foo.kt` written match, and symlinks can't dodge it.
*/
class ReadBeforeWriteRule : ToolCallRule {
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
ToolCapability.FILE_WRITE in capabilities
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
val root = input.workspace.workspaceRoot
val readReal = input.sessionReadPaths.map { realOf(input, root, it) }.toSet()
val issues = mutableListOf<ValidationIssue>()
val observations = mutableListOf<ToolCallObservation>()
var disposition = RiskAction.PROCEED
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
val resolvedInput = resolveInput(root, raw)
val exists = input.probe.exists(resolvedInput)
val read = input.probe.resolveReal(resolvedInput) in readReal
observations += ToolCallObservation(
ruleCode = RULE_CODE,
facts = mapOf("path" to raw, "exists" to exists.toString(), "read" to read.toString()),
)
if (exists && !read) {
issues += ValidationIssue(
code = RULE_CODE,
message = "Tool '${input.request.toolName}' targets '$raw', which you have not " +
"read this session — call file_read on it before writing or editing.",
severity = ValidationSeverity.ERROR,
)
disposition = maxAction(disposition, RiskAction.BLOCK)
}
}
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
}
private fun resolveInput(root: Path, raw: String): Path {
val candidate = Path.of(raw)
return if (candidate.isAbsolute) candidate else root.resolve(candidate)
}
private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path =
input.probe.resolveReal(resolveInput(root, raw))
private companion object {
const val RULE_CODE = "READ_BEFORE_WRITE"
}
}
@@ -0,0 +1,80 @@
package com.correx.core.toolintent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.core.tools.contract.ToolCapability
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ReadBeforeWriteRuleTest {
private val workspace = Path.of("/work/project")
private val rule = ReadBeforeWriteRule()
/** A probe where existence is configurable; resolveReal just normalizes (no symlinks). */
private class FakeProbe(private val existing: Set<Path> = emptySet()) : WorldProbe {
override fun exists(path: Path): Boolean = path.toAbsolutePath().normalize() in existing
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
}
private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize()
private fun input(
pathArg: String,
probe: WorldProbe,
read: Set<String> = emptySet(),
tool: String = "file_edit",
) = ToolCallAssessmentInput(
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), tool, mapOf("path" to pathArg)),
capabilities = setOf(ToolCapability.FILE_WRITE),
workspace = WorkspacePolicy(workspace, emptyList()),
probe = probe,
sessionReadPaths = read,
)
@Test
fun `applies only to FILE_WRITE`() {
assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_WRITE)))
assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_READ)))
assertFalse(rule.appliesTo(emptySet()))
}
@Test
fun `editing an existing file that was not read is blocked`() {
val target = "/work/project/src/A.kt"
val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target)))))
assertEquals(RiskAction.BLOCK, r.disposition)
assertEquals("READ_BEFORE_WRITE", r.issues.single().code)
assertEquals("false", r.observations.single().facts["read"])
}
@Test
fun `editing an existing file that was read proceeds`() {
val target = "/work/project/src/A.kt"
val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))), read = setOf(target)))
assertEquals(RiskAction.PROCEED, r.disposition)
assertTrue(r.issues.isEmpty())
}
@Test
fun `creating a brand-new file proceeds without a prior read`() {
val target = "/work/project/src/New.kt" // not in existing → new file
val r = rule.assess(input(target, FakeProbe(existing = emptySet()), tool = "file_write"))
assertEquals(RiskAction.PROCEED, r.disposition)
}
@Test
fun `a relative read of the same file satisfies an absolute write`() {
val target = "/work/project/src/A.kt"
// read recorded as a workspace-relative path; resolves to the same real path as the write.
val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))), read = setOf("src/A.kt")))
assertEquals(RiskAction.PROCEED, r.disposition)
}
}
@@ -0,0 +1,83 @@
package com.correx.core.toolintent
import com.correx.core.approvals.Tier
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import kotlinx.datetime.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ReadFilesProjectionTest {
private val session = SessionId("s")
private val projection = ReadFilesProjection(session)
private var seq = 0L
private fun stored(payload: EventPayload) = StoredEvent(
metadata = EventMetadata(EventId("e-${seq}"), session, Instant.parse("2026-01-01T00:00:00Z"), 1, null, null),
sequence = ++seq,
sessionSequence = seq,
payload = payload,
)
private fun readRequested(invId: String, path: String) = stored(
ToolInvocationRequestedEvent(
invocationId = ToolInvocationId(invId),
sessionId = session,
stageId = StageId("st"),
toolName = "file_read",
tier = Tier.T1,
request = ToolRequest(ToolInvocationId(invId), session, StageId("st"), "file_read", mapOf("path" to path)),
),
)
private fun completed(invId: String, tool: String = "file_read") = stored(
ToolExecutionCompletedEvent(
invocationId = ToolInvocationId(invId),
sessionId = session,
toolName = tool,
receipt = ToolReceipt(
invocationId = ToolInvocationId(invId),
toolName = tool,
exitCode = 0,
outputSummary = "ok",
durationMs = 1,
tier = Tier.T1,
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
),
),
)
private fun fold(events: List<StoredEvent>) =
events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) }
@Test
fun `a completed file_read adds the path to the read set`() {
val state = fold(listOf(readRequested("r1", "src/A.kt"), completed("r1")))
assertEquals(setOf("src/A.kt"), state.readPaths)
}
@Test
fun `a read still pending its completion does not count`() {
val state = fold(listOf(readRequested("r1", "src/A.kt")))
assertTrue(state.readPaths.isEmpty())
assertTrue(state.pending.containsKey("r1"))
}
@Test
fun `a non-read tool completion is ignored`() {
// file_write request never enters pending, so its completion adds nothing.
val state = fold(listOf(completed("w1", tool = "file_write")))
assertTrue(state.readPaths.isEmpty())
}
}