#![cfg(feature = "shell")]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use agent_bridle_core::{Caveats, Gate, Scope, Tool, ToolContext};
use agent_bridle_tool_shell::ShellTool;
fn ctx(granted: Caveats) -> ToolContext {
Gate::new(0)
.authorize(&ShellTool::new(), &granted)
.expect("authorize")
}
fn exec_only(names: &[&str]) -> Caveats {
Caveats {
exec: Scope::only(names.iter().map(|s| (*s).to_string())),
..Caveats::top()
}
}
fn unique_temp(tag: &str) -> PathBuf {
static N: AtomicU64 = AtomicU64::new(0);
std::env::temp_dir().join(format!(
"ab-redir-{}-{}-{}",
tag,
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
))
}
fn shell_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[tokio::test]
async fn real_echo_runs_and_captures_stdout() {
let out = ShellTool::new()
.invoke(
serde_json::json!({"program": "echo", "args": ["hello"]}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(out["exit_code"], 0);
assert_eq!(out["stdout"], "hello\n");
assert!(out.get("denied").is_none());
}
#[tokio::test]
async fn real_output_cap_is_config_driven() {
let limits = agent_bridle_core::LimitsPolicy {
max_output_bytes: 8,
..agent_bridle_core::LimitsPolicy::default()
};
let out = ShellTool::with_config(limits)
.invoke(
serde_json::json!({"program": "echo", "args": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
let stdout = out["stdout"].as_str().expect("stdout string");
assert!(
stdout.len() <= 8,
"output must be capped at the configured 8 bytes, got {}",
stdout.len()
);
assert_eq!(
out["stdout_truncated"], true,
"a source past the configured cap is flagged truncated"
);
}
#[tokio::test]
async fn real_pipeline_passes_data_between_stages() {
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "echo hello | cat"}),
&ctx(exec_only(&["echo", "cat"])),
)
.await
.expect("invoke");
assert_eq!(out["exit_code"], 0);
assert_eq!(out["stdout"], "hello\n");
}
#[tokio::test]
async fn real_pipeline_exit_code_is_the_last_stage() {
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "true | false"}),
&ctx(exec_only(&["true", "false"])),
)
.await
.expect("invoke");
assert_eq!(
out["exit_code"], 1,
"pipeline exit is the last stage's: {out}"
);
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "false | true"}),
&ctx(exec_only(&["true", "false"])),
)
.await
.expect("invoke");
assert_eq!(out["exit_code"], 0, "no pipefail: {out}");
}
#[tokio::test]
async fn real_stderr_and_nonzero_exit_are_captured() {
let out = ShellTool::new()
.invoke(
serde_json::json!({"program": "cat", "args": ["/nonexistent/agent-bridle/path"]}),
&ctx(exec_only(&["cat"])),
)
.await
.expect("invoke");
assert_ne!(
out["exit_code"], 0,
"cat of a missing file must fail: {out}"
);
assert!(
!out["stderr"].as_str().unwrap_or("").is_empty(),
"stderr must be captured: {out}"
);
}
#[tokio::test]
async fn real_out_of_scope_program_is_denied_and_never_spawns() {
let out = ShellTool::new()
.invoke(
serde_json::json!({"program": "rm", "args": ["-rf", "/tmp/agent-bridle-should-not-exist"]}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(out["denied"], true);
assert_eq!(out["denials"][0]["target"], "rm");
assert!(out.get("exit_code").is_none(), "nothing ran: {out}");
}
#[tokio::test]
async fn real_stdout_redirect_truncates_then_appends_a_file() {
let path = unique_temp("out");
let p = shell_path(&path);
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("echo first > {p}")}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(out["exit_code"], 0, "truncate redirect should run: {out}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first\n");
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("echo second >> {p}")}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(out["exit_code"], 0, "append redirect should run: {out}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first\nsecond\n");
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn real_stdin_redirect_feeds_a_file() {
let path = unique_temp("in");
std::fs::write(&path, "b\na\nc\n").unwrap();
let p = shell_path(&path);
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("cat < {p}")}),
&ctx(exec_only(&["cat"])),
)
.await
.expect("invoke");
assert_eq!(out["exit_code"], 0);
assert_eq!(out["stdout"], "b\na\nc\n");
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn real_pipeline_with_stdout_redirect_on_last_stage() {
let path = unique_temp("pipe");
let p = shell_path(&path);
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("echo piped | cat > {p}")}),
&ctx(exec_only(&["echo", "cat"])),
)
.await
.expect("invoke");
assert_eq!(out["exit_code"], 0);
assert_eq!(
out["stdout"], "",
"last-stage redirect means empty captured stdout: {out}"
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "piped\n");
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn real_and_chain_runs_then_short_circuits() {
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "true && echo ran"}),
&ctx(exec_only(&["true", "echo"])),
)
.await
.expect("invoke");
assert_eq!(out["stdout"], "ran\n");
assert_eq!(out["exit_code"], 0);
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "false && echo nope"}),
&ctx(exec_only(&["false", "echo"])),
)
.await
.expect("invoke");
assert_eq!(out["stdout"], "", "echo must be skipped: {out}");
assert_eq!(out["exit_code"], 1);
}
#[tokio::test]
async fn real_or_fallback_and_semicolon_sequence() {
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "false || echo fallback"}),
&ctx(exec_only(&["false", "echo"])),
)
.await
.expect("invoke");
assert_eq!(out["stdout"], "fallback\n");
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "echo a ; echo b"}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(out["stdout"], "a\nb\n");
}
#[tokio::test]
async fn real_glob_expands_against_the_filesystem() {
let dir = unique_temp("glob");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.rs"), "A").unwrap();
std::fs::write(dir.join("b.rs"), "B").unwrap();
std::fs::write(dir.join("c.txt"), "C").unwrap();
let d = dir.to_string_lossy().into_owned();
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "cat *.rs", "cwd": d}),
&ctx(exec_only(&["cat"])),
)
.await
.expect("invoke");
assert_eq!(out["exit_code"], 0);
assert_eq!(out["stdout"], "AB", "glob expanded + sorted: {out}");
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "cat zzz*", "cwd": d}),
&ctx(exec_only(&["cat"])),
)
.await
.expect("invoke");
assert_ne!(
out["exit_code"], 0,
"unmatched glob → literal, cat fails: {out}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn real_allowlisted_var_expands_from_the_environment() {
let expected = format!("{}\n", std::env::var("HOME").unwrap_or_default());
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "echo $HOME"}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(out["exit_code"], 0);
assert_eq!(
out["stdout"], expected,
"$HOME must expand to the env value: {out}"
);
}
#[tokio::test]
async fn real_env_map_reaches_the_child() {
let marker = format!("ab-env-seam-{}", std::process::id());
let out = ShellTool::new()
.invoke(
serde_json::json!({
"program": "env",
"env": { "AB_ENV_SEAM_PROOF": marker },
}),
&ctx(exec_only(&["env"])),
)
.await
.expect("invoke");
assert_eq!(out["exit_code"], 0, "env must run: {out}");
let stdout = out["stdout"].as_str().unwrap_or_default();
assert!(
stdout.contains(&format!("AB_ENV_SEAM_PROOF={marker}")),
"the injected env var must reach the child: {out}"
);
}
#[tokio::test]
async fn real_mixed_and_quoted_variable_words_expand() {
let home = std::env::var("HOME").unwrap_or_default();
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "echo $HOME/sub"}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(out["stdout"], format!("{home}/sub\n"), "mixed word: {out}");
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "echo \"prefix-$HOME\""}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(
out["stdout"],
format!("prefix-{home}\n"),
"quoted var: {out}"
);
}
#[tokio::test]
async fn real_stderr_redirect_to_file() {
let path = unique_temp("err");
let p = shell_path(&path);
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("cat /nonexistent/agent-bridle 2> {p}")}),
&ctx(exec_only(&["cat"])),
)
.await
.expect("invoke");
assert_ne!(out["exit_code"], 0);
assert_eq!(out["stderr"], "", "stderr went to the file: {out}");
assert!(
!std::fs::read_to_string(&path).unwrap().is_empty(),
"the error must be in the file"
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn real_2to1_merges_stderr_into_captured_stdout() {
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "cat /nonexistent/agent-bridle 2>&1"}),
&ctx(exec_only(&["cat"])),
)
.await
.expect("invoke");
assert_ne!(out["exit_code"], 0);
assert!(
!out["stdout"].as_str().unwrap_or("").is_empty(),
"the error must appear on merged stdout: {out}"
);
assert_eq!(out["stderr"], "", "stderr was merged into stdout: {out}");
}
#[tokio::test]
async fn real_2to1_before_a_pipe_feeds_stderr_downstream() {
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "cat /nonexistent/agent-bridle 2>&1 | cat"}),
&ctx(exec_only(&["cat"])),
)
.await
.expect("invoke");
assert!(
!out["stdout"].as_str().unwrap_or("").is_empty(),
"stderr merged into the pipe must reach the downstream stage: {out}"
);
}
#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
#[tokio::test]
async fn real_landlock_confines_a_spawned_childs_own_write() {
use agent_bridle_core::landlock_is_supported;
if !landlock_is_supported() {
eprintln!("skipping: kernel lacks Landlock");
return;
}
let allowed = unique_temp("ll-allowed");
std::fs::create_dir_all(&allowed).unwrap();
let forbidden = unique_temp("ll-forbidden");
std::fs::create_dir_all(&forbidden).unwrap();
let caveats = Caveats {
exec: Scope::only(["touch".to_string()]),
fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
};
let inside = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("touch {}/ok", allowed.to_string_lossy())}),
&ctx(caveats.clone()),
)
.await
.expect("invoke");
assert_eq!(
inside["exit_code"], 0,
"write within fs_write must succeed: {inside}"
);
assert_eq!(
inside["sandbox_kind"], "landlock",
"must report kernel enforcement: {inside}"
);
assert!(allowed.join("ok").exists(), "the in-scope file must exist");
let outside = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("touch {}/escape", forbidden.to_string_lossy())}),
&ctx(caveats),
)
.await
.expect("invoke");
assert_ne!(
outside["exit_code"], 0,
"the kernel must deny a write outside fs_write scope: {outside}"
);
assert!(
!forbidden.join("escape").exists(),
"the out-of-scope file must NOT have been created"
);
let _ = std::fs::remove_dir_all(&allowed);
let _ = std::fs::remove_dir_all(&forbidden);
}
#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
#[tokio::test]
async fn real_landlock_confines_a_find_exec_grandchild_write() {
use agent_bridle_core::landlock_is_supported;
if !landlock_is_supported() {
eprintln!("skipping: kernel lacks Landlock");
return;
}
let allowed = unique_temp("ll-fe-allowed");
std::fs::create_dir_all(&allowed).unwrap();
std::fs::write(allowed.join("seed"), b"x").unwrap();
let forbidden = unique_temp("ll-fe-forbidden");
std::fs::create_dir_all(&forbidden).unwrap();
let caveats = Caveats {
exec: Scope::only(["find".to_string(), "touch".to_string()]),
fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
};
let inside = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!(
"find {a} -type f -exec touch {a}/ok ';'",
a = allowed.to_string_lossy()
)}),
&ctx(caveats.clone()),
)
.await
.expect("invoke");
assert_eq!(inside["sandbox_kind"], "landlock", "{inside}");
assert!(
allowed.join("ok").exists(),
"an in-scope grandchild write must succeed: {inside}"
);
let _ = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!(
"find {a} -type f -exec touch {f}/escape ';'",
a = allowed.to_string_lossy(),
f = forbidden.to_string_lossy()
)}),
&ctx(caveats),
)
.await
.expect("invoke");
assert!(
!forbidden.join("escape").exists(),
"the out-of-scope grandchild write must be denied by the kernel"
);
let _ = std::fs::remove_dir_all(&allowed);
let _ = std::fs::remove_dir_all(&forbidden);
}
#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
#[tokio::test]
async fn real_landlock_confines_a_grep_dash_f_read_injection() {
use agent_bridle_core::landlock_is_supported;
if !landlock_is_supported() {
eprintln!("skipping: kernel lacks Landlock");
return;
}
let allowed = unique_temp("ll-ri-allowed");
std::fs::create_dir_all(&allowed).unwrap();
std::fs::write(allowed.join("data"), b"hello\n").unwrap();
let forbidden = unique_temp("ll-ri-forbidden");
std::fs::create_dir_all(&forbidden).unwrap();
std::fs::write(forbidden.join("secret"), b"hello\n").unwrap();
let caveats = Caveats {
exec: Scope::only(["grep".to_string()]),
fs_read: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
};
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!(
"grep -f {f}/secret {a}/data",
f = forbidden.to_string_lossy(),
a = allowed.to_string_lossy()
)}),
&ctx(caveats),
)
.await
.expect("invoke");
assert_ne!(
out["exit_code"], 0,
"the kernel must deny grep reading the out-of-scope -f file: {out}"
);
assert_eq!(
out["sandbox_kind"], "landlock",
"must report kernel enforcement: {out}"
);
assert_eq!(out["stdout"], "", "no out-of-scope content may leak: {out}");
let _ = std::fs::remove_dir_all(&allowed);
let _ = std::fs::remove_dir_all(&forbidden);
}
#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
#[tokio::test]
async fn real_seatbelt_confines_a_spawned_childs_own_write() {
use agent_bridle_core::seatbelt_is_supported;
if !seatbelt_is_supported() {
eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
return;
}
let allowed = unique_temp("sb-allowed");
std::fs::create_dir_all(&allowed).unwrap();
let forbidden = unique_temp("sb-forbidden");
std::fs::create_dir_all(&forbidden).unwrap();
let caveats = Caveats {
exec: Scope::only(["touch".to_string()]),
fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
};
let inside = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("touch {}/ok", allowed.to_string_lossy())}),
&ctx(caveats.clone()),
)
.await
.expect("invoke");
assert_eq!(
inside["sandbox_kind"], "seatbelt",
"must report kernel enforcement: {inside}"
);
assert_eq!(
inside["exit_code"], 0,
"write within fs_write must succeed: {inside}"
);
assert!(allowed.join("ok").exists(), "the in-scope file must exist");
let outside = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("touch {}/escape", forbidden.to_string_lossy())}),
&ctx(caveats),
)
.await
.expect("invoke");
assert_ne!(
outside["exit_code"], 0,
"the kernel must deny a write outside fs_write scope: {outside}"
);
assert!(
!forbidden.join("escape").exists(),
"the out-of-scope file must NOT have been created"
);
let _ = std::fs::remove_dir_all(&allowed);
let _ = std::fs::remove_dir_all(&forbidden);
}
#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
#[tokio::test]
async fn real_seatbelt_wrapped_pipeline_pipes_data_between_stages() {
use agent_bridle_core::seatbelt_is_supported;
if !seatbelt_is_supported() {
eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
return;
}
let scope = unique_temp("sb-pipe");
std::fs::create_dir_all(&scope).unwrap();
let caveats = Caveats {
exec: Scope::only(["echo".to_string(), "cat".to_string()]),
fs_write: Scope::only([scope.to_string_lossy().into_owned()]),
..Caveats::top()
};
let out = ShellTool::new()
.invoke(
serde_json::json!({"cmd": "echo wrapped | cat"}),
&ctx(caveats),
)
.await
.expect("invoke");
assert_eq!(out["sandbox_kind"], "seatbelt", "{out}");
assert_eq!(out["exit_code"], 0, "{out}");
assert_eq!(
out["stdout"], "wrapped\n",
"data must flow through both wrapped stages: {out}"
);
let _ = std::fs::remove_dir_all(&scope);
}
#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
#[tokio::test]
async fn real_seatbelt_confines_a_spawned_childs_own_read() {
use agent_bridle_core::seatbelt_is_supported;
if !seatbelt_is_supported() {
eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
return;
}
let allowed = unique_temp("sb-r-allowed");
std::fs::create_dir_all(&allowed).unwrap();
let forbidden = unique_temp("sb-r-forbidden");
std::fs::create_dir_all(&forbidden).unwrap();
std::fs::write(allowed.join("ok.txt"), b"in-scope\n").unwrap();
std::fs::write(forbidden.join("secret.txt"), b"top-secret\n").unwrap();
let caveats = Caveats {
exec: Scope::only(["cat".to_string()]),
fs_read: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
};
let inside = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("cat {}/ok.txt", allowed.to_string_lossy())}),
&ctx(caveats.clone()),
)
.await
.expect("invoke");
assert_eq!(inside["sandbox_kind"], "seatbelt", "{inside}");
assert_eq!(
inside["exit_code"], 0,
"in-scope read must succeed (binary loads + reads): {inside}"
);
assert!(
inside["stdout"]
.as_str()
.unwrap_or_default()
.contains("in-scope"),
"must read the in-scope file's contents: {inside}"
);
let outside = ShellTool::new()
.invoke(
serde_json::json!({"cmd": format!("cat {}/secret.txt", forbidden.to_string_lossy())}),
&ctx(caveats),
)
.await
.expect("invoke");
assert_ne!(
outside["exit_code"], 0,
"the kernel must deny reading a file outside fs_read scope: {outside}"
);
assert!(
!outside["stdout"]
.as_str()
.unwrap_or_default()
.contains("top-secret"),
"out-of-scope file contents must NOT leak: {outside}"
);
let _ = std::fs::remove_dir_all(&allowed);
let _ = std::fs::remove_dir_all(&forbidden);
}
#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
#[tokio::test]
async fn real_seatbelt_denies_egress_when_net_is_empty() {
use agent_bridle_core::seatbelt_is_supported;
if !seatbelt_is_supported() || !std::path::Path::new("/usr/bin/curl").exists() {
eprintln!("skipping: sandbox-exec or curl unavailable");
return;
}
let caveats = Caveats {
exec: Scope::only(["curl".to_string()]),
net: Scope::none(),
..Caveats::top()
};
let out = ShellTool::new()
.invoke(
serde_json::json!({ "cmd": "curl -sS --max-time 5 http://1.1.1.1/" }),
&ctx(caveats),
)
.await
.expect("invoke");
assert_eq!(out["sandbox_kind"], "seatbelt", "{out}");
assert_eq!(
out["enforcement"]["net"], "kernel",
"net:none must report kernel-enforced egress denial: {out}"
);
assert_eq!(
out["exit_code"], 7,
"egress under net:none must be denied at the socket (curl exit 7): {out}"
);
}