fix(tools): write refuses to overwrite an unread or stale file

The visible `write` tool replaced an existing file without checking that
the model had ever read it, or that nobody had changed it since. A
whole-file overwrite is the mutation most likely to destroy work: a file
a person edited after the model's last read, or one a peer agent changed,
was silently replaced. The File `edit` path already guarded this with
require_fresh_file_read, a size and mtime snapshot recorded by every
read.

execute_contract_write now runs the same check when the file exists, via
require_fresh_read_before_write. That method shares one
require_fresh_read_for body with the edit check, and only the tool names
in the refusal differ. The existing legacy wording ("File with
action=\"read\"") is unchanged, so its tests still pin it. Creating a new
file needs no read, and a successful write records the new snapshot, so
write-after-write in one session still works. The `write` description is
left unchanged so the session-pinned tool catalog and the conformance
goldens keep their prefix; the refusal itself names the recovery.

Two tests wrote over a file they had never read. They now record a read
of the resolved path first, the way the read tool does (on macOS a
tempdir path resolves from /var to /private/var). One assertion now
checks that a refused write leaves that read fresh. The old form checked
the unresolved path, so it could never fail.

Verification:
- scripts/dev-test.sh tui tools:: core:: runtime_api runtime_threads
  work_surface sandbox:: -> 4824 run, 4819 passed, 5 failed. Two were
  these tests before the resolved-path fix; tools::file:: re-run: 159
  passed, 0 failed. The other three are
  tools::test_runner::tests::run_tests_*, which fail in this harness
  because the nested cargo gets "Operation not permitted" on the build
  cache path. That is environmental and unrelated.
- clippy -p codewhale-tui --all-targets --all-features --locked with the
  CI flags: clean. cargo fmt --check: clean.
- blocking-call budget 539/165 within budget; dead-code budget 214 at
  budget.

Refs CW-7450

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-17 16:16:40 -07:00
1 parent f53d9a4d4d
commit 92f900edf0
6 files changed
+57 -8

No files matched your search

+7
View File
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `write` refuses to replace an existing file that has not been read in this
session, or that changed after it was read. The File `edit` action already
made this check. Before, a whole-file write could overwrite contents the
model never saw, or a change you or another agent made after the model's
last read. Creating a new file is unchanged, and a file Codewhale wrote
itself counts as read. The refusal names the recovery: read the file, then
write again (CW-7450).
- `codewhale exec` warns again when the sandbox posture asks for confinement
but no OS sandbox can enforce it, for example on a Linux host or container
where bubblewrap cannot start. With no external sandbox backend configured,
+7
View File
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `write` refuses to replace an existing file that has not been read in this
session, or that changed after it was read. The File `edit` action already
made this check. Before, a whole-file write could overwrite contents the
model never saw, or a change you or another agent made after the model's
last read. Creating a new file is unchanged, and a file Codewhale wrote
itself counts as read. The refusal names the recovery: read the file, then
write again (CW-7450).
- `codewhale exec` warns again when the sandbox posture asks for confinement
but no OS sandbox can enforce it, for example on a Linux host or container
where bubblewrap cannot start. With no external sandbox backend configured,
+3
View File
@@ -1698,6 +1698,9 @@ impl WriteFileTool {
let prior = load_contract_source(&file_path, false, context).await?;
let existed_before = prior.is_some();
if existed_before {
context.require_fresh_read_before_write(&file_path, path_str)?;
}
let prior_bytes = prior.unwrap_or_default();
let prior_contents = String::from_utf8_lossy(&prior_bytes);
+7 -1
View File
@@ -1045,6 +1045,8 @@ async fn contract_write_and_edit_reject_payload_or_line_ending_growth_before_mut
let path = temporary.path().join("plain.txt");
let context = ToolContext::new(temporary.path());
fs::write(&path, b"seed\r\n").unwrap();
let resolved = context.resolve_path("plain.txt").unwrap();
context.note_file_read(&resolved);
let error = WriteFileTool::execute_contract_write(
json!({"path": "plain.txt", "content": "x".repeat(CONTRACT_FILE_MAX_BYTES + 1)}),
&context,
@@ -1074,7 +1076,11 @@ async fn contract_write_and_edit_reject_payload_or_line_ending_growth_before_mut
.contains("16 MiB processing cap")
);
assert_eq!(fs::read(&path).unwrap(), b"seed\r\n");
assert!(context.require_fresh_file_read(&path, "plain.txt").is_err());
assert!(
context
.require_fresh_file_read(&resolved, "plain.txt")
.is_ok()
);
}
}
+1
View File
@@ -812,6 +812,7 @@ async fn contract_write_over_crlf_file_preserves_crlf_line_endings() {
let ctx = ToolContext::new(tmp.path().to_path_buf());
let path = tmp.path().join("crlf.txt");
fs::write(&path, b"alpha\r\nbeta\r\n").expect("initial CRLF write");
ctx.note_file_read(&ctx.resolve_path("crlf.txt").expect("resolve"));
WriteFileTool::execute_contract_write(
json!({"path": "crlf.txt", "content": "gamma\ndelta\n"}),
+32 -7
View File
@@ -1048,6 +1048,31 @@ impl ToolContext {
&self,
path: &Path,
requested_path: &str,
) -> Result<(), ToolError> {
self.require_fresh_read_for(
path,
requested_path,
"File action=\"edit\"",
"File with action=\"read\"",
"File action=\"read\"",
)
}
pub fn require_fresh_read_before_write(
&self,
path: &Path,
requested_path: &str,
) -> Result<(), ToolError> {
self.require_fresh_read_for(path, requested_path, "write", "read", "read")
}
fn require_fresh_read_for(
&self,
path: &Path,
requested_path: &str,
mutation: &str,
read_call: &str,
read: &str,
) -> Result<(), ToolError> {
let prior = {
let tracker = self.file_read_tracker.lock().map_err(|_| {
@@ -1060,25 +1085,25 @@ impl ToolContext {
let Some(prior) = prior else {
return Err(ToolError::execution_failed(format!(
"Refusing File action=\"edit\" for {} because it has not been read in this session. \
Recovery: call File with action=\"read\" path=\"{requested_path}\" to inspect the current contents, \
then retry File action=\"edit\" with a unique search string.",
"Refusing {mutation} for {} because it has not been read in this session. \
Recovery: call {read_call} path=\"{requested_path}\" to inspect the current contents, \
then retry {mutation}.",
path.display()
)));
};
let current = file_read_snapshot(path).map_err(|e| {
ToolError::execution_failed(format!(
"Refusing File action=\"edit\" for {} because the file could not be checked for staleness ({e}). \
Recovery: call File with action=\"read\" path=\"{requested_path}\" again, then retry File action=\"edit\".",
"Refusing {mutation} for {} because the file could not be checked for staleness ({e}). \
Recovery: call {read_call} path=\"{requested_path}\" again, then retry {mutation}.",
path.display()
))
})?;
if current != prior {
return Err(ToolError::execution_failed(format!(
"Refusing File action=\"edit\" for {} because it changed since the last File action=\"read\" call. \
Recovery: call File with action=\"read\" path=\"{requested_path}\" again and retry with the current contents.",
"Refusing {mutation} for {} because it changed since the last {read} call. \
Recovery: call {read_call} path=\"{requested_path}\" again and retry with the current contents.",
path.display()
)));
}