diff --git a/crates/tinytools-std/src/filesystem/file_write/mod.rs b/crates/tinytools-std/src/filesystem/file_write/mod.rs index 0efcd66..25186d8 100644 --- a/crates/tinytools-std/src/filesystem/file_write/mod.rs +++ b/crates/tinytools-std/src/filesystem/file_write/mod.rs @@ -92,8 +92,10 @@ impl Tool for FileWriteTool { /// an external process to win the race — outside this gate's threat model, /// which governs the agent, not concurrent writers — and `execute()` still /// enforces workspace containment + symlink refusal regardless, so a write - /// that slips through as "create" cannot escape the sandbox. We therefore - /// do not re-probe in `execute()`. + /// that slips through as "create" cannot escape the sandbox. `execute()` + /// does re-probe for one case: a context workspace can resolve the path + /// somewhere other than this probe's root, and an existing file there that + /// this probe saw as new is refused rather than overwritten unapproved. fn external_effect_with_args(&self, args: &serde_json::Value) -> bool { if !self.gate.write_needs_approval() { return false; // Full (allow) or read-only (blocked in execute) @@ -173,6 +175,20 @@ impl FileWriteTool { Err(msg) => return Ok(ToolResult::error(msg)), }; + // Re-probe: the approval probe resolved `path` against the approval + // root, but a context workspace can move the write elsewhere. A path it + // saw as new (no approval asked) that exists here would be an + // unapproved overwrite. + if self.gate.write_needs_approval() + && !self.external_effect_with_args(&args) + && tokio::fs::symlink_metadata(&resolved_target).await.is_ok() + { + return Ok(ToolResult::error(format!( + "{path} already exists in this workspace; overwriting it needs approval. \ + Use the edit tool to change it" + ))); + } + // Create parent directory only at the validated, resolved location. if let Some(resolved_parent) = resolved_target.parent() { tokio::fs::create_dir_all(resolved_parent).await?; diff --git a/crates/tinytools-std/src/filesystem/file_write/mod_tests.rs b/crates/tinytools-std/src/filesystem/file_write/mod_tests.rs index 73407b3..e8946bb 100644 --- a/crates/tinytools-std/src/filesystem/file_write/mod_tests.rs +++ b/crates/tinytools-std/src/filesystem/file_write/mod_tests.rs @@ -500,3 +500,28 @@ async fn file_write_uses_the_context_workspace_when_one_is_threaded() { assert!(isolated.path().join("in_ws.txt").exists()); assert!(!home.path().join("in_ws.txt").exists()); } + +// The approval probe ran against the tool's approval root, where the path is +// new, so no approval was asked. Under a context workspace the same path is an +// existing file: overwriting it would be an unapproved edit. +#[tokio::test] +async fn an_overwrite_the_approval_probe_saw_as_a_create_is_refused() { + use crate::filesystem::test_support::WorkspaceContext; + let home = tempfile::tempdir().unwrap(); + let isolated = tempfile::tempdir().unwrap(); + std::fs::write(isolated.path().join("a.txt"), "keep").unwrap(); + let tool = FileWriteTool::new(test_security(home.path().to_path_buf())); + let args = json!({"path": "a.txt", "content": "clobbered"}); + assert!(!tool.external_effect_with_args(&args), "probed as a create"); + + let context = WorkspaceContext::at(isolated.path()); + let result = tool + .execute_with_context(args, ToolCallOptions::default(), Some(&context)) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + assert_eq!( + std::fs::read_to_string(isolated.path().join("a.txt")).unwrap(), + "keep" + ); +}