Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions crates/tinytools-std/src/filesystem/file_write/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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?;
Expand Down
25 changes: 25 additions & 0 deletions crates/tinytools-std/src/filesystem/file_write/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Loading