use super::*;
#[tokio::test]
async fn edit_file_replaces_unique_match_and_reports_delta() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("f.txt"), "hello world\nsecond line\n").unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"edit_file",
serde_json::json!({
"path": "f.txt",
"old_string": "world",
"new_string": "rust\nand more"
}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("edited f.txt (+1 lines"), "got: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("f.txt")).unwrap(),
"hello rust\nand more\nsecond line\n"
);
}
#[tokio::test]
async fn edit_file_rejects_empty_missing_and_ambiguous_old_string() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("f.txt"), "dup\ndup\n").unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "", "new_string": "x"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("old_string must not be empty"), "got: {out}");
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "absent", "new_string": "x"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("old_string not found in f.txt"), "got: {out}");
assert!(out.contains("do not guess again"), "got: {out}");
assert!(
out.contains("dup"),
"miss error must include the file content: {out}"
);
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "dup", "new_string": "x"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("matches 2 locations"), "got: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("f.txt")).unwrap(),
"dup\ndup\n"
);
}
#[tokio::test]
async fn edit_file_denied_outside_fs_write_scope_and_missing_file() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = Caveats {
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "a", "new_string": "b"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(
out.contains("capability denied: fs_write"),
"denied before any fs access, got: {out}"
);
let caveats = caveats_rw(ws.path());
let out = run_tool(
"edit_file",
serde_json::json!({"path": "missing.txt", "old_string": "a", "new_string": "b"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("error: reading missing.txt"), "got: {out}");
}
#[tokio::test]
#[cfg(target_os = "linux")]
async fn edit_file_symlink_under_workspace_escaping_is_denied() {
let ws = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
std::fs::write(outside.path().join("secret.txt"), "OUTSIDE SECRET\n").unwrap();
std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
let out = run_tool(
"edit_file",
serde_json::json!({
"path": "link/secret.txt",
"old_string": "OUTSIDE",
"new_string": "EDITED",
}),
ws.path(),
&caveats_rw(ws.path()),
None,
)
.await;
assert!(
!out.contains("OUTSIDE SECRET"),
"object-bound edit must not leak the outside file: {out}"
);
assert_eq!(
out,
denied_fs_result(
"fs_write",
&ws.path().join("link/secret.txt").to_string_lossy()
),
"the symlink-escape edit must be denied: {out}"
);
assert_eq!(
std::fs::read_to_string(outside.path().join("secret.txt")).unwrap(),
"OUTSIDE SECRET\n",
"the outside file must be UNCHANGED"
);
}
#[tokio::test]
async fn edit_file_appends_build_check_result() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("f.txt"), "old\n").unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "old", "new_string": "new"}),
ws.path(),
&caveats,
Some(passing_build_check_cmd()),
)
.await;
let confinable = crate::confined_exec::kernel_fs_fence_available();
if confinable {
assert!(out.contains("✓ build check passed"), "got: {out}");
} else {
assert!(
out.contains("build check"),
"build-check line appended: {out}"
);
}
let failing_check = failing_build_check_cmd("broke");
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "new", "new_string": "newer"}),
ws.path(),
&caveats,
Some(&failing_check),
)
.await;
if confinable {
assert!(out.contains("✗ build check failed"), "got: {out}");
assert!(out.contains("broke"), "model sees the failure text: {out}");
} else {
assert!(
out.contains("build check"),
"build-check line appended: {out}"
);
}
}
#[tokio::test]
async fn write_file_shrink_guard_refuses_large_deletion() {
let ws = tempfile::TempDir::new().unwrap();
let big: String = (0..100).map(|i| format!("line {i}\n")).collect();
std::fs::write(ws.path().join("big.txt"), &big).unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"write_file",
serde_json::json!({"path": "big.txt", "content": "tiny\n"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(
out.contains("would shrink big.txt from 100 → 1 lines"),
"got: {out}"
);
assert!(out.contains("edit_file"), "points at the safer tool: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("big.txt")).unwrap(),
big
);
}
#[tokio::test]
async fn write_file_creates_parent_directories() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"write_file",
serde_json::json!({"path": "a/b/c.txt", "content": "nested"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("wrote a/b/c.txt"), "got: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("a/b/c.txt")).unwrap(),
"nested"
);
}
#[tokio::test]
async fn delete_file_removes_one_file_and_appends_build_check() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("old.rs"), "fn main() {}\n").unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"delete_file",
serde_json::json!({"path": "old.rs"}),
ws.path(),
&caveats,
Some(passing_build_check_cmd()),
)
.await;
assert!(out.starts_with("deleted old.rs"), "got: {out}");
if crate::confined_exec::kernel_fs_fence_available() {
assert!(out.contains("✓ build check passed"), "got: {out}");
} else {
assert!(
out.contains("build check"),
"build-check line appended: {out}"
);
}
assert!(
!ws.path().join("old.rs").exists(),
"delete_file must remove the target file"
);
}
#[tokio::test]
async fn delete_file_denies_missing_files_directories_and_fs_write_misses() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
std::fs::create_dir(ws.path().join("dir")).unwrap();
let denied = Caveats {
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"delete_file",
serde_json::json!({"path": "secret.txt"}),
ws.path(),
&denied,
None,
)
.await;
assert!(out.contains("capability denied: fs_write"), "got: {out}");
assert!(
ws.path().join("secret.txt").exists(),
"denied delete must not remove the file"
);
let caveats = caveats_rw(ws.path());
let out = run_tool(
"delete_file",
serde_json::json!({"path": "missing.txt"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("file does not exist"), "got: {out}");
let out = run_tool(
"delete_file",
serde_json::json!({"path": "dir"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("refuses directories"), "got: {out}");
assert!(ws.path().join("dir").is_dir(), "directory must remain");
}
#[tokio::test]
#[cfg(target_os = "linux")]
async fn delete_file_symlink_under_workspace_escaping_is_denied() {
let ws = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
std::fs::write(outside.path().join("victim.txt"), "keep me\n").unwrap();
std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
let out = run_tool(
"delete_file",
serde_json::json!({"path": "link/victim.txt"}),
ws.path(),
&caveats_rw(ws.path()),
None,
)
.await;
let full = ws.path().join("link/victim.txt");
let read_caveats = caveats_rw(ws.path());
let before = crate::agentic::tools::file_capture::capture(&read_caveats.fs_read, &full);
let after = crate::agentic::tools::file_capture::capture(&read_caveats.fs_read, &full);
let receipt = crate::agentic::tools::file_capture::receipt("link/victim.txt", &before, &after);
let mut display = crate::agentic::display::ToolDisplay::new(Vec::new(), false, 80, 0, false);
let expected = receipt.present(
crate::agentic::tools::file_capture::failure(
denied_fs_result("fs_write", &full.to_string_lossy()),
"",
),
"",
&mut display,
);
assert_eq!(
out, expected,
"the symlink-escape delete must be denied: {out}"
);
assert!(
outside.path().join("victim.txt").exists(),
"the outside file must survive — the delete never escaped"
);
}
#[tokio::test]
async fn read_file_denial_and_missing_file_errors() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"read_file",
serde_json::json!({"path": "secret.txt"}),
ws.path(),
&denied,
None,
)
.await;
assert!(out.contains("capability denied: fs_read"), "got: {out}");
let caveats = caveats_rw(ws.path());
let out = run_tool(
"read_file",
serde_json::json!({"path": "nope.txt"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("error: reading nope.txt"), "got: {out}");
}
#[tokio::test]
#[cfg(target_os = "linux")]
async fn read_file_symlink_under_workspace_escaping_is_denied() {
let ws = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
std::fs::write(outside.path().join("secret.txt"), "TOP SECRET").unwrap();
std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
let confined = Caveats {
fs_read: Scope::only([ws.path().to_string_lossy().into_owned()]),
..caveats_rw(ws.path())
};
let out = run_tool(
"read_file",
serde_json::json!({"path": "link/secret.txt"}),
ws.path(),
&confined,
None,
)
.await;
assert!(
!out.contains("TOP SECRET"),
"object-bound read must not follow a symlink out of the workspace: {out}"
);
assert_eq!(
out,
denied_fs_result(
"fs_read",
&ws.path().join("link/secret.txt").to_string_lossy()
),
"a contained-read escape must surface as an fs_read denial: {out}"
);
}
#[tokio::test]
async fn list_dir_denial_and_missing_dir_errors() {
let ws = tempfile::TempDir::new().unwrap();
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"list_dir",
serde_json::json!({"path": "."}),
ws.path(),
&denied,
None,
)
.await;
assert!(out.contains("capability denied: fs_read"), "got: {out}");
let caveats = caveats_rw(ws.path());
let out = run_tool(
"list_dir",
serde_json::json!({"path": "not-a-dir"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("error:"), "got: {out}");
}
#[tokio::test]
#[cfg(target_os = "linux")]
async fn list_dir_symlink_under_workspace_escaping_is_denied() {
let ws = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
std::fs::write(outside.path().join("outside_secret.txt"), "x").unwrap();
std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
let confined = Caveats {
fs_read: Scope::only([ws.path().to_string_lossy().into_owned()]),
..caveats_rw(ws.path())
};
let out = run_tool(
"list_dir",
serde_json::json!({"path": "link"}),
ws.path(),
&confined,
None,
)
.await;
assert!(
!out.contains("outside_secret.txt"),
"object-bound list_dir must not enumerate a directory outside the workspace: {out}"
);
assert_eq!(
out,
denied_fs_result("fs_read", &ws.path().join("link").to_string_lossy()),
"got: {out}"
);
}
#[tokio::test]
async fn approved_external_write_retains_shrink_guard() {
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let path = outside.path().join("large.txt");
let original = "original line\n".repeat(100);
std::fs::write(&path, &original).unwrap();
let caveats = caveats_rw(ws.path());
let mut gate = MockGate::new(true, &caveats);
let output = run_tool_gated(
"write_file",
serde_json::json!({"path":path, "content":"tiny\n"}),
ws.path(),
&caveats,
&mut gate,
)
.await;
assert_eq!(gate.asks.len(), 1);
assert!(output.contains("would shrink"), "{output}");
assert_eq!(std::fs::read_to_string(path).unwrap(), original);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test]
async fn cli_exact_file_read_grant_reads_only_the_named_object() {
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let granted = outside.path().join("allowed.txt");
let sibling = outside.path().join("sibling.txt");
std::fs::write(&granted, "allowed contents\n").unwrap();
std::fs::write(&sibling, "ungranted sibling contents\n").unwrap();
let workspace = ws.path().to_string_lossy();
let mut caveats = crate::ToolPermissions {
preset: crate::PermissionPreset::ReadOnly,
..Default::default()
}
.to_caveats(&workspace);
crate::caveats::lock_fs_to_workspace(
&mut caveats,
&workspace,
&[granted.to_string_lossy().into_owned()],
&[],
);
let out = run_tool(
"read_file",
serde_json::json!({"path": granted}),
ws.path(),
&caveats,
None,
)
.await;
assert_eq!(out, "allowed contents\n", "exact-file read failed: {out}");
let out = run_tool(
"read_file",
serde_json::json!({"path": sibling}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("capability denied: fs_read"), "{out}");
assert!(!out.contains("ungranted sibling contents"), "{out}");
std::fs::remove_file(&granted).unwrap();
std::os::unix::fs::symlink("sibling.txt", &granted).unwrap();
let out = run_tool(
"read_file",
serde_json::json!({"path": granted}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("capability denied: fs_read"), "{out}");
assert!(!out.contains("ungranted sibling contents"), "{out}");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test]
async fn cli_exact_file_write_grant_creates_and_updates_only_the_named_object() {
let ws = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let granted = outside.path().join("allowed.txt");
let sibling = outside.path().join("sibling.txt");
std::fs::write(&sibling, "unchanged sibling\n").unwrap();
let workspace = ws.path().to_string_lossy();
let mut caveats = crate::ToolPermissions {
preset: crate::PermissionPreset::ReadOnly,
..Default::default()
}
.to_caveats(&workspace);
crate::caveats::lock_fs_to_workspace(
&mut caveats,
&workspace,
&[],
&[granted.to_string_lossy().into_owned()],
);
for content in ["created contents\n", "updated contents\n"] {
let out = run_tool(
"write_file",
serde_json::json!({"path": granted, "content": content}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("wrote "), "exact-file write failed: {out}");
assert_eq!(std::fs::read_to_string(&granted).unwrap(), content);
let out = run_tool(
"read_file",
serde_json::json!({"path": granted}),
ws.path(),
&caveats,
None,
)
.await;
assert_eq!(out, content, "write grant must also permit read: {out}");
}
let large = (0..100)
.map(|line| format!("line {line}\n"))
.collect::<String>();
std::fs::write(&granted, &large).unwrap();
let out = run_tool(
"write_file",
serde_json::json!({"path": granted, "content": "too short\n"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(
out.contains("would shrink"),
"exact-file preimage must retain the shrink guard: {out}"
);
assert_eq!(std::fs::read_to_string(&granted).unwrap(), large);
let out = run_tool(
"write_file",
serde_json::json!({"path": sibling, "content": "forbidden change\n"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("capability denied: fs_write"), "{out}");
assert_eq!(
std::fs::read_to_string(&sibling).unwrap(),
"unchanged sibling\n"
);
std::fs::remove_file(&granted).unwrap();
std::os::unix::fs::symlink("sibling.txt", &granted).unwrap();
let out = run_tool(
"write_file",
serde_json::json!({"path": granted, "content": "forbidden change\n"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("capability denied: fs_write"), "{out}");
assert_eq!(
std::fs::read_to_string(&sibling).unwrap(),
"unchanged sibling\n"
);
}
#[tokio::test]
async fn edit_file_warns_when_new_string_has_literal_backslash_n_and_no_newline() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("f.rs"), "let a = 1;\nlet b = 2;\n").unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"edit_file",
serde_json::json!({
"path": "f.rs",
"old_string": "let a = 1;",
"new_string": "let a = 1;\\n let c = 3;"
}),
ws.path(),
&caveats,
None,
)
.await;
assert!(
out.starts_with("edited f.rs"),
"the edit still applies: {out}"
);
assert!(
out.contains("literal `\\n`") && out.contains("real newlines"),
"a warning must accompany the success: {out}"
);
assert_eq!(
std::fs::read_to_string(ws.path().join("f.rs")).unwrap(),
"let a = 1;\\n let c = 3;\nlet b = 2;\n",
"the model's text is applied verbatim, never rewritten"
);
}
#[tokio::test]
async fn edit_file_does_not_warn_on_ordinary_or_pre_existing_escapes() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("f.rs"), "print(\"a\\n\");\nlet b = 2;\n").unwrap();
let caveats = caveats_rw(ws.path());
for (old, new) in [
("let b = 2;", "let b = 2;\nlet c = 3;\\n"), ("let b = 2;", "let b = 9;"), ("print(\"a\\n\");", "print(\"b\\n\");"), ] {
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.rs", "old_string": old, "new_string": new}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("edited f.rs"), "{out}");
assert!(!out.contains("real newlines"), "spurious warning: {out}");
}
}