mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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:
1 parent
f53d9a4d4d
commit
92f900edf0
6 files changed
+57
-8
No files matched your search
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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()
|
||||
)));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user