From 0e1095e9baf2b285b688a31cb3574dc179645219 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 18 Jul 2026 14:05:53 +0400 Subject: [PATCH] feat(tools): clobber guard blocks file_write overwriting real code with elided stubs (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tools/filesystem/FileWriteTool.kt | 38 +++++++++++++++++++ .../tools/filesystem/FileWriteToolTest.kt | 29 ++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt index 2deca9d4..db8b17f9 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt @@ -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 + } } diff --git a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteToolTest.kt b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteToolTest.kt index db1aa6e3..7223f414 100644 --- a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteToolTest.kt +++ b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteToolTest.kt @@ -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")