feat(toolintent): stale-write gate

Block writing a file whose on-disk content changed since the session read it — a
concurrent/external edit the agent's view doesn't reflect. file_read records a
content hash on whole-file reads; the orchestrator's completion path now carries
the tool's structuredOutput (it previously dropped it, unlike SandboxedToolExecutor
— the two paths were inconsistent), so SessionContext.readHashes folds it.
StaleWriteRule compares the current hash (WorldProbe.contentHash) against the
read-time hash and BLOCKs a mismatch, telling the agent to re-read first. Files the
session wrote itself are excluded, so its own edits never look stale; partial reads
set no baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 06:15:15 +00:00
parent 4159361690
commit 6a779861c2
8 changed files with 183 additions and 1 deletions
@@ -63,6 +63,7 @@ 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.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.core.toolintent.rules.ReferenceExistsRule import com.correx.core.toolintent.rules.ReferenceExistsRule
import com.correx.core.toolintent.rules.StaleWriteRule
import com.correx.core.toolintent.rules.WriteScopeRule import com.correx.core.toolintent.rules.WriteScopeRule
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
@@ -269,6 +270,7 @@ fun main() {
ReadBeforeWriteRule(), ReadBeforeWriteRule(),
ReferenceExistsRule(), ReferenceExistsRule(),
WriteScopeRule(), WriteScopeRule(),
StaleWriteRule(),
ManifestContainmentRule(), ManifestContainmentRule(),
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
NetworkHostRule( NetworkHostRule(
@@ -1289,6 +1289,10 @@ abstract class SessionOrchestrator(
toolName = toolCall.function.name, toolName = toolCall.function.name,
exitCode = result.exitCode, exitCode = result.exitCode,
outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT), outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
// Carry the tool's structured metadata (e.g. file_read's contentHash) so
// session projections can read it — matching SandboxedToolExecutor, which
// already does this; the two completion paths were inconsistent.
structuredOutput = result.metadata,
affectedEntities = affected.map { it.toString() }, affectedEntities = affected.map { it.toString() },
durationMs = 0, durationMs = 0,
tier = tier, tier = tier,
@@ -19,6 +19,9 @@ data class SessionContext(
val reads: Set<String> = emptySet(), val reads: Set<String> = emptySet(),
val writes: Set<String> = emptySet(), val writes: Set<String> = emptySet(),
val activeTask: ActiveTask? = null, val activeTask: ActiveTask? = null,
// Path -> content hash captured when the file was last fully read this session; the stale-write
// gate compares it against the file's current hash. Only whole-file reads contribute.
val readHashes: Map<String, String> = emptyMap(),
val pendingReads: Map<String, List<String>> = emptyMap(), val pendingReads: Map<String, List<String>> = emptyMap(),
) )
@@ -60,9 +63,16 @@ class SessionContextProjection(private val sessionId: SessionId) : Projection<Se
private fun recordCompletion(state: SessionContext, payload: ToolExecutionCompletedEvent): SessionContext { private fun recordCompletion(state: SessionContext, payload: ToolExecutionCompletedEvent): SessionContext {
if (payload.sessionId != sessionId) return state if (payload.sessionId != sessionId) return state
val justRead = state.pendingReads[payload.invocationId.value] val justRead = state.pendingReads[payload.invocationId.value]
val hash = payload.receipt.structuredOutput["contentHash"] as? String
val newHashes = if (justRead != null && hash != null) {
state.readHashes + justRead.associateWith { hash }
} else {
state.readHashes
}
return state.copy( return state.copy(
reads = if (justRead != null) state.reads + justRead else state.reads, reads = if (justRead != null) state.reads + justRead else state.reads,
writes = state.writes + payload.receipt.affectedEntities, writes = state.writes + payload.receipt.affectedEntities,
readHashes = newHashes,
pendingReads = state.pendingReads - payload.invocationId.value, pendingReads = state.pendingReads - payload.invocationId.value,
) )
} }
@@ -2,6 +2,7 @@ package com.correx.core.toolintent
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.security.MessageDigest
/** Abstracts filesystem observation so rules are testable and replay never re-stats. */ /** Abstracts filesystem observation so rules are testable and replay never re-stats. */
interface WorldProbe { interface WorldProbe {
@@ -9,6 +10,11 @@ interface WorldProbe {
/** Symlink-resolved real path if it exists; otherwise the normalized absolute path. */ /** Symlink-resolved real path if it exists; otherwise the normalized absolute path. */
fun resolveReal(path: Path): Path fun resolveReal(path: Path): Path
/** Content hash of the file's current bytes, or null if it can't be read. Used by the
* stale-write gate to compare against the hash captured when the file was read. Defaulted
* so probes/fakes that don't observe content need not implement it. */
fun contentHash(path: Path): String? = null
} }
class FileSystemWorldProbe : WorldProbe { class FileSystemWorldProbe : WorldProbe {
@@ -16,4 +22,9 @@ class FileSystemWorldProbe : WorldProbe {
override fun resolveReal(path: Path): Path = override fun resolveReal(path: Path): Path =
runCatching { path.toRealPath() }.getOrElse { path.toAbsolutePath().normalize() } runCatching { path.toRealPath() }.getOrElse { path.toAbsolutePath().normalize() }
override fun contentHash(path: Path): String? = runCatching {
MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path))
.joinToString("") { "%02x".format(it) }
}.getOrNull()
} }
@@ -0,0 +1,74 @@
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
/**
* Stale-write gate. Blocks writing a file whose on-disk content has changed since the session read
* it — the agent's view is stale (a concurrent/external edit). Only files that were *fully* read
* baseline a hash; files the session has itself written this run are excluded, so the agent's own
* edits never look stale. Catches the genuine "someone/something changed it under me" case without
* tripping on normal read→edit flow. The block tells the agent to re-read before writing.
*/
class StaleWriteRule : ToolCallRule {
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
ToolCapability.FILE_WRITE in capabilities
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
val readHashes = input.session.readHashes
if (readHashes.isEmpty()) return ToolCallAssessment()
val root = input.workspace.workspaceRoot
val hashByReal = readHashes.entries.associate { realOf(input, root, it.key) to it.value }
val writtenReal = input.session.writes.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 resolved = resolveInput(root, raw)
val real = input.probe.resolveReal(resolved)
val recorded = hashByReal[real]
if (recorded == null || real in writtenReal) continue // not read in full, or self-edited
val current = input.probe.contentHash(resolved)
val stale = current != null && current != recorded
observations += ToolCallObservation(
ruleCode = RULE_CODE,
facts = mapOf("path" to raw, "stale" to stale.toString()),
)
if (stale) {
issues += ValidationIssue(
code = RULE_CODE,
message = "Tool '${input.request.toolName}' targets '$raw', which changed on disk " +
"since you read it — re-read it before writing so you don't clobber the change.",
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 = "STALE_WRITE"
}
}
@@ -45,7 +45,11 @@ class SessionContextProjectionTest {
), ),
) )
private fun completed(invId: String, affected: List<String> = emptyList()) = stored( private fun completed(
invId: String,
affected: List<String> = emptyList(),
structured: Map<String, Any> = emptyMap(),
) = stored(
ToolExecutionCompletedEvent( ToolExecutionCompletedEvent(
invocationId = ToolInvocationId(invId), invocationId = ToolInvocationId(invId),
sessionId = session, sessionId = session,
@@ -55,6 +59,7 @@ class SessionContextProjectionTest {
toolName = "tool", toolName = "tool",
exitCode = 0, exitCode = 0,
outputSummary = "ok", outputSummary = "ok",
structuredOutput = structured,
affectedEntities = affected, affectedEntities = affected,
durationMs = 1, durationMs = 1,
tier = Tier.T1, tier = Tier.T1,
@@ -79,6 +84,14 @@ class SessionContextProjectionTest {
assertTrue(ctx.reads.isEmpty()) assertTrue(ctx.reads.isEmpty())
} }
@Test
fun `a full read records its content hash for the read path`() {
val ctx = fold(
listOf(readRequested("r1", "src/A.kt"), completed("r1", structured = mapOf("contentHash" to "H1"))),
)
assertEquals(mapOf("src/A.kt" to "H1"), ctx.readHashes)
}
@Test @Test
fun `a SessionWorkingTask event sets the active task and the latest wins`() { fun `a SessionWorkingTask event sets the active task and the latest wins`() {
val ctx = fold( val ctx = fold(
@@ -0,0 +1,60 @@
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.StaleWriteRule
import com.correx.core.tools.contract.ToolCapability
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class StaleWriteRuleTest {
private val workspace = Path.of("/work/project")
private val rule = StaleWriteRule()
private val target = "/work/project/src/A.kt"
private class FakeProbe(private val hashes: Map<Path, String>) : WorldProbe {
override fun exists(path: Path) = true
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
override fun contentHash(path: Path): String? = hashes[path.toAbsolutePath().normalize()]
}
private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize()
private fun input(onDisk: String, session: SessionContext) = ToolCallAssessmentInput(
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_edit", mapOf("path" to target)),
capabilities = setOf(ToolCapability.FILE_WRITE),
workspace = WorkspacePolicy(workspace, emptyList()),
probe = FakeProbe(mapOf(abs(target) to onDisk)),
session = session,
)
@Test
fun `unchanged since read proceeds`() {
val r = rule.assess(input(onDisk = "H1", SessionContext(readHashes = mapOf(target to "H1"))))
assertEquals(RiskAction.PROCEED, r.disposition)
}
@Test
fun `changed on disk since read is blocked`() {
val r = rule.assess(input(onDisk = "H2", SessionContext(readHashes = mapOf(target to "H1"))))
assertEquals(RiskAction.BLOCK, r.disposition)
assertEquals("STALE_WRITE", r.issues.single().code)
}
@Test
fun `a file the session wrote itself is not stale`() {
val session = SessionContext(readHashes = mapOf(target to "H1"), writes = setOf(target))
assertEquals(RiskAction.PROCEED, rule.assess(input(onDisk = "H2", session)).disposition)
}
@Test
fun `a file never fully read is not this gate's concern`() {
assertEquals(RiskAction.PROCEED, rule.assess(input(onDisk = "H2", SessionContext())).disposition)
}
}
@@ -142,9 +142,13 @@ class FileReadTool(
val start = ((startLine ?: 1) - 1).coerceAtLeast(0) val start = ((startLine ?: 1) - 1).coerceAtLeast(0)
val end = (endLine ?: lines.size).coerceAtMost(lines.size) val end = (endLine ?: lines.size).coerceAtMost(lines.size)
val content = lines.subList(start, end).joinToString("\n") val content = lines.subList(start, end).joinToString("\n")
// Record a content hash only for a whole-file read, so the stale-write gate baselines against
// what the agent actually saw; a partial read establishes no baseline.
val metadata = if (startLine == null && endLine == null) mapOf("contentHash" to sha256(path)) else emptyMap()
ToolResult.Success( ToolResult.Success(
invocationId = request.invocationId, invocationId = request.invocationId,
output = content, output = content,
metadata = metadata,
) )
}.getOrElse { }.getOrElse {
ToolResult.Failure( ToolResult.Failure(
@@ -154,6 +158,10 @@ class FileReadTool(
) )
} }
private fun sha256(path: Path): String =
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path))
.joinToString("") { "%02x".format(it) }
private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching { private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching {
val entries = path.listDirectoryEntries().sortedBy { it.name } val entries = path.listDirectoryEntries().sortedBy { it.name }
val output = entries.joinToString("\n") { entry -> val output = entries.joinToString("\n") { entry ->