//! 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" ); }