feat(tools): clobber guard blocks file_write overwriting real code with elided stubs (#245)

A full file_write that shrinks a non-trivial existing file (>=30 non-blank
lines) by >60% into a stub with literal `...` placeholders is almost always
the model rewriting a file from memory instead of editing it — the incident
that silently broke SessionRoutes.kt (273->28 lines) in run f11afb08. Reject it
(recoverable) and steer to file_edit. Requires BOTH the hard shrink AND elision
markers, so dead-code refactors and new-file scaffolds pass untouched. Runs
before the write regardless of approval tier, so the auto-driver can't wave it
through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 14:05:53 +04:00
parent e25e9e46fd
commit 0e1095e9ba
2 changed files with 67 additions and 0 deletions
@@ -154,6 +154,15 @@ class FileWriteTool(
val originalContent = runCatching { Files.readString(path) }.getOrDefault("")
// Clobber guard (#245): a full overwrite that shrinks an existing non-trivial file to an
// elided stub (literal `...` placeholders) is almost always the model writing a file from
// memory instead of editing it — the incident that silently broke SessionRoutes.kt. Block it
// (recoverable) and force a targeted file_edit. Independent of approval tier because the
// auto-driver approves T2 writes without a human looking.
clobberReason(originalContent, content)?.let { reason ->
return@withContext ToolResult.Failure(request.invocationId, reason, recoverable = true)
}
val result = runCatching {
Files.createDirectories(path.parent)
AtomicFileWriter.write(path, content.toByteArray(Charsets.UTF_8))
@@ -180,6 +189,30 @@ class FileWriteTool(
}
}
/**
* Returns a rejection reason when [newContent] would overwrite a non-trivial existing file
* ([oldContent]) with a drastically-shrunk elided stub, else null. Requires BOTH a big shrink
* and elision markers so legitimate refactors that only shrink (dead-code removal) and new-file
* scaffolds that only elide (target didn't exist → oldContent is "") both pass untouched.
*/
private fun clobberReason(oldContent: String, newContent: String): String? {
val oldLines = oldContent.lines().count { it.isNotBlank() }
if (oldLines < MIN_GUARDED_LINES) return null
val newLines = newContent.lines().count { it.isNotBlank() }
val shrankHard = newLines < oldLines * MAX_SHRINK_RATIO
if (!shrankHard || !hasElisionMarker(newContent)) return null
return "Refusing full overwrite: this replaces $oldLines lines of existing code with a " +
"$newLines-line stub containing '...' placeholders. Use file_edit (operation 'replace') " +
"for a targeted change instead of rewriting the whole file from memory."
}
/** A line that is just an elision placeholder (`...`, `// ...`, `# ... existing code ...`). */
private fun hasElisionMarker(content: String): Boolean =
content.lineSequence().any { line ->
val t = line.trim().trim('/', '#', '*', '<', '!', '-', ' ')
t == "..." || t == "" || t.endsWith(" ...") || t.endsWith("")
}
private fun handleExecutionException(
e: Throwable,
invocationId: ToolInvocationId,
@@ -207,4 +240,9 @@ class FileWriteTool(
recoverable = false,
)
}
private companion object {
const val MIN_GUARDED_LINES = 30
const val MAX_SHRINK_RATIO = 0.4
}
}
@@ -132,6 +132,35 @@ class FileWriteToolTest {
assertEquals("hello", Files.readString(target))
}
@Test
fun `clobber guard blocks overwriting real code with an elided stub`(): Unit = runBlocking {
val tempDir = Files.createTempDirectory("file_write_clobber")
val target = tempDir.resolve("Routes.kt")
Files.writeString(target, (1..273).joinToString("\n") { "line $it = something()" })
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val stub = "fun routes() {\n // ... existing routes ...\n ...\n}"
val result = tool.execute(createRequest(mapOf("path" to target.toString(), "content" to stub)))
assertTrue(result is ToolResult.Failure)
result as ToolResult.Failure
assertTrue(result.recoverable)
assertTrue(result.reason.contains("file_edit"))
assertEquals(273, Files.readString(target).lines().size) // original untouched
}
@Test
fun `clobber guard allows a genuine shrink with no placeholders`(): Unit = runBlocking {
val tempDir = Files.createTempDirectory("file_write_shrink")
val target = tempDir.resolve("Routes.kt")
Files.writeString(target, (1..273).joinToString("\n") { "line $it = something()" })
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
val trimmed = (1..40).joinToString("\n") { "kept $it = something()" }
val result = tool.execute(createRequest(mapOf("path" to target.toString(), "content" to trimmed)))
assertTrue(result is ToolResult.Success)
assertEquals(trimmed, Files.readString(target))
}
@Test
fun `write atomically overwrites an existing file and leaves no temp files`(): Unit = runBlocking {
val tempDir = Files.createTempDirectory("file_write_overwrite")