fix(toolintent): key the read-before-write exemption on content provenance, not parameter shape

The exemption added in 6a8a7b31 was broader than the invariant it stood on. "Tool
declares a SOURCE_PATH" is a claim about the parameter list; the safe property is
"every byte written derives from an existing source object rather than from
model-supplied content". A future transform or import tool could name a source and
still write model-controlled output, and would have inherited the exemption.

ToolCapability.CONTENT_FROM_SOURCE now carries that provenance claim explicitly.
file_copy declares it; ReadBeforeWriteRule.appliesTo stands down only for calls that
do, so ToolCallAssessor skips the rule rather than the rule skipping itself. The
capability is recorded on the invocation event like every other one, so replay
classifies a call by what it actually claimed instead of re-deriving it from
parameters.

Tool availability is by declared tool name, not capability-set containment, so the
extra capability does not narrow which stages can reach file_copy.

Tests: the exemption is asserted through ToolCallAssessor, plus a source-naming tool
WITHOUT the provenance capability that stays gated. ./gradlew check green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 12:04:04 +04:00
parent 6a8a7b31c1
commit d18075925d
7 changed files with 55 additions and 14 deletions
@@ -6,7 +6,6 @@ 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.ParamRole
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolPath
import com.correx.core.validation.model.ValidationIssue
@@ -26,15 +25,16 @@ import java.nio.file.Path
class ReadBeforeWriteRule : ToolCallRule {
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
ToolCapability.FILE_WRITE in capabilities
ToolCapability.FILE_WRITE in capabilities &&
// A call whose bytes come entirely from an existing source object
// ([ToolCapability.CONTENT_FROM_SOURCE]) has nothing for this gate to protect: there is
// no model-authored content to clobber the file with. The exemption keys on that
// declared provenance, NOT on the presence of a source-path parameter — a future
// transform or import tool could name a source and still write model-controlled output,
// and must stay gated.
ToolCapability.CONTENT_FROM_SOURCE !in capabilities
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
// A call that declares a SOURCE_PATH takes its content from a file on disk, not from the
// model's memory, so there is nothing for this gate to protect: demanding a file_read of the
// source (or of the target being replaced by it) would force the very bytes-through-context
// round-trip that such tools exist to avoid — and is unsatisfiable for a binary file.
if (input.paramRoles.containsValue(ParamRole.SOURCE_PATH)) return ToolCallAssessment()
val root = input.workspace.workspaceRoot
val readReal = input.session.reads.map { realOf(input, root, it) }.toSet()
@@ -14,6 +14,7 @@ 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
/**
@@ -114,14 +115,17 @@ class PathNormalizationRuleTest {
}
@Test
fun `a content-from-disk call is exempt from read-before-write even when the dest exists`() {
fun `a content-from-source call is exempt from read-before-write`() {
// Overwriting an existing binary via a copy must not demand a file_read of it first — that
// read is impossible to satisfy usefully and is exactly the round-trip file_copy removes.
// The exemption lives in appliesTo, so ToolCallAssessor never runs the gate for such a call.
val exempt = setOf(ToolCapability.FILE_WRITE, ToolCapability.CONTENT_FROM_SOURCE)
assertFalse(ReadBeforeWriteRule().appliesTo(exempt))
val dest = Path.of("/work/project/public/logo.png")
val r = ReadBeforeWriteRule().assess(
val r = ToolCallAssessor(listOf(ReadBeforeWriteRule())).assess(
input(
mapOf("source" to "assets/logo.png", "dest" to "public/logo.png"),
setOf(ToolCapability.FILE_WRITE),
exempt,
FakeProbe(existing = setOf(dest)),
paramRoles = mapOf("source" to ParamRole.SOURCE_PATH, "dest" to ParamRole.PATH),
tool = "file_copy",
@@ -131,6 +135,26 @@ class PathNormalizationRuleTest {
assertTrue(r.issues.isEmpty())
}
@Test
fun `naming a source does not by itself earn the exemption`() {
// The invariant is content provenance, not parameter shape: a hypothetical transform tool
// that reads a source AND writes model-authored output stays gated.
val target = "/work/project/src/A.kt"
val capabilities = setOf(ToolCapability.FILE_WRITE)
assertTrue(ReadBeforeWriteRule().appliesTo(capabilities))
val r = ReadBeforeWriteRule().assess(
input(
mapOf("source" to "template.kt", "path" to target),
capabilities,
FakeProbe(existing = setOf(Path.of(target))),
paramRoles = mapOf("source" to ParamRole.SOURCE_PATH, "path" to ParamRole.PATH),
tool = "file_transform",
),
)
assertEquals(RiskAction.BLOCK, r.disposition)
assertEquals("READ_BEFORE_WRITE", r.issues.single().code)
}
@Test
fun `a model-authored write still requires a prior read`() {
val target = "/work/project/src/A.kt"