//! Integration tests for OS-level (bubblewrap) confinement of the agent Bash //! tool. These actually invoke `SandboxedBashTool::call` and assert that, in a //! review mode, the kernel boundary blocks filesystem and network escapes while //! legitimate in-sandbox work succeeds. //! //! The escape tests require a working `bwrap`; when it is absent the confined //! path fails closed (asserted separately) and the live-escape tests skip with //! a printed notice rather than producing a false pass. #![cfg(unix)] use boosapi::services::agent_workbench::bash_sandbox::{build_bwrap_args, SandboxedBashTool}; use boosapi::services::agent_workbench::permission::PermissionMode; use open_agent_sdk::types::{Tool, ToolUseContext}; use serde_json::json; use std::path::{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_bash_it_{}", uuid::Uuid::new_v4().simple())); std::fs::create_dir_all(&base).unwrap(); base.canonicalize().unwrap() } async fn run(tool: &SandboxedBashTool, command: &str) -> (String, bool) { let ctx = ToolUseContext::new(std::env::temp_dir().to_string_lossy().to_string()); let res = tool .call(json!({ "command": command }), &ctx) .await .expect("tool call ok"); (res.get_text(), res.is_error) } #[test] fn build_args_pin_session_dir_and_isolation() { let args = build_bwrap_args(Path::new("/srv/sess/abc"), "id"); let j = args.join(" "); assert!(j.contains("--bind /srv/sess/abc /srv/sess/abc")); assert!(j.contains("--chdir /srv/sess/abc")); assert!(args.iter().any(|a| a == "--unshare-all")); assert!(!j.contains("/etc")); } #[tokio::test] async fn default_mode_blocks_reading_etc_passwd() { if !bwrap_available() { eprintln!("SKIP: bwrap not available"); return; } let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default); let (out, _is_err) = run(&tool, "cat /etc/passwd").await; // The host's /etc is never mounted, so the read cannot succeed. assert!( !out.contains("root:") && !out.contains(":/bin/"), "should not see host passwd contents, got: {out}" ); } #[tokio::test] async fn default_mode_blocks_listing_root() { if !bwrap_available() { eprintln!("SKIP: bwrap not available"); return; } let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default); let (out, _) = run(&tool, "ls /").await; // /etc, /root, /home must be absent from the sandbox root. assert!(!out.contains("etc"), "sandbox root must not expose /etc: {out}"); assert!(!out.contains("root"), "sandbox root must not expose /root: {out}"); assert!(!out.contains("home"), "sandbox root must not expose /home: {out}"); } #[tokio::test] async fn default_mode_blocks_writing_outside_sandbox() { if !bwrap_available() { eprintln!("SKIP: bwrap not available"); return; } let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default); let (_out, is_err) = run(&tool, "echo pwned > /etc/cron.d/x").await; assert!(is_err, "writing outside the sandbox must fail"); // And the host file must not exist. assert!(!Path::new("/etc/cron.d/x").exists()); } #[tokio::test] async fn default_mode_blocks_network() { if !bwrap_available() { eprintln!("SKIP: bwrap not available"); return; } let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default); let (out, _) = run(&tool, "getent hosts example.com || echo NET-BLOCKED").await; assert!(out.contains("NET-BLOCKED"), "network should be unshared: {out}"); } #[tokio::test] async fn default_mode_allows_work_inside_sandbox() { if !bwrap_available() { eprintln!("SKIP: bwrap not available"); return; } let sd = tmp_session(); let tool = SandboxedBashTool::new(sd.clone(), PermissionMode::Default); let (out, is_err) = run(&tool, "echo hello > note.txt && cat note.txt").await; assert!(!is_err, "in-sandbox work should succeed: {out}"); assert!(out.contains("hello")); // The file lands in the real session dir (bound rw). assert!(sd.join("note.txt").exists()); } #[tokio::test] async fn full_access_mode_is_unconfined() { // FullAccess is the explicitly-dangerous mode: it should NOT be sandboxed, // so it can read the host (this documents the intended escape hatch). let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::FullAccess); let (out, _) = run(&tool, "test -e /etc && echo HOST-VISIBLE || echo no").await; assert!(out.contains("HOST-VISIBLE"), "full access should see host: {out}"); }