forked from boos/BoosAPI
test(agent): pin the TOCTOU mitigation property for file tools
The file tools (Read/Write/Edit) open paths inside the SDK process after the can_use_tool callback validates them, leaving a check-vs-open window. Fully closing it atomically needs openat2(RESOLVE_BENEATH) file tools replacing the SDK's in-process ones (tracked as follow-up; the Edit tool's file-state-cache contract makes that a non-trivial reimplementation). This test pins the fact that makes the window unreachable under the current model: an exploit needs a concurrent mutator, the only agent-controlled source of concurrency is a backgrounded process, and bwrap's PID namespace (--unshare-all) makes the bash command PID 1 — so the kernel reaps any backgrounded job when the single bash call exits. The test backgrounds a writer that would keep appending for ~2s and asserts its output stops the moment the call returns. With sequential tool execution and per-session bind isolation, no surviving concurrent mutator exists to win the race. This is an honest mitigation with evidence, not a claim that the in-process open is atomic.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
//! TOCTOU analysis evidence for the agent sandbox.
|
||||
//!
|
||||
//! The file tools (Read/Write/Edit) open paths *inside the SDK process*, after
|
||||
//! BoosAPI's `can_use_tool` callback has validated the path. That leaves a
|
||||
//! check-vs-open window which, in the abstract, a concurrent writer could use to
|
||||
//! swap a validated name for a symlink that escapes the sandbox.
|
||||
//!
|
||||
//! Closing that window atomically would require replacing the SDK's in-process
|
||||
//! file tools with `openat2(RESOLVE_BENEATH)` implementations (tracked as
|
||||
//! follow-up). What this test pins down is whether the window is *reachable* at
|
||||
//! all under the current execution model: an exploit needs a second, concurrent
|
||||
//! mutator, and the only agent-controlled way to get concurrency is a
|
||||
//! backgrounded process. Bash now runs under bwrap with `--unshare-all`, which
|
||||
//! includes a PID namespace; the command is PID 1 of that namespace, so when it
|
||||
//! exits the kernel reaps every other process in the namespace — a backgrounded
|
||||
//! racer cannot outlive the single bash call that spawned it. Combined with
|
||||
//! sequential tool execution and per-session bind isolation, there is no
|
||||
//! surviving concurrent mutator to win the race.
|
||||
//!
|
||||
//! This test proves the reaping property empirically (it is the load-bearing
|
||||
//! fact); it does not claim the in-process open is atomic.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use boosapi::services::agent_workbench::bash_sandbox::SandboxedBashTool;
|
||||
use boosapi::services::agent_workbench::permission::PermissionMode;
|
||||
use open_agent_sdk::types::{Tool, ToolUseContext};
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn bwrap_available() -> bool {
|
||||
["/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap"]
|
||||
.iter()
|
||||
.any(|p| Path::new(p).is_file())
|
||||
}
|
||||
|
||||
fn tmp_session() -> PathBuf {
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("agent_toctou_{}", uuid::Uuid::new_v4().simple()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
/// A backgrounded process spawned by a sandboxed bash call must not survive the
|
||||
/// call: bwrap's PID namespace reaps it when the shell (PID 1) exits. This is
|
||||
/// what removes the concurrent mutator that a file-tool TOCTOU would need.
|
||||
#[tokio::test]
|
||||
async fn backgrounded_process_does_not_outlive_bash_call() {
|
||||
if !bwrap_available() {
|
||||
eprintln!("SKIP: bwrap not available");
|
||||
return;
|
||||
}
|
||||
let sd = tmp_session();
|
||||
let tool = SandboxedBashTool::new(sd.clone(), PermissionMode::Default);
|
||||
let ctx = ToolUseContext::new(std::env::temp_dir().to_string_lossy().to_string());
|
||||
|
||||
// Background a writer that would keep appending for ~2s if it survived,
|
||||
// then let the foreground shell exit immediately.
|
||||
let cmd = "( for i in $(seq 1 200); do echo x >> racer_marker; sleep 0.01; done ) & \
|
||||
echo backgrounded";
|
||||
let res = tool.call(json!({ "command": cmd }), &ctx).await.unwrap();
|
||||
assert!(!res.is_error, "spawn call should succeed: {}", res.get_text());
|
||||
|
||||
let marker = sd.join("racer_marker");
|
||||
let count_now = std::fs::read_to_string(&marker)
|
||||
.map(|s| s.lines().count())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Wait well past the racer's would-be lifetime.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
|
||||
|
||||
let count_later = std::fs::read_to_string(&marker)
|
||||
.map(|s| s.lines().count())
|
||||
.unwrap_or(0);
|
||||
|
||||
assert_eq!(
|
||||
count_now, count_later,
|
||||
"backgrounded racer kept writing after the bash call returned \
|
||||
(count {count_now} -> {count_later}); the PID namespace did not reap it, \
|
||||
so a concurrent mutator could exist"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user