#![cfg(feature = "host-shell")]
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use agent_bridle_core::{Caveats, Gate, Scope, Tool, ToolContext};
use agent_bridle_tool_shell::HostShellTool;
fn ctx(granted: Caveats) -> ToolContext {
Gate::new(0)
.authorize(&HostShellTool::new(), &granted)
.expect("authorize")
}
fn unique_temp(tag: &str) -> PathBuf {
static N: AtomicU64 = AtomicU64::new(0);
std::env::temp_dir().join(format!(
"ab-hostshell-{}-{}-{}",
tag,
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
))
}
#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
#[tokio::test]
async fn dynamic_construct_runs_but_out_of_scope_write_is_kernel_denied() {
use agent_bridle_core::landlock_is_supported;
if !landlock_is_supported() {
eprintln!("skipping: kernel lacks Landlock");
return;
}
let allowed = unique_temp("allowed");
std::fs::create_dir_all(&allowed).unwrap();
let forbidden = unique_temp("forbidden");
std::fs::create_dir_all(&forbidden).unwrap();
let caveats = Caveats {
fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
};
let ok_path = format!("{}/ok.txt", allowed.to_string_lossy());
let evil_path = format!("{}/evil.txt", forbidden.to_string_lossy());
let cmd = format!(
"echo \"$(echo dynamic-ran)\" > {ok_path}; echo escaped > {evil_path} 2>/dev/null; echo done"
);
let out = HostShellTool::new()
.invoke(serde_json::json!({ "cmd": cmd }), &ctx(caveats))
.await
.expect("invoke");
assert_eq!(
out["sandbox_kind"], "landlock",
"engine must report real kernel enforcement: {out}"
);
assert_eq!(
out["disclosure"]["engine"], "sandbox-host",
"engine identity must be disclosed (ADR 0019 D4): {out}"
);
assert_ne!(
out["denied"], true,
"an fs-restricted grant is served: {out}"
);
assert!(
allowed.join("ok.txt").exists(),
"the in-scope write from a dynamic construct must succeed: {out}"
);
let body = std::fs::read_to_string(allowed.join("ok.txt")).unwrap();
assert_eq!(
body.trim(),
"dynamic-ran",
"the $(...) substitution must have executed"
);
assert!(
!forbidden.join("evil.txt").exists(),
"the out-of-scope write must be kernel-denied: {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 macos_dynamic_construct_runs_but_out_of_scope_write_is_seatbelt_denied() {
let allowed = unique_temp("allowed");
std::fs::create_dir_all(&allowed).unwrap();
let forbidden = unique_temp("forbidden");
std::fs::create_dir_all(&forbidden).unwrap();
let caveats = Caveats {
fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
..Caveats::top()
};
let ok_path = format!("{}/ok.txt", allowed.to_string_lossy());
let evil_path = format!("{}/evil.txt", forbidden.to_string_lossy());
let cmd = format!(
"echo \"$(echo dynamic-ran)\" > {ok_path}; echo escaped > {evil_path} 2>/dev/null; echo done"
);
let out = HostShellTool::new()
.invoke(serde_json::json!({ "cmd": cmd }), &ctx(caveats))
.await
.expect("invoke");
assert_eq!(
out["sandbox_kind"], "seatbelt",
"engine must report real kernel enforcement: {out}"
);
assert_ne!(
out["denied"], true,
"an fs-restricted grant is served: {out}"
);
assert!(
allowed.join("ok.txt").exists(),
"the in-scope write from a dynamic construct must succeed: {out}"
);
assert!(
!forbidden.join("evil.txt").exists(),
"the out-of-scope write must be kernel-denied: {out}"
);
let _ = std::fs::remove_dir_all(&allowed);
let _ = std::fs::remove_dir_all(&forbidden);
}
#[tokio::test]
async fn restricted_exec_is_refused_not_run() {
let caveats = Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
};
let sentinel = unique_temp("exec-refused-sentinel");
let cmd = format!("echo pwned > {}", sentinel.to_string_lossy());
let out = HostShellTool::new()
.invoke(serde_json::json!({ "cmd": cmd }), &ctx(caveats))
.await
.expect("invoke");
assert_eq!(
out["denied"], true,
"a restricted exec grant must be refused: {out}"
);
assert_eq!(
out["denials"][0]["kind"], "exec",
"the denial must name the exec axis: {out}"
);
assert_eq!(
out["disclosure"]["engine"], "sandbox-host",
"engine identity disclosed even on refusal: {out}"
);
assert!(
!sentinel.exists(),
"nothing may run when the grant is refused: {out}"
);
}
#[tokio::test]
async fn restricted_net_is_refused() {
let caveats = Caveats {
net: Scope::only(["example.com:443".to_string()]),
..Caveats::top()
};
let out = HostShellTool::new()
.invoke(serde_json::json!({ "cmd": "echo hi" }), &ctx(caveats))
.await
.expect("invoke");
assert_eq!(
out["denied"], true,
"a restricted net grant must be refused: {out}"
);
assert_eq!(
out["denials"][0]["kind"], "net",
"the denial must name the net axis: {out}"
);
}
#[tokio::test]
async fn fully_ambient_grant_runs_the_command() {
let out = HostShellTool::new()
.invoke(
serde_json::json!({ "cmd": "echo \"$(echo composed)\"" }),
&ctx(Caveats::top()),
)
.await
.expect("invoke");
assert_ne!(out["denied"], true, "ambient grant must run: {out}");
assert_eq!(out["exit_code"], 0, "the command must succeed: {out}");
assert_eq!(
out["stdout"].as_str().unwrap_or("").trim(),
"composed",
"the dynamic construct must have executed: {out}"
);
}
#[tokio::test]
async fn full_access_seeds_default_path_into_the_child() {
use agent_bridle_core::default_exec_path;
let out = HostShellTool::new()
.invoke(
serde_json::json!({ "cmd": "printf '%s' \"$PATH\"" }),
&ctx(Caveats::top()),
)
.await
.expect("invoke");
assert_ne!(out["denied"], true, "ambient grant must run: {out}");
assert_eq!(
out["stdout"].as_str().unwrap_or_default(),
default_exec_path(),
"the child must see the seeded default PATH (was unset pre-fix): {out}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn bare_name_resolves_when_path_includes_its_dir() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_temp("bin");
std::fs::create_dir_all(&dir).unwrap();
let tool = dir.join("marker-tool");
std::fs::write(&tool, "#!/bin/sh\necho marker-ran\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
let out = HostShellTool::new()
.invoke(
serde_json::json!({
"cmd": "marker-tool",
"env": { "PATH": dir.to_string_lossy() },
}),
&ctx(Caveats::top()),
)
.await
.expect("invoke");
assert_ne!(out["denied"], true, "ambient grant must run: {out}");
assert_eq!(out["exit_code"], 0, "the bare-name tool must run: {out}");
assert_eq!(
out["stdout"].as_str().unwrap_or_default().trim(),
"marker-ran",
"the bare program name must resolve via the provided PATH: {out}"
);
let _ = std::fs::remove_dir_all(&dir);
}