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.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(
|
||||||
|
|||||||
+4
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+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(
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -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 ->
|
||||||
|
|||||||
Reference in New Issue
Block a user