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:
@@ -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(
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
@@ -19,6 +19,9 @@ data class SessionContext(
|
||||
val reads: Set<String> = emptySet(),
|
||||
val writes: Set<String> = 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<String, 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 {
|
||||
if (payload.sessionId != sessionId) return state
|
||||
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(
|
||||
reads = if (justRead != null) state.reads + justRead else state.reads,
|
||||
writes = state.writes + payload.receipt.affectedEntities,
|
||||
readHashes = newHashes,
|
||||
pendingReads = state.pendingReads - payload.invocationId.value,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.correx.core.toolintent
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Abstracts filesystem observation so rules are testable and replay never re-stats. */
|
||||
interface WorldProbe {
|
||||
@@ -9,6 +10,11 @@ interface WorldProbe {
|
||||
|
||||
/** Symlink-resolved real path if it exists; otherwise the normalized absolute 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 {
|
||||
@@ -16,4 +22,9 @@ class FileSystemWorldProbe : WorldProbe {
|
||||
|
||||
override fun resolveReal(path: Path): Path =
|
||||
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"
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -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(
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+8
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user