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:
@@ -61,6 +61,7 @@ import com.correx.core.toolintent.rules.ExecInterpreterRule
|
||||
import com.correx.core.toolintent.rules.ManifestContainmentRule
|
||||
import com.correx.core.toolintent.rules.NetworkHostRule
|
||||
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.inference.summarizeWithInference
|
||||
import com.correx.core.kernel.orchestration.JournalCompactionService
|
||||
@@ -261,6 +262,7 @@ fun main() {
|
||||
val toolCallAssessor = ToolCallAssessor(
|
||||
rules = listOf(
|
||||
PathContainmentRule(),
|
||||
ReadBeforeWriteRule(),
|
||||
ManifestContainmentRule(),
|
||||
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
|
||||
NetworkHostRule(
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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
|
||||
// means "no per-session grants", which never narrows the static allow-list.
|
||||
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(
|
||||
|
||||
+72
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user