1
0
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:
boos
2026-06-22 09:17:29 +08:00
parent b0ce83a937
commit d751055452
+83
View File
@@ -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"
);
}