From 6a779861c2df223fb8787d86fda52fcd764fb9ff Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 25 Jun 2026 06:15:15 +0000 Subject: [PATCH] feat(toolintent): stale-write gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../kotlin/com/correx/apps/server/Main.kt | 2 + .../orchestration/SessionOrchestrator.kt | 4 + .../correx/core/toolintent/SessionContext.kt | 10 +++ .../com/correx/core/toolintent/WorldProbe.kt | 11 +++ .../core/toolintent/rules/StaleWriteRule.kt | 74 +++++++++++++++++++ .../SessionContextProjectionTest.kt | 15 +++- .../core/toolintent/StaleWriteRuleTest.kt | 60 +++++++++++++++ .../tools/filesystem/FileReadTool.kt | 8 ++ 8 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/StaleWriteRule.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 38190c3b..9aaa5462 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -63,6 +63,7 @@ import com.correx.core.toolintent.rules.NetworkHostRule import com.correx.core.toolintent.rules.PathContainmentRule import com.correx.core.toolintent.rules.ReadBeforeWriteRule import com.correx.core.toolintent.rules.ReferenceExistsRule +import com.correx.core.toolintent.rules.StaleWriteRule import com.correx.core.toolintent.rules.WriteScopeRule import com.correx.apps.server.freestyle.FreestyleDriver import com.correx.apps.server.inference.summarizeWithInference @@ -269,6 +270,7 @@ fun main() { ReadBeforeWriteRule(), ReferenceExistsRule(), WriteScopeRule(), + StaleWriteRule(), ManifestContainmentRule(), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), NetworkHostRule( diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index c9b0826d..7515962f 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -1289,6 +1289,10 @@ abstract class SessionOrchestrator( toolName = toolCall.function.name, exitCode = result.exitCode, 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() }, durationMs = 0, tier = tier, diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt index 599f2dfd..efc63ed1 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt @@ -19,6 +19,9 @@ data class SessionContext( val reads: Set = emptySet(), val writes: Set = emptySet(), 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 = emptyMap(), val pendingReads: Map> = emptyMap(), ) @@ -60,9 +63,16 @@ class SessionContextProjection(private val sessionId: SessionId) : Projection): 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() + val observations = mutableListOf() + 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" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt index 0f1053a7..7857422f 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt @@ -45,7 +45,11 @@ class SessionContextProjectionTest { ), ) - private fun completed(invId: String, affected: List = emptyList()) = stored( + private fun completed( + invId: String, + affected: List = emptyList(), + structured: Map = emptyMap(), + ) = stored( ToolExecutionCompletedEvent( invocationId = ToolInvocationId(invId), sessionId = session, @@ -55,6 +59,7 @@ class SessionContextProjectionTest { toolName = "tool", exitCode = 0, outputSummary = "ok", + structuredOutput = structured, affectedEntities = affected, durationMs = 1, tier = Tier.T1, @@ -79,6 +84,14 @@ class SessionContextProjectionTest { 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 fun `a SessionWorkingTask event sets the active task and the latest wins`() { val ctx = fold( diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt new file mode 100644 index 00000000..2e091c4c --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt @@ -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) : 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) + } +} diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt index 6aca46fa..526d8a7b 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt @@ -142,9 +142,13 @@ class FileReadTool( val start = ((startLine ?: 1) - 1).coerceAtLeast(0) val end = (endLine ?: lines.size).coerceAtMost(lines.size) 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( invocationId = request.invocationId, output = content, + metadata = metadata, ) }.getOrElse { 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 { val entries = path.listDirectoryEntries().sortedBy { it.name } val output = entries.joinToString("\n") { entry ->