Skip to content
Open
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,10 @@ yolo run run run nee
- `git reset --hard` and anything else that throws away uncommitted work;
- deploys, `terraform apply`, `kubectl apply`;
- opening or merging pull requests;
- writes outside the project;
- writes outside the project, including a write that a symlink inside it

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"file tools ... follow the link" reads as if it covers the shell file commands too (cp, mv, tee), which this doesn't. Please name write_file/edit_file explicitly.

would carry out (file tools and shell redirects follow the link to see
where it lands; this is checked when the call is judged, so it is not a
sandbox and doesn't cover every shell command);
- reading `~/.aws/credentials` and other keys;
- sending local data off the machine;
- commands rusty doesn't recognise.
Expand Down
122 changes: 113 additions & 9 deletions src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ fn classify_path_write(path: &str, ctx: &Ctx) -> Class {
if !ctx.writable(path) {
return risky(format!("writes outside the project: {path}"));
}
if let Some(real) = ctx.leaves_through_link(path) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only write_file/edit_file, redirects, dd of= and pg_dump -f reach this. For cp/ln/install, file_tool still checks only ctx.writable, and mv/tee/touch/truncate go through paths_inside. So ln -s ~/.ssh k followed by cp key k/authorized_keys or tee k/x is still allowed as a workspace write. Please route those destinations through leaves_through_link too.

Related: a single command like ln -s ~ h && echo x >> h/.zshrc is judged before the link exists, so the redirect passes. Treating an ln -s whose target is outside the project as risky would cover it.

return risky(format!("writes outside the project through a link: {path} → {}", real.display()));
}
let lower = unq(path).to_lowercase();
let name = Path::new(&lower).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
if name.starts_with(".env") || lower.contains(".git/") || name.ends_with(".pem") || name.ends_with(".key") {
Expand Down Expand Up @@ -282,21 +285,26 @@ impl<'a> Ctx<'a> {
/// An absolute, normalised path, or None when it depends on the shell
/// (`$VAR`, `~user`, a relative path after an unknown `cd`).
fn resolve(&self, p: &str) -> Option<PathBuf> {
self.absolute(p).map(|p| normalize(&p))
}

/// `resolve` before normalising: `..` is still there, for the system to
/// apply after any link before it.
fn absolute(&self, p: &str) -> Option<PathBuf> {
let p = unq(p);
if p.is_empty() || p.contains(['$', '`']) {
return None;
}
let joined = if p == "~" || p.starts_with("~/") {
if p == "~" || p.starts_with("~/") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/unknown".into());
PathBuf::from(home).join(p.trim_start_matches('~').trim_start_matches('/'))
Some(PathBuf::from(home).join(p.trim_start_matches('~').trim_start_matches('/')))
} else if p.starts_with('~') {
return None;
None
} else if Path::new(p).is_absolute() {
PathBuf::from(p)
Some(PathBuf::from(p))
} else {
self.here.as_ref()?.join(p)
};
Some(normalize(&joined))
Some(self.here.as_ref()?.join(p))
}
}

fn inside(&self, p: &str) -> bool {
Expand All @@ -307,6 +315,44 @@ impl<'a> Ctx<'a> {
fn writable(&self, p: &str) -> bool {
self.resolve(p).is_some_and(|p| p.starts_with(self.root) || scratch(&p))
}

/// Where a write to `p` really lands, when a symlink on the way leads
/// out of the project and its temp directories. This is checked when
/// the call is judged: a link swapped between then and the write is not
/// caught, so it is a boundary for honest mistakes, not a sandbox.
fn leaves_through_link(&self, p: &str) -> Option<PathBuf> {
let real = real_path(&self.absolute(p)?);
(!(real.starts_with(real_path(self.root)) || scratch_under(&real, real_path))).then_some(real)
}
}

/// Where an absolute path lands, resolved the way the system does it: part
/// by part, each link replaced by its target as it is reached (a last link
/// too, whose target a write would create) and `..` taken from what is
/// really there. Parts that don't exist yet are kept as written.
fn real_path(p: &Path) -> PathBuf {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard links aren't covered. On macOS, home and the project are usually on the same volume, so after ln ~/.zshrc z (allowed, since only the destination is checked), edit_file z modifies ~/.zshrc. Checking nlink > 1 on an existing regular file (or refusing ln without -s for sources outside the project) would catch this.

let parts = |p: &Path| p.components().rev().map(|c| c.as_os_str().to_os_string()).collect::<Vec<_>>();
let mut todo = parts(p);
let mut out = PathBuf::new();
let mut links = 0;
while let Some(part) = todo.pop() {
if part == "/" {
out = PathBuf::from("/");
} else if part == ".." {
out.pop();
} else if part != "." {
let next = out.join(&part);
match std::fs::read_link(&next) {
// The system gives up after 40 links; a loop never resolves.
Ok(target) if links < 40 => {
links += 1;
todo.extend(parts(&target));
}
_ => out = next,
}
}
}
out
}

fn normalize(p: &Path) -> PathBuf {
Expand All @@ -326,14 +372,21 @@ fn normalize(p: &Path) -> PathBuf {
/// Somewhere under a temp directory (not the temp directory itself), and
/// never inside the home directory, whatever TMPDIR says.
fn scratch(p: &Path) -> bool {
if std::env::var("HOME").is_ok_and(|h| !h.is_empty() && p.starts_with(normalize(Path::new(&h)))) {
scratch_under(p, Path::to_path_buf)
}

/// `scratch` with home and the temp roots passed through `map` first, so a
/// path whose links are followed is compared with followed roots (macOS
/// keeps /tmp, /var/tmp and $TMPDIR under /private).
fn scratch_under(p: &Path, map: impl Fn(&Path) -> PathBuf) -> bool {
if std::env::var("HOME").is_ok_and(|h| !h.is_empty() && p.starts_with(map(&normalize(Path::new(&h))))) {
return false;
}
let mut roots = vec![PathBuf::from("/tmp"), PathBuf::from("/private/tmp"), PathBuf::from("/var/tmp")];
if let Ok(t) = std::env::var("TMPDIR") {
roots.push(normalize(Path::new(&t)));
}
roots.iter().any(|r| p.starts_with(r) && p != r.as_path())
roots.iter().map(|r| map(r)).any(|r| p.starts_with(&r) && p != r.as_path())
}

/// Folders that a build or an install recreates, so deleting them is routine.
Expand Down Expand Up @@ -2749,6 +2802,57 @@ mod tests {
assert!(matches!(ask.decide("edit_file", &edit, cwd), Verdict::Ask(_)));
}

/// A write a link carries out of the project is a write outside it.
/// Only judged here, never written: the outside targets are system paths.
#[test]
fn writes_go_where_links_lead() {
use std::os::unix::fs::symlink;
let work = std::env::temp_dir().join(format!("rusty-links-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
std::fs::create_dir_all(work.join("src")).unwrap();
std::fs::write(work.join("src/lib.rs"), "").unwrap();
symlink("/usr", work.join("system")).unwrap();
symlink("/etc/hosts", work.join("hosts")).unwrap();
symlink("/nonexistent-rusty-target/new.txt", work.join("dangling")).unwrap();
symlink(work.join("src"), work.join("code")).unwrap();
symlink("src/lib.rs", work.join("lib.rs")).unwrap();
symlink("loop-b", work.join("loop-a")).unwrap();
symlink("loop-a", work.join("loop-b")).unwrap();
let file = |tool: &str, path: &str| classify(tool, &json!({"path": path, "content": "x"}), &work);
for (tool, path) in [
("write_file", "system/new.txt"),
("edit_file", "hosts"),
("write_file", "dangling"),
("write_file", "system/../etc/new.txt"),
("write_file", "code/../system/new.txt"),
] {
let class = file(tool, path);
assert!(
matches!(&class, Class::Risky(why) if why.contains("outside the project through a link")),
"{tool} {path}: {class:?}"
);
}
assert!(
matches!(file("write_file", "dangling"), Class::Risky(why) if why.ends_with("/nonexistent-rusty-target/new.txt"))
);
for path in ["src/lib.rs", "src/new.rs", "code/new.rs", "lib.rs", "new/dir/file.rs", "loop-a/x"] {
assert_eq!(file("write_file", path), Class::WorkspaceWrite, "{path}");
}
// The shell's redirects take the same check; other shell writes don't.
let shell = |cmd: &str| classify("bash", &json!({"command": cmd}), &work);
assert!(matches!(shell("echo hi > system/new.txt"), Class::Risky(_)));
assert_eq!(shell("echo hi > code/new.rs"), Class::WorkspaceWrite);
// Each mode treats it as any other write outside the project.
let args = json!({"path": "system/new.txt", "content": "x"});
assert!(matches!(Policy::new(Mode::Auto).decide("write_file", &args, &work), Verdict::Ask(_)));
assert!(matches!(Policy::new(Mode::Ask).decide("write_file", &args, &work), Verdict::Ask(_)));
assert!(matches!(Policy::new(Mode::ReadOnly).decide("write_file", &args, &work), Verdict::Deny(_)));
assert_eq!(Policy::new(Mode::Yolo).decide("write_file", &args, &work), Verdict::Allow);
let inside = json!({"path": "code/new.rs", "content": "x"});
assert_eq!(Policy::new(Mode::Auto).decide("write_file", &inside, &work), Verdict::Allow);
std::fs::remove_dir_all(&work).unwrap();
}

#[test]
fn destructive_ignores_yolo_and_allow_rules() {
let cwd = Path::new("/work/proj");
Expand Down
41 changes: 41 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2017,6 +2017,47 @@ fn truncated_reply_after_the_check_leaves_the_goal_open_completion_contract() {
assert!(String::from_utf8_lossy(&out.stderr).contains("\"goal\":\"open\""));
}

#[test]
fn a_link_out_of_the_project_does_not_carry_a_write_with_it() {
use std::os::unix::fs::symlink;
let s = Sandbox::new("link-escape");
// Outside the project, in the (fake) home directory, where writes ask.
let outside = s.home.join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("notes.md"), "keep\n").unwrap();
symlink(&outside, s.project.join("escape")).unwrap();
symlink(outside.join("notes.md"), s.project.join("notes.md")).unwrap();
let (url, server) = scripted_endpoint(vec![
tool_reply("write_file", serde_json::json!({"path": "escape/new.txt", "content": "planted\n"})),
tool_reply("edit_file", serde_json::json!({"path": "notes.md", "old_string": "keep", "new_string": "lost"})),
tool_reply("write_file", serde_json::json!({"path": "kept.txt", "content": "fine\n"})),
text_reply("ok"),
]);
let out = s
.cmd()
.env("RUSTY_BASE_URL", url)
.args(["--mode", "standard", "--agents", "off", "--memory", "off", "tidy up"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map(|child| wait(child, Duration::from_secs(20)))
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let requests = server.join().unwrap();
assert_eq!(requests.len(), 4);
assert!(!outside.join("new.txt").exists(), "a write followed a link out of the project");
assert_eq!(std::fs::read_to_string(outside.join("notes.md")).unwrap(), "keep\n");
assert_eq!(std::fs::read_to_string(s.project.join("kept.txt")).unwrap(), "fine\n");
for i in [1, 2] {
let told = requests[i]["messages"].as_array().unwrap().last().unwrap()["content"].to_string();
assert!(told.contains("needs approval"), "{told}");
}
let out = s.repl(&["/permissions check echo planted > escape/new.txt", "/permissions check echo ok > kept.txt"]);
assert!(out.contains("writes outside the project through a link: escape/new.txt"), "{out}");
assert!(out.contains("runs"), "{out}");
}

#[test]
fn yolo_still_refuses_a_destructive_command_when_nobody_is_watching() {
let s = Sandbox::new("yolo-destructive");
Expand Down
Loading