feat(agent): confine agent bash in an OS sandbox (bubblewrap), mode-gated
The SDK's built-in Bash tool ran commands as a bare subprocess with only current_dir set — no OS boundary. On a server that let an agent shell read /etc/shadow, cd /, write anywhere the process uid could, and reach the network; the phase-1 path validation only guards the file tools, not bash. (The SDK's SandboxSettings is dead config: Agent::new never reads it.) Replace it with SandboxedBashTool — same name and input schema, registered via custom_tools so it overrides the built-in by name — that confines the command with bubblewrap, gated by permission mode: - Default / AcceptEdits (review modes): run inside a bwrap namespace with the system mounted read-only, the session sandbox dir bind-mounted rw at its real path and made the cwd, /etc //root //home and all other host paths absent, and --unshare-all (network included). Filesystem escape and network egress are blocked by the kernel, not by convention. - FullAccess (explicitly dangerous): unconfined, same as before, but every command is audit-logged with a warning. Fail-closed: in a review mode, if bwrap is unavailable the command is refused rather than silently run unconfined — no fabricated boundary. Verified end-to-end in tests/agent_bash_sandbox.rs by invoking the tool: under Default mode `cat /etc/passwd` sees no host passwd, `ls /` exposes no etc//root//home, a write to /etc/cron.d/x fails (and the host file is never created), and the network is unreachable; in-sandbox work succeeds and lands in the real session dir; FullAccess can see the host. bwrap 0.11.2 confirmed functional on this host (root, CAP_SYS_ADMIN, user namespaces enabled). Phase-1 suites stay green.
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
use crate::auth::jwt::validate_jwt;
|
use crate::auth::jwt::validate_jwt;
|
||||||
use crate::services::agent_workbench::{
|
use crate::services::agent_workbench::{
|
||||||
approval::{self, ApprovalOutcome, SettledState},
|
approval::{self, ApprovalOutcome, SettledState},
|
||||||
|
bash_sandbox::SandboxedBashTool,
|
||||||
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
|
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
|
||||||
sandbox,
|
sandbox,
|
||||||
session_store::{self, UsageDelta},
|
session_store::{self, UsageDelta},
|
||||||
@@ -318,6 +319,13 @@ async fn chat_handler(
|
|||||||
|
|
||||||
let can_use_tool = build_can_use_tool(tx.clone(), cwd.clone(), mode);
|
let can_use_tool = build_can_use_tool(tx.clone(), cwd.clone(), mode);
|
||||||
|
|
||||||
|
// Replace the SDK's built-in Bash (a bare subprocess) with an
|
||||||
|
// OS-sandboxed one confined to this session's directory. Registered as
|
||||||
|
// a custom tool, it overrides the built-in by name; review modes run it
|
||||||
|
// under bwrap, FullAccess runs it unconfined with audit logging.
|
||||||
|
let sandboxed_bash: Arc<dyn open_agent_sdk::types::Tool> =
|
||||||
|
Arc::new(SandboxedBashTool::new(cwd.clone(), mode));
|
||||||
|
|
||||||
let agent = Agent::new(AgentOptions {
|
let agent = Agent::new(AgentOptions {
|
||||||
provider: Some(provider),
|
provider: Some(provider),
|
||||||
model: Some(model.clone()),
|
model: Some(model.clone()),
|
||||||
@@ -325,6 +333,7 @@ async fn chat_handler(
|
|||||||
system_prompt: Some(SYSTEM_PROMPT.to_string()),
|
system_prompt: Some(SYSTEM_PROMPT.to_string()),
|
||||||
max_turns: Some(MAX_TURNS),
|
max_turns: Some(MAX_TURNS),
|
||||||
can_use_tool: Some(can_use_tool),
|
can_use_tool: Some(can_use_tool),
|
||||||
|
custom_tools: vec![sandboxed_bash],
|
||||||
session_id: Some(session_id.clone()),
|
session_id: Some(session_id.clone()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
//! OS-level sandboxed Bash tool for the Agent Workbench.
|
||||||
|
//!
|
||||||
|
//! The open-agent-sdk's built-in `Bash` tool runs commands as a plain
|
||||||
|
//! subprocess with only `current_dir` set — no OS boundary. On a server that
|
||||||
|
//! means an agent's shell can `cat /etc/shadow`, `cd /`, write anywhere the
|
||||||
|
//! process uid can, and reach the network. This tool replaces it (same name +
|
||||||
|
//! schema, registered via `custom_tools` so it overrides the built-in) and
|
||||||
|
//! confines the command with [bubblewrap](https://github.com/containers/bubblewrap):
|
||||||
|
//!
|
||||||
|
//! - **Default / AcceptEdits** (review modes): the command runs inside a
|
||||||
|
//! bwrap namespace with the system mounted read-only, the session sandbox
|
||||||
|
//! directory bind-mounted read-write at its real path, `/etc`, `/root`,
|
||||||
|
//! `/home` and every other host path *absent*, and the network unshared.
|
||||||
|
//! Filesystem reads/writes outside the session dir and all network egress are
|
||||||
|
//! therefore impossible at the kernel level, not merely discouraged.
|
||||||
|
//! - **FullAccess** (explicitly dangerous): the command runs unconfined, the
|
||||||
|
//! same behaviour the bare SDK had, but every command is audit-logged with a
|
||||||
|
//! loud warning.
|
||||||
|
//!
|
||||||
|
//! **Fail-closed:** in a review mode, if `bwrap` is unavailable the command is
|
||||||
|
//! refused rather than silently run unconfined — we never fabricate a boundary.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use crate::services::agent_workbench::permission::PermissionMode;
|
||||||
|
use open_agent_sdk::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
|
||||||
|
const MAX_TIMEOUT_MS: u64 = 600_000;
|
||||||
|
const MAX_OUTPUT_SIZE: usize = 100_000;
|
||||||
|
|
||||||
|
/// Read-only host paths exposed inside the sandbox so common tooling works.
|
||||||
|
/// Deliberately excludes `/etc`, `/root`, `/home`, `/var`, `/proc`-host, etc.
|
||||||
|
const RO_SYSTEM_PATHS: &[&str] = &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/opt"];
|
||||||
|
|
||||||
|
/// A Bash tool that confines commands to the session sandbox via bubblewrap.
|
||||||
|
pub struct SandboxedBashTool {
|
||||||
|
/// Canonical session sandbox directory (bind-mounted rw inside bwrap).
|
||||||
|
session_dir: PathBuf,
|
||||||
|
/// Session permission mode — decides confined vs unconfined.
|
||||||
|
mode: PermissionMode,
|
||||||
|
/// Resolved `bwrap` binary path, if present on this host.
|
||||||
|
bwrap: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxedBashTool {
|
||||||
|
pub fn new(session_dir: PathBuf, mode: PermissionMode) -> Self {
|
||||||
|
Self {
|
||||||
|
session_dir,
|
||||||
|
mode,
|
||||||
|
bwrap: find_bwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this mode confines bash (everything except FullAccess).
|
||||||
|
fn is_confined(&self) -> bool {
|
||||||
|
!matches!(self.mode, PermissionMode::FullAccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate the `bwrap` binary (PATH + well-known locations).
|
||||||
|
fn find_bwrap() -> Option<PathBuf> {
|
||||||
|
for cand in ["/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap"] {
|
||||||
|
let p = Path::new(cand);
|
||||||
|
if p.is_file() {
|
||||||
|
return Some(p.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::env::var_os("PATH").and_then(|paths| {
|
||||||
|
std::env::split_paths(&paths)
|
||||||
|
.map(|d| d.join("bwrap"))
|
||||||
|
.find(|p| p.is_file())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the bwrap argument vector that confines `command` to `session_dir`.
|
||||||
|
///
|
||||||
|
/// Pure and unit-testable. The session directory is bound read-write at its own
|
||||||
|
/// real absolute path and becomes the working directory; the rest of the
|
||||||
|
/// filesystem is either read-only (system paths) or absent. `--unshare-all`
|
||||||
|
/// (which includes the network namespace) plus `--die-with-parent` and
|
||||||
|
/// `--new-session` complete the isolation.
|
||||||
|
pub fn build_bwrap_args(session_dir: &Path, command: &str) -> Vec<String> {
|
||||||
|
let sd = session_dir.to_string_lossy().to_string();
|
||||||
|
let mut args: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for p in RO_SYSTEM_PATHS {
|
||||||
|
// `--ro-bind-try` tolerates paths that don't exist on this host.
|
||||||
|
args.push("--ro-bind-try".into());
|
||||||
|
args.push((*p).into());
|
||||||
|
args.push((*p).into());
|
||||||
|
}
|
||||||
|
// Minimal virtual filesystems.
|
||||||
|
args.push("--dev".into());
|
||||||
|
args.push("/dev".into());
|
||||||
|
args.push("--proc".into());
|
||||||
|
args.push("/proc".into());
|
||||||
|
args.push("--tmpfs".into());
|
||||||
|
args.push("/tmp".into());
|
||||||
|
// The session sandbox, bound rw at its real path, then made the cwd.
|
||||||
|
args.push("--bind".into());
|
||||||
|
args.push(sd.clone());
|
||||||
|
args.push(sd.clone());
|
||||||
|
args.push("--chdir".into());
|
||||||
|
args.push(sd);
|
||||||
|
// Full namespace isolation incl. network; tear down with the parent.
|
||||||
|
args.push("--unshare-all".into());
|
||||||
|
args.push("--die-with-parent".into());
|
||||||
|
args.push("--new-session".into());
|
||||||
|
// Run the command under a shell inside the sandbox.
|
||||||
|
args.push("/bin/bash".into());
|
||||||
|
args.push("-c".into());
|
||||||
|
args.push(command.into());
|
||||||
|
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for SandboxedBashTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"Bash"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Executes a bash command inside an OS-level sandbox confined to the \
|
||||||
|
session working directory (read-only system, no network) unless the \
|
||||||
|
session is in full-access mode. Use for shell operations."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_schema(&self) -> ToolInputSchema {
|
||||||
|
ToolInputSchema {
|
||||||
|
schema_type: "object".to_string(),
|
||||||
|
properties: HashMap::from([
|
||||||
|
(
|
||||||
|
"command".to_string(),
|
||||||
|
json!({ "type": "string", "description": "The command to execute" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"timeout".to_string(),
|
||||||
|
json!({ "type": "number", "description": "Optional timeout in milliseconds (max 600000)" }),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description".to_string(),
|
||||||
|
json!({ "type": "string", "description": "Clear description of what this command does" }),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
required: vec!["command".to_string()],
|
||||||
|
additional_properties: Some(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
|
||||||
|
let command = input
|
||||||
|
.get("command")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.ok_or_else(|| ToolError::InvalidInput("Missing 'command' field".to_string()))?;
|
||||||
|
|
||||||
|
let timeout_ms = input
|
||||||
|
.get("timeout")
|
||||||
|
.and_then(|t| t.as_u64())
|
||||||
|
.unwrap_or(DEFAULT_TIMEOUT_MS)
|
||||||
|
.min(MAX_TIMEOUT_MS);
|
||||||
|
|
||||||
|
// Build the runner per mode.
|
||||||
|
let (program, args): (String, Vec<String>) = if self.is_confined() {
|
||||||
|
let Some(bwrap) = &self.bwrap else {
|
||||||
|
// Fail closed: never downgrade a review-mode session to an
|
||||||
|
// unconfined shell just because the sandbox tool is missing.
|
||||||
|
return Ok(ToolResult::error(
|
||||||
|
"Bash is disabled: the OS sandbox (bwrap) is not available on \
|
||||||
|
this host, and this session runs in a review mode that requires \
|
||||||
|
confinement. Re-run in full-access mode (explicitly unsafe) or \
|
||||||
|
install bubblewrap."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
(
|
||||||
|
bwrap.to_string_lossy().to_string(),
|
||||||
|
build_bwrap_args(&self.session_dir, command),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// FullAccess: unconfined, but audit every command loudly.
|
||||||
|
tracing::warn!(
|
||||||
|
target: "boosapi::agent::bash",
|
||||||
|
mode = "full_access",
|
||||||
|
command = command,
|
||||||
|
"UNCONFINED agent bash command (full-access mode)"
|
||||||
|
);
|
||||||
|
(
|
||||||
|
"/bin/bash".to_string(),
|
||||||
|
vec!["-c".to_string(), command.to_string()],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unconfined mode honours the SDK's working dir; confined mode's cwd is
|
||||||
|
// set inside bwrap via --chdir, so the outer cwd is irrelevant.
|
||||||
|
let outer_cwd = if self.is_confined() {
|
||||||
|
self.session_dir.clone()
|
||||||
|
} else {
|
||||||
|
PathBuf::from(&context.working_dir)
|
||||||
|
};
|
||||||
|
|
||||||
|
let fut = run_capture(&program, &args, &outer_cwd);
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), fut).await {
|
||||||
|
Ok(Ok((stdout, stderr, code))) => Ok(format_result(stdout, stderr, code)),
|
||||||
|
Ok(Err(e)) => Ok(ToolResult::error(format!("Command failed: {e}"))),
|
||||||
|
Err(_) => Ok(ToolResult::error(format!(
|
||||||
|
"Command timed out after {timeout_ms}ms"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_capture(
|
||||||
|
program: &str,
|
||||||
|
args: &[String],
|
||||||
|
cwd: &Path,
|
||||||
|
) -> Result<(String, String, i32), std::io::Error> {
|
||||||
|
let output = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.current_dir(cwd)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
Ok((
|
||||||
|
String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
output.status.code().unwrap_or(-1),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_result(stdout: String, stderr: String, exit_code: i32) -> ToolResult {
|
||||||
|
let mut result = String::new();
|
||||||
|
if !stdout.is_empty() {
|
||||||
|
result.push_str(&stdout);
|
||||||
|
}
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
if !result.is_empty() {
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
result.push_str("STDERR:\n");
|
||||||
|
result.push_str(&stderr);
|
||||||
|
}
|
||||||
|
if result.len() > MAX_OUTPUT_SIZE {
|
||||||
|
result.truncate(MAX_OUTPUT_SIZE);
|
||||||
|
result.push_str("\n... (output truncated)");
|
||||||
|
}
|
||||||
|
if exit_code != 0 {
|
||||||
|
result.push_str(&format!("\n\nExit code: {exit_code}"));
|
||||||
|
}
|
||||||
|
if result.is_empty() {
|
||||||
|
result = "(no output)".to_string();
|
||||||
|
}
|
||||||
|
if exit_code != 0 {
|
||||||
|
ToolResult::error(result)
|
||||||
|
} else {
|
||||||
|
ToolResult::text(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn args_bind_session_dir_and_isolate() {
|
||||||
|
let sd = Path::new("/srv/data/agent_sessions/abc");
|
||||||
|
let args = build_bwrap_args(sd, "echo hi");
|
||||||
|
let joined = args.join(" ");
|
||||||
|
// Session dir is bound rw and made the cwd.
|
||||||
|
assert!(joined.contains("--bind /srv/data/agent_sessions/abc /srv/data/agent_sessions/abc"));
|
||||||
|
assert!(joined.contains("--chdir /srv/data/agent_sessions/abc"));
|
||||||
|
// Network + everything else unshared, parent-bound.
|
||||||
|
assert!(args.iter().any(|a| a == "--unshare-all"));
|
||||||
|
assert!(args.iter().any(|a| a == "--die-with-parent"));
|
||||||
|
// /etc is never bound.
|
||||||
|
assert!(!joined.contains("/etc"));
|
||||||
|
// System paths are read-only.
|
||||||
|
assert!(joined.contains("--ro-bind-try /usr /usr"));
|
||||||
|
// The command is the last argument, run under bash -c.
|
||||||
|
assert_eq!(args.last().unwrap(), "echo hi");
|
||||||
|
let n = args.len();
|
||||||
|
assert_eq!(args[n - 3], "/bin/bash");
|
||||||
|
assert_eq!(args[n - 2], "-c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confinement_follows_mode() {
|
||||||
|
let sd = PathBuf::from("/tmp/x");
|
||||||
|
assert!(SandboxedBashTool::new(sd.clone(), PermissionMode::Default).is_confined());
|
||||||
|
assert!(SandboxedBashTool::new(sd.clone(), PermissionMode::AcceptEdits).is_confined());
|
||||||
|
assert!(!SandboxedBashTool::new(sd, PermissionMode::FullAccess).is_confined());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_overrides_builtin_bash() {
|
||||||
|
let t = SandboxedBashTool::new(PathBuf::from("/tmp/x"), PermissionMode::Default);
|
||||||
|
assert_eq!(t.name(), "Bash");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//!
|
//!
|
||||||
//! - [`permission`]: tool risk classification + the per-mode decision matrix.
|
//! - [`permission`]: tool risk classification + the per-mode decision matrix.
|
||||||
//! - [`sandbox`]: per-session isolated working directory + path validation.
|
//! - [`sandbox`]: per-session isolated working directory + path validation.
|
||||||
|
//! - [`bash_sandbox`]: OS-level (bubblewrap) confinement for the Bash tool.
|
||||||
//! - [`approval`]: process-global registry bridging the SSE approval round-trip.
|
//! - [`approval`]: process-global registry bridging the SSE approval round-trip.
|
||||||
//! - [`session_store`]: persistence of per-session token/cost accounting.
|
//! - [`session_store`]: persistence of per-session token/cost accounting.
|
||||||
//!
|
//!
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
//! `crate::routes::agent`.
|
//! `crate::routes::agent`.
|
||||||
|
|
||||||
pub mod approval;
|
pub mod approval;
|
||||||
|
pub mod bash_sandbox;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod session_store;
|
pub mod session_store;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! 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}");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user