Files
BoosAPI/tests/agent_sandbox.rs
T
boos 3bb1272c6d fix(agent): prevent dangling-symlink write escape from the sandbox
validate_path_within_cwd used exists() to decide whether a path was new.
exists() follows symlinks, so a *dangling* symlink (target missing) reported
false and fell into the not-yet-existing branch, which only bound-checked the
nearest existing ancestor — never the symlink itself. An approved
`ln -s /etc/cron.d/pwn evil` inside the sandbox would therefore pass
validation, and a subsequent Write to `evil` would follow the link and create
a file outside the sandbox. In AcceptEdits mode this chained cleanly: approve
one innocuous-looking symlink (Execute), then writes auto-allow.

Resolve every path component with lstat (symlink_metadata, which does not
follow links) instead of exists():
- Final component: if it is a symlink (dangling or live), resolve its real
  target via read_link + canonicalize-nearest-ancestor and reject unless the
  target stays within the sandbox.
- Ancestor walk: an intermediate dangling symlink directory is likewise
  resolved and bound-checked rather than skipped.

Live symlinks were already caught by canonicalize; this closes the dangling
case. It does not close the check-vs-open TOCTOU (the open happens inside the
SDK tool and would need O_NOFOLLOW/openat2 there); tracked for phase 2 along
with OS-level bash isolation.

Covered by tests/agent_sandbox.rs (dangling symlink to absolute and relative
targets, write tool through a dangling link, write through an intermediate
dangling symlink dir, all rejected; dangling link pointing inside and ordinary
new files still allowed) plus mirrored in-crate unit tests.
2026-06-22 10:53:45 +08:00

66 lines
2.3 KiB
Rust

//! Integration test for the dangling-symlink write-escape fix.
//!
//! Runs against the library's public API so it compiles/executes independently
//! of unrelated in-crate test fixtures. Reproduces the concrete escape an
//! approved `ln -s` could otherwise open: a symlink whose target does not exist
//! yet (so `exists()` is false and `canonicalize()` fails) must still be
//! rejected when it points outside the per-session sandbox, instead of being
//! treated as a brand-new in-sandbox path that a Write would follow out.
#![cfg(unix)]
use boosapi::services::agent_workbench::sandbox::{check_tool_paths, validate_path_within_cwd};
use serde_json::json;
use std::os::unix::fs::symlink;
use std::path::PathBuf;
fn tmp_sandbox() -> PathBuf {
let base = std::env::temp_dir().join(format!("agent_it_sb_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&base).unwrap();
base.canonicalize().unwrap()
}
#[test]
fn dangling_symlink_to_absolute_target_is_rejected() {
let sb = tmp_sandbox();
symlink("/etc/cron.d/pwn_it", sb.join("evil")).unwrap();
let res = validate_path_within_cwd("evil", &sb);
assert!(res.is_err(), "expected rejection, got {res:?}");
}
#[test]
fn dangling_relative_symlink_escape_is_rejected() {
let sb = tmp_sandbox();
symlink("../../escape_it", sb.join("evil")).unwrap();
assert!(validate_path_within_cwd("evil", &sb).is_err());
}
#[test]
fn write_tool_through_dangling_symlink_is_blocked() {
let sb = tmp_sandbox();
symlink("/etc/cron.d/pwn_it2", sb.join("evil")).unwrap();
let input = json!({ "path": "evil", "content": "* * * * * root sh -c id" });
assert!(check_tool_paths("write", &input, &sb).is_err());
}
#[test]
fn write_through_intermediate_dangling_symlink_dir_is_blocked() {
let sb = tmp_sandbox();
symlink("/etc/nonexistent_it_dir", sb.join("evildir")).unwrap();
let input = json!({ "path": "evildir/pwn", "content": "x" });
assert!(check_tool_paths("write", &input, &sb).is_err());
}
#[test]
fn dangling_symlink_pointing_inside_sandbox_is_allowed() {
let sb = tmp_sandbox();
symlink(sb.join("future_file"), sb.join("ok_link")).unwrap();
assert!(validate_path_within_cwd("ok_link", &sb).is_ok());
}
#[test]
fn ordinary_new_file_still_allowed() {
let sb = tmp_sandbox();
assert!(validate_path_within_cwd("notes/today.md", &sb).is_ok());
}