use std::collections::BTreeMap;
use std::io::{PipeReader, PipeWriter, Read};
#[cfg(windows)]
use std::io::{Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::process::{Child, Stdio};
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use agent_bridle_core::{
best_available_sandbox, confinement_unenforceable, effective_sandbox_kind, enforcement_report,
human_gate, is_unbridled, loopback_fenced_caveats, net_egress_proxy_hosts, Caveats, Denial,
DenialKind, Disclosure, EnforcementReport, LimitsPolicy, SandboxKind, SandboxPolicy, Tool,
ToolContext, ToolEnvelope, ToolError, ToolResult,
};
use async_trait::async_trait;
use crate::net_proxy;
use crate::parse::{
classify, seg_literal, Arg, Command, Redirect, Refusal, Script, ScriptItem, Seg, Sep, StderrTo,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Captured {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
pub net_denials: Vec<Denial>,
}
pub(crate) struct SpawnCfg {
pub max_output: usize,
pub audit_sink: Option<String>,
pub sandbox: Arc<SandboxPolicy>,
pub unbridled: bool,
}
pub(crate) trait Spawner: Send + Sync {
fn run(
&self,
stages: &[Command],
cwd: Option<&str>,
caveats: &Caveats,
env: &BTreeMap<String, String>,
cfg: &SpawnCfg,
) -> ToolResult<Captured>;
}
struct OsSpawner;
impl Spawner for OsSpawner {
fn run(
&self,
stages: &[Command],
cwd: Option<&str>,
caveats: &Caveats,
env: &BTreeMap<String, String>,
cfg: &SpawnCfg,
) -> ToolResult<Captured> {
if cfg.unbridled {
return run_pipeline(stages, cwd, &[], env, cfg.max_output);
}
if let Some((allow_hosts, fenced)) = egress_proxy_plan(caveats, &cfg.sandbox) {
return run_with_egress_proxy(stages, cwd, &fenced, env, allow_hosts, cfg);
}
if intended_sandbox_kind(caveats, &cfg.sandbox) == SandboxKind::None {
run_pipeline(stages, cwd, &[], env, cfg.max_output)
} else {
run_confined(stages, cwd, caveats, env, cfg)
}
}
}
fn egress_proxy_plan(
caveats: &Caveats,
sandbox: &Arc<SandboxPolicy>,
) -> Option<(Vec<String>, Caveats)> {
let allow_hosts = net_egress_proxy_hosts(caveats)?;
let fenced = loopback_fenced_caveats(caveats);
if intended_sandbox_kind(&fenced, sandbox) == SandboxKind::None {
return None; }
Some((allow_hosts, fenced))
}
fn run_with_egress_proxy(
stages: &[Command],
cwd: Option<&str>,
fenced: &Caveats,
env: &BTreeMap<String, String>,
allow_hosts: Vec<String>,
cfg: &SpawnCfg,
) -> ToolResult<Captured> {
let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(fenced)?;
let proxy = net_proxy::start(
allow_hosts,
Arc::new(net_proxy::StdResolver),
net_audit_sink(cfg.audit_sink.as_deref()),
)
.map_err(ToolError::Exec)?;
let mut env = env.clone();
for (k, v) in proxy.proxy_env() {
env.insert(k, v);
}
let stages = stages.to_vec();
let cwd = cwd.map(str::to_string);
let fenced = fenced.clone();
let max_output = cfg.max_output;
let sandbox = cfg.sandbox.clone();
let captured = std::thread::Builder::new()
.name("agent-bridle-confined".to_string())
.spawn(move || {
best_available_sandbox(&sandbox).apply(&fenced)?;
run_pipeline(&stages, cwd.as_deref(), &prefix, &env, max_output)
})
.map_err(ToolError::Exec)?
.join()
.map_err(|_| {
ToolError::Exec(std::io::Error::other("confined execution thread panicked"))
})?;
let refused = proxy.refused_hosts();
drop(proxy); let mut captured = captured?;
captured.net_denials = refused
.into_iter()
.map(|host| Denial {
kind: DenialKind::Net,
reason: format!("net does not permit '{host}'"),
target: host,
})
.collect();
Ok(captured)
}
fn net_audit_sink(configured: Option<&str>) -> Arc<dyn net_proxy::AuditSink> {
match configured {
Some(path) if !path.is_empty() => std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map(|f| Arc::new(net_proxy::JsonlSink::new(f)) as Arc<dyn net_proxy::AuditSink>)
.unwrap_or_else(|_| Arc::new(net_proxy::NullSink)),
_ => Arc::new(net_proxy::NullSink),
}
}
fn intended_sandbox_kind(caveats: &Caveats, sandbox: &Arc<SandboxPolicy>) -> SandboxKind {
effective_sandbox_kind(best_available_sandbox(sandbox).kind(), caveats)
}
fn run_confined(
stages: &[Command],
cwd: Option<&str>,
caveats: &Caveats,
env: &BTreeMap<String, String>,
cfg: &SpawnCfg,
) -> ToolResult<Captured> {
let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(caveats)?;
let stages = stages.to_vec();
let cwd = cwd.map(str::to_string);
let caveats = caveats.clone();
let env = env.clone();
let max_output = cfg.max_output;
let sandbox = cfg.sandbox.clone();
std::thread::Builder::new()
.name("agent-bridle-confined".to_string())
.spawn(move || {
best_available_sandbox(&sandbox).apply(&caveats)?;
run_pipeline(&stages, cwd.as_deref(), &prefix, &env, max_output)
})
.map_err(ToolError::Exec)?
.join()
.map_err(|_| ToolError::Exec(std::io::Error::other("confined execution thread panicked")))?
}
static SHELL_SCHEMA: LazyLock<serde_json::Value> = LazyLock::new(|| {
serde_json::from_str(include_str!("shell_tool.schema.json"))
.expect("embedded shell_tool.schema.json must be valid JSON")
});
#[derive(Clone)]
pub struct ShellTool {
spawner: Arc<dyn Spawner>,
env: Arc<dyn EnvProvider>,
lister: Arc<dyn DirLister>,
limits: LimitsPolicy,
sandbox: Arc<SandboxPolicy>,
}
impl std::fmt::Debug for ShellTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ShellTool")
}
}
impl ShellTool {
#[must_use]
pub fn new() -> Self {
Self::with_config(LimitsPolicy::default())
}
#[must_use]
pub fn with_config(limits: LimitsPolicy) -> Self {
Self {
spawner: Arc::new(OsSpawner),
env: Arc::new(RealEnv),
lister: Arc::new(RealDirLister),
limits,
sandbox: Arc::new(SandboxPolicy::default()),
}
}
#[must_use]
pub fn with_sandbox_policy(mut self, sandbox: SandboxPolicy) -> Self {
self.sandbox = Arc::new(sandbox);
self
}
#[cfg(test)]
fn with_spawner(spawner: Arc<dyn Spawner>) -> Self {
Self {
spawner,
env: Arc::new(RealEnv),
lister: Arc::new(RealDirLister),
limits: LimitsPolicy::default(),
sandbox: Arc::new(SandboxPolicy::default()),
}
}
#[cfg(test)]
fn with_spawner_and_env(spawner: Arc<dyn Spawner>, env: Arc<dyn EnvProvider>) -> Self {
Self {
spawner,
env,
lister: Arc::new(RealDirLister),
limits: LimitsPolicy::default(),
sandbox: Arc::new(SandboxPolicy::default()),
}
}
#[cfg(test)]
fn with_seams(
spawner: Arc<dyn Spawner>,
env: Arc<dyn EnvProvider>,
lister: Arc<dyn DirLister>,
) -> Self {
Self {
spawner,
env,
lister,
limits: LimitsPolicy::default(),
sandbox: Arc::new(SandboxPolicy::default()),
}
}
}
impl Default for ShellTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for ShellTool {
fn name(&self) -> &str {
"shell"
}
fn schema(&self) -> serde_json::Value {
let mut schema = SHELL_SCHEMA.clone();
schema["properties"]["timeout_secs"]["maximum"] =
serde_json::Value::from(self.limits.max_timeout_secs);
schema
}
async fn invoke(
&self,
args: serde_json::Value,
cx: &ToolContext,
) -> ToolResult<serde_json::Value> {
let parsed = ShellArgs::parse(&args, &self.limits)?;
let unbridled = is_unbridled();
let sandbox_kind = if unbridled {
SandboxKind::None
} else {
match egress_proxy_plan(cx.caveats(), &self.sandbox) {
Some((_, fenced)) => intended_sandbox_kind(&fenced, &self.sandbox),
None => intended_sandbox_kind(cx.caveats(), &self.sandbox),
}
};
let enforcement = enforcement_report(cx.caveats(), sandbox_kind);
let mut script = match parsed.script() {
Ok(s) => s,
Err(refusal) => return Ok(refused_envelope(sandbox_kind, enforcement, &refusal)),
};
for item in &mut script {
for stage in &mut item.pipeline {
let mut new_argv: Vec<Arg> = Vec::with_capacity(stage.argv.len());
for (i, arg) in stage.argv.drain(..).enumerate() {
let pattern: Option<String> = if i == 0 {
None
} else {
match &arg {
Arg::Glob(p) => Some(p.clone()),
Arg::VarGlob(segs) => {
match expand_varglob(segs, &*self.env, &self.limits.var_allowlist) {
Ok(p) => Some(p),
Err((target, e)) => {
return Ok(deny(
sandbox_kind,
enforcement,
DenialKind::Exec,
&target,
&e,
))
}
}
}
_ => None,
}
};
match pattern {
Some(p) => {
let mut leash = |dir: &Path| cx.check_path_read(dir);
match expand_glob_walk(
&p,
parsed.cwd.as_deref(),
&*self.lister,
&mut leash,
self.limits.max_glob_depth,
self.limits.max_glob_matches,
) {
Ok(ms) => new_argv.extend(ms.into_iter().map(Arg::Lit)),
Err(e) => {
return Ok(deny(
sandbox_kind,
enforcement,
DenialKind::Open,
&p,
&e,
))
}
}
}
None => new_argv.push(arg),
}
}
stage.argv = new_argv;
for redirect in &mut stage.redirects {
let segs = match redirect {
Redirect::Stdout { path, .. }
| Redirect::Stderr { path, .. }
| Redirect::Stdin { path } => path,
Redirect::StderrToStdout => continue,
};
match expand_redirect_target(segs, &*self.env, &self.limits.var_allowlist) {
Ok(resolved) => *segs = vec![Seg::Lit(resolved)],
Err((target, e)) => {
return Ok(deny(
sandbox_kind,
enforcement,
DenialKind::Open,
&target,
&e,
))
}
}
}
}
}
for item in &script {
for stage in &item.pipeline {
match stage.argv.first() {
Some(Arg::Lit(program)) => {
if let Err(e) = cx.check_exec(program) {
return Ok(deny(
sandbox_kind,
enforcement,
DenialKind::Exec,
program,
&e,
));
}
}
Some(Arg::Glob(pattern)) => {
return Ok(deny(
sandbox_kind,
enforcement,
DenialKind::Exec,
pattern,
&ToolError::denied("a glob pattern is not allowed as a program name"),
));
}
Some(Arg::Var(_segs)) => {
return Ok(deny(
sandbox_kind,
enforcement,
DenialKind::Exec,
"$VAR",
&ToolError::denied("a variable is not allowed as a program name"),
));
}
Some(Arg::VarGlob(_)) => {
return Ok(deny(
sandbox_kind,
enforcement,
DenialKind::Exec,
"$VAR/glob",
&ToolError::denied("a glob pattern is not allowed as a program name"),
));
}
None => {} }
for arg in &stage.argv {
match arg {
Arg::Var(segs) => {
for seg in segs {
if let Seg::Var(name) = seg {
if !is_allowed_var(name, &self.limits.var_allowlist) {
return Ok(deny(
sandbox_kind,
enforcement,
DenialKind::Exec,
&format!("${name}"),
&ToolError::denied(format!(
"variable ${name} is not in the confined shell's allowlist"
)),
));
}
}
}
}
Arg::Glob(_) => unreachable!("glob expanded at admission"),
Arg::VarGlob(_) => unreachable!("VarGlob expanded at admission"),
Arg::Lit(_) => {}
}
}
for redirect in &stage.redirects {
let (path, checked) = match redirect {
Redirect::Stdout { path, .. } | Redirect::Stderr { path, .. } => {
let p = seg_literal(path).expect("redirect target lowered");
(p, cx.check_path_write(Path::new(p)))
}
Redirect::Stdin { path } => {
let p = seg_literal(path).expect("redirect target lowered");
(p, cx.check_path_read(Path::new(p)))
}
Redirect::StderrToStdout => continue,
};
if let Err(e) = checked {
return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, path, &e));
}
}
}
}
if let Some(cwd) = &parsed.cwd {
if let Err(e) = cx.check_path_read(Path::new(cwd)) {
return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, cwd, &e));
}
}
if !unbridled && confinement_unenforceable(sandbox_kind, cx.caveats(), cx.strength_floor())
{
return Ok(deny(
sandbox_kind,
enforcement,
DenialKind::Exec,
"confinement",
&ToolError::denied(format!(
"a restricted filesystem/exec/net axis cannot be enforced on this host \
at the required strength floor ({:?}); refusing to run unconfined",
cx.strength_floor()
)),
));
}
let spawner = Arc::clone(&self.spawner);
let cwd = parsed.cwd.clone();
let timeout = parsed.timeout;
let cfg = SpawnCfg {
max_output: self.limits.max_output_bytes,
audit_sink: self.limits.audit_sink.clone(),
sandbox: Arc::clone(&self.sandbox),
unbridled,
};
let disclosure = Disclosure {
unbridled,
human_gate: human_gate(),
..Disclosure::default()
};
let env = parsed.env;
let caveats = cx.caveats().clone();
let run = tokio::task::spawn_blocking(move || {
run_script(&*spawner, &script, cwd.as_deref(), &caveats, &env, &cfg)
});
match tokio::time::timeout(timeout, run).await {
Ok(joined) => {
let captured = joined
.map_err(|e| ToolError::Other(anyhow::anyhow!("shell task panicked: {e}")))??;
Ok(ToolEnvelope::new(sandbox_kind)
.with_enforcement(enforcement)
.with_disclosure(disclosure)
.with_exit_code(captured.exit_code)
.with_truncation(captured.stdout_truncated, captured.stderr_truncated)
.with_stdout(captured.stdout)
.with_stderr(captured.stderr)
.with_denials(captured.net_denials)
.with_timed_out(false)
.into_json())
}
Err(_elapsed) => Ok(ToolEnvelope::new(sandbox_kind)
.with_enforcement(enforcement)
.with_disclosure(disclosure)
.with_stderr(format!("command timed out after {}s", timeout.as_secs()))
.with_timed_out(true)
.into_json()),
}
}
}
fn run_script(
spawner: &dyn Spawner,
script: &[ScriptItem],
cwd: Option<&str>,
caveats: &Caveats,
env: &BTreeMap<String, String>,
cfg: &SpawnCfg,
) -> ToolResult<Captured> {
let mut stdout = String::new();
let mut stderr = String::new();
let mut status: i32 = 0;
let mut stdout_truncated = false;
let mut stderr_truncated = false;
let mut net_denials: Vec<Denial> = Vec::new();
for item in script {
let run_it = match item.sep {
Sep::Seq => true,
Sep::And => status == 0,
Sep::Or => status != 0,
};
if run_it {
let captured = spawner.run(&item.pipeline, cwd, caveats, env, cfg)?;
stdout.push_str(&captured.stdout);
stderr.push_str(&captured.stderr);
stdout_truncated |= captured.stdout_truncated;
stderr_truncated |= captured.stderr_truncated;
net_denials.extend(captured.net_denials);
status = captured.exit_code;
}
}
let stdout_truncated = stdout_truncated || stdout.len() > cfg.max_output;
let stderr_truncated = stderr_truncated || stderr.len() > cfg.max_output;
Ok(Captured {
exit_code: status,
stdout: cap_string(stdout, cfg.max_output),
stderr: cap_string(stderr, cfg.max_output),
net_denials,
stdout_truncated,
stderr_truncated,
})
}
fn deny(
sandbox_kind: SandboxKind,
enforcement: EnforcementReport,
kind: DenialKind,
target: &str,
err: &ToolError,
) -> serde_json::Value {
ToolEnvelope::new(sandbox_kind)
.with_enforcement(enforcement)
.with_disclosure(unbridle_disclosure())
.with_denials(vec![Denial {
kind,
target: target.to_string(),
reason: err.to_string(),
}])
.into_json()
}
fn unbridle_disclosure() -> Disclosure {
Disclosure {
unbridled: is_unbridled(),
human_gate: human_gate(),
..Disclosure::default()
}
}
fn refused_envelope(
sandbox_kind: SandboxKind,
enforcement: EnforcementReport,
refusal: &Refusal,
) -> serde_json::Value {
ToolEnvelope::new(sandbox_kind)
.with_enforcement(enforcement)
.with_disclosure(unbridle_disclosure())
.with_denials(vec![Denial {
kind: DenialKind::Exec,
target: refusal.construct(),
reason: refusal.to_string(),
}])
.into_json()
}
struct ShellArgs {
program: Option<String>,
args: Vec<String>,
cmd: Option<String>,
cwd: Option<String>,
env: BTreeMap<String, String>,
timeout: Duration,
}
impl ShellArgs {
fn parse(v: &serde_json::Value, limits: &LimitsPolicy) -> ToolResult<Self> {
let obj = v
.as_object()
.ok_or_else(|| ToolError::denied("shell args must be a JSON object"))?;
let program = obj
.get("program")
.and_then(|x| x.as_str())
.map(String::from);
let cmd = obj.get("cmd").and_then(|x| x.as_str()).map(String::from);
let args = obj
.get("args")
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let cwd = obj.get("cwd").and_then(|x| x.as_str()).map(String::from);
let env = obj
.get("env")
.and_then(|x| x.as_object())
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect::<BTreeMap<String, String>>()
})
.unwrap_or_default();
let timeout_secs = obj
.get("timeout_secs")
.and_then(serde_json::Value::as_u64)
.unwrap_or(limits.default_timeout_secs)
.clamp(1, limits.max_timeout_secs);
match (&program, &cmd) {
(Some(_), Some(_)) => {
return Err(ToolError::denied(
"provide exactly one of `program` or `cmd`, not both",
))
}
(None, None) => return Err(ToolError::denied("provide one of `program` or `cmd`")),
_ => {}
}
if program.is_none() && !args.is_empty() {
return Err(ToolError::denied(
"`args` may only be used together with `program`",
));
}
Ok(Self {
program,
args,
cmd,
cwd,
env,
timeout: Duration::from_secs(timeout_secs),
})
}
fn script(&self) -> Result<Script, Refusal> {
if let Some(program) = &self.program {
let mut argv = Vec::with_capacity(1 + self.args.len());
argv.push(Arg::Lit(program.clone()));
argv.extend(self.args.iter().cloned().map(Arg::Lit));
Ok(vec![ScriptItem {
sep: Sep::Seq,
pipeline: vec![Command {
argv,
redirects: Vec::new(),
}],
}])
} else {
classify(self.cmd.as_deref().unwrap_or(""))
}
}
}
fn is_allowed_var(name: &str, allowlist: &[String]) -> bool {
allowlist.iter().any(|v| v == name)
}
pub(crate) trait EnvProvider: Send + Sync {
fn get(&self, name: &str) -> Option<String>;
}
pub(crate) struct RealEnv;
impl EnvProvider for RealEnv {
fn get(&self, name: &str) -> Option<String> {
std::env::var(name).ok()
}
}
fn expand_redirect_target(
segs: &[Seg],
env: &dyn EnvProvider,
allowlist: &[String],
) -> Result<String, (String, ToolError)> {
let mut out = String::new();
for seg in segs {
match seg {
Seg::Lit(s) => out.push_str(s),
Seg::Var(name) => {
if !is_allowed_var(name, allowlist) {
return Err((
format!("${name}"),
ToolError::denied(format!(
"variable ${name} is not in the confined shell's allowlist"
)),
));
}
out.push_str(&env.get(name).unwrap_or_default());
}
}
}
Ok(out)
}
fn expand_varglob(
segs: &[Seg],
env: &dyn EnvProvider,
allowlist: &[String],
) -> Result<String, (String, ToolError)> {
let mut out = String::new();
let mut last_var_byte: Option<usize> = None; let mut last_slash_byte: Option<usize> = None; for seg in segs {
match seg {
Seg::Lit(s) => {
for ch in s.chars() {
if ch == '/' {
last_slash_byte = Some(out.len());
}
out.push(ch);
}
}
Seg::Var(name) => {
if !is_allowed_var(name, allowlist) {
return Err((
format!("${name}"),
ToolError::denied(format!(
"variable ${name} is not in the confined shell's allowlist"
)),
));
}
for ch in env.get(name).unwrap_or_default().chars() {
if ch == '/' {
last_slash_byte = Some(out.len());
}
last_var_byte = Some(out.len());
out.push(ch);
}
}
}
}
let basename_start = last_slash_byte.map_or(0, |i| i + 1);
if last_var_byte.is_some_and(|v| v >= basename_start) {
return Err((
"$VAR".to_string(),
ToolError::denied(
"a variable in a glob's basename is not supported (re-injection guard); \
put the variable in the directory prefix, e.g. $DIR/*.rs",
),
));
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GlobEntry {
pub name: String,
pub is_dir: bool,
}
pub(crate) trait DirLister: Send + Sync {
fn list(&self, dir: &Path) -> Vec<GlobEntry>;
}
pub(crate) struct RealDirLister;
impl DirLister for RealDirLister {
fn list(&self, dir: &Path) -> Vec<GlobEntry> {
std::fs::read_dir(dir)
.map(|rd| {
rd.filter_map(|e| {
let e = e.ok()?;
let name = e.file_name().into_string().ok()?;
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
Some(GlobEntry { name, is_dir })
})
.collect()
})
.unwrap_or_default()
}
}
fn join_rel(rel: &str, name: &str) -> String {
if rel.is_empty() {
name.to_string()
} else if rel == "/" {
format!("/{name}")
} else {
format!("{rel}/{name}")
}
}
fn descend_all(
real: &Path,
rel: &str,
list: &dyn DirLister,
leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
depth: usize,
max_matches: usize,
out: &mut Vec<(PathBuf, String)>,
) -> ToolResult<()> {
if depth == 0 || out.len() >= max_matches {
return Ok(());
}
leash(real)?;
let mut entries = list.list(real);
entries.sort_by(|a, b| a.name.cmp(&b.name));
for e in entries {
if e.is_dir && !e.name.starts_with('.') {
let child_real = real.join(&e.name);
let child_rel = join_rel(rel, &e.name);
out.push((child_real.clone(), child_rel.clone()));
if out.len() >= max_matches {
break;
}
descend_all(
&child_real,
&child_rel,
list,
leash,
depth - 1,
max_matches,
out,
)?;
}
}
Ok(())
}
fn expand_glob_walk(
pattern: &str,
cwd: Option<&str>,
list: &dyn DirLister,
leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
max_depth: usize,
max_matches: usize,
) -> ToolResult<Vec<String>> {
let absolute = pattern.starts_with('/');
let segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
let base_real = if absolute {
PathBuf::from("/")
} else {
cwd.map_or_else(|| PathBuf::from("."), PathBuf::from)
};
let base_rel = if absolute {
"/".to_string()
} else {
String::new()
};
let mut frontier: Vec<(PathBuf, String)> = vec![(base_real, base_rel)];
for seg in &segments {
let mut next: Vec<(PathBuf, String)> = Vec::new();
if *seg == "**" {
for (real, rel) in &frontier {
next.push((real.clone(), rel.clone())); descend_all(real, rel, list, leash, max_depth, max_matches, &mut next)?;
}
} else {
let seg_hidden = seg.starts_with('.');
for (real, rel) in &frontier {
leash(real)?;
let mut entries = list.list(real);
entries.sort_by(|a, b| a.name.cmp(&b.name));
for e in entries {
if (seg_hidden || !e.name.starts_with('.')) && fnmatch(seg, &e.name) {
next.push((real.join(&e.name), join_rel(rel, &e.name)));
if next.len() >= max_matches {
break;
}
}
}
}
}
frontier = next;
if frontier.is_empty() {
break;
}
}
let mut matches: Vec<String> = frontier.into_iter().map(|(_, rel)| rel).collect();
matches.retain(|m| !m.is_empty()); matches.sort();
matches.dedup();
if matches.is_empty() {
Ok(vec![pattern.to_string()])
} else {
Ok(matches)
}
}
fn fnmatch(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
fnmatch_inner(&p, &n)
}
fn fnmatch_inner(p: &[char], n: &[char]) -> bool {
match p.first() {
None => n.is_empty(),
Some('*') => fnmatch_inner(&p[1..], n) || (!n.is_empty() && fnmatch_inner(p, &n[1..])),
Some('?') => !n.is_empty() && fnmatch_inner(&p[1..], &n[1..]),
Some('[') => {
if n.is_empty() {
return false;
}
match match_class(&p[1..], n[0]) {
Some((matched, rest)) => matched && fnmatch_inner(rest, &n[1..]),
None => n[0] == '[' && fnmatch_inner(&p[1..], &n[1..]),
}
}
Some(&c) => !n.is_empty() && c == n[0] && fnmatch_inner(&p[1..], &n[1..]),
}
}
fn match_class(p: &[char], c: char) -> Option<(bool, &[char])> {
let mut i = 0;
let negate = matches!(p.first(), Some('!' | '^'));
if negate {
i = 1;
}
let mut matched = false;
let mut first = true;
while i < p.len() {
if p[i] == ']' && !first {
return Some((matched ^ negate, &p[i + 1..]));
}
first = false;
if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
if c >= p[i] && c <= p[i + 2] {
matched = true;
}
i += 3;
} else {
if c == p[i] {
matched = true;
}
i += 1;
}
}
None
}
fn open_for_write(path: &str, append: bool) -> std::io::Result<std::fs::File> {
#[cfg(windows)]
if append {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(path)?;
file.seek(SeekFrom::End(0))?;
return Ok(file);
}
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(!append)
.append(append)
.open(path)
}
fn kill_all(children: &mut [Child]) {
for child in children.iter_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
fn expand_stage_argv(stage: &Command, _cwd: Option<&str>) -> Vec<String> {
let mut argv = Vec::with_capacity(stage.argv.len());
for arg in &stage.argv {
match arg {
Arg::Lit(s) => argv.push(s.clone()),
Arg::Var(segs) => {
let mut word = String::new();
for seg in segs {
match seg {
Seg::Lit(s) => word.push_str(s),
Seg::Var(name) => word.push_str(&std::env::var(name).unwrap_or_default()),
}
}
argv.push(word);
}
Arg::Glob(_) => unreachable!("glob expanded at admission"),
Arg::VarGlob(_) => unreachable!("VarGlob lowered/expanded at admission"),
}
}
argv
}
fn run_pipeline(
stages: &[Command],
cwd: Option<&str>,
wrap: &[String],
env: &BTreeMap<String, String>,
max_output: usize,
) -> ToolResult<Captured> {
debug_assert!(!stages.is_empty(), "the parser guarantees ≥1 stage");
let n = stages.len();
let last = n - 1;
let mut children: Vec<Child> = Vec::with_capacity(n);
let mut prev_stdin: Option<PipeReader> = None;
let mut stdout_capture: Option<PipeReader> = None;
let mut stderr_threads: Vec<std::thread::JoinHandle<(Vec<u8>, bool)>> = Vec::new();
for (i, stage) in stages.iter().enumerate() {
let is_last = i == last;
let stage_argv = expand_stage_argv(stage, cwd);
let argv: Vec<String> = if wrap.is_empty() {
stage_argv
} else {
wrap.iter().cloned().chain(stage_argv).collect()
};
let mut cmd = std::process::Command::new(&argv[0]);
cmd.args(&argv[1..]);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
for (k, v) in env {
cmd.env(k, v);
}
if let Some(path) = stage.stdin_path() {
let file = ok_or_kill(std::fs::File::open(path), &mut children)?;
cmd.stdin(Stdio::from(file));
prev_stdin = None;
} else {
cmd.stdin(match prev_stdin.take() {
Some(reader) => Stdio::from(reader),
None => Stdio::null(),
});
}
let dup_source: DupSource;
if let Some((path, append)) = stage.stdout_redirect() {
let file = ok_or_kill(open_for_write(path, append), &mut children)?;
let clone = ok_or_kill(file.try_clone(), &mut children)?;
cmd.stdout(Stdio::from(file));
dup_source = DupSource::File(clone);
} else {
let (reader, writer) = ok_or_kill(std::io::pipe(), &mut children)?;
let clone = ok_or_kill(writer.try_clone(), &mut children)?;
cmd.stdout(Stdio::from(writer));
if is_last {
stdout_capture = Some(reader);
} else {
prev_stdin = Some(reader);
}
dup_source = DupSource::Pipe(clone);
}
match stage.stderr_disposition() {
StderrTo::Stdout => match dup_source {
DupSource::File(f) => {
cmd.stderr(Stdio::from(f));
}
DupSource::Pipe(w) => {
cmd.stderr(Stdio::from(w));
}
},
StderrTo::File { path, append } => {
let file = ok_or_kill(open_for_write(&path, append), &mut children)?;
cmd.stderr(Stdio::from(file));
}
StderrTo::Capture => {
cmd.stderr(Stdio::piped());
}
}
let mut child = ok_or_kill(cmd.spawn(), &mut children)?;
if matches!(stage.stderr_disposition(), StderrTo::Capture) {
let err = child.stderr.take().expect("stderr is piped");
stderr_threads.push(std::thread::spawn(move || read_capped(err, max_output)));
}
children.push(child);
}
let stdout_thread =
stdout_capture.map(|reader| std::thread::spawn(move || read_capped(reader, max_output)));
let mut exit_code = -1;
for (i, child) in children.iter_mut().enumerate() {
let status = child.wait().map_err(ToolError::Exec)?;
if i == last {
exit_code = status.code().unwrap_or(-1);
}
}
let (stdout, stdout_truncated) =
stdout_thread.map_or((Vec::new(), false), |h| h.join().unwrap_or_default());
let mut stderr = Vec::new();
let mut stderr_truncated = false;
for h in stderr_threads {
let (buf, trunc) = h.join().unwrap_or_default();
stderr.extend(buf);
stderr_truncated |= trunc;
}
let stderr_truncated = stderr_truncated || stderr.len() > max_output;
Ok(Captured {
exit_code,
stdout: capped_utf8(&stdout, max_output),
stderr: capped_utf8(&stderr, max_output),
stdout_truncated,
stderr_truncated,
net_denials: Vec::new(),
})
}
enum DupSource {
File(std::fs::File),
Pipe(PipeWriter),
}
fn ok_or_kill<T>(result: std::io::Result<T>, children: &mut [Child]) -> ToolResult<T> {
result.map_err(|e| {
kill_all(children);
ToolError::Exec(e)
})
}
fn read_capped(mut reader: impl Read, max_output: usize) -> (Vec<u8>, bool) {
let mut buf = Vec::new();
let _ = (&mut reader).take(max_output as u64).read_to_end(&mut buf);
let mut probe = [0u8; 1];
let truncated = matches!(reader.read(&mut probe), Ok(n) if n > 0);
(buf, truncated)
}
fn capped_utf8(bytes: &[u8], max_output: usize) -> String {
let slice = &bytes[..bytes.len().min(max_output)];
String::from_utf8_lossy(slice).into_owned()
}
fn cap_string(mut s: String, max_output: usize) -> String {
if s.len() > max_output {
let mut end = max_output;
while !s.is_char_boundary(end) {
end -= 1;
}
s.truncate(end);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use agent_bridle_core::{Caveats, Gate, Scope};
use std::collections::HashMap;
use std::sync::Mutex;
#[test]
fn schema_loads_from_data_file_with_expected_shape() {
let s = ShellTool::new().schema();
assert_eq!(s["type"], "object");
assert_eq!(s["additionalProperties"], false);
for key in ["program", "args", "cmd", "cwd", "env", "timeout_secs"] {
assert!(
s["properties"].get(key).is_some(),
"schema is missing the `{key}` property: {s}"
);
}
}
#[test]
fn schema_timeout_maximum_tracks_the_configured_limits() {
let limits = agent_bridle_core::LimitsPolicy {
max_timeout_secs: 7,
..agent_bridle_core::LimitsPolicy::default()
};
let s = ShellTool::with_config(limits).schema();
assert_eq!(s["properties"]["timeout_secs"]["maximum"], 7);
assert!(SHELL_SCHEMA["properties"]["timeout_secs"]
.get("maximum")
.is_none());
}
#[derive(Default)]
struct MockSpawner {
calls: Mutex<Vec<Vec<Command>>>,
envs: Mutex<Vec<BTreeMap<String, String>>>,
exit_by_program: HashMap<String, i32>,
block_ms: u64,
net_denials: Vec<Denial>,
}
impl MockSpawner {
fn with_exit(program: &str, code: i32) -> Self {
let mut m = Self::default();
m.exit_by_program.insert(program.to_string(), code);
m
}
fn with_net_denials(denials: Vec<Denial>) -> Self {
Self {
net_denials: denials,
..Self::default()
}
}
}
fn prog(stage: &Command) -> &str {
match stage.argv.first() {
Some(Arg::Lit(s) | Arg::Glob(s)) => s,
Some(Arg::Var(_) | Arg::VarGlob(_)) | None => "",
}
}
impl Spawner for MockSpawner {
fn run(
&self,
stages: &[Command],
_cwd: Option<&str>,
_caveats: &Caveats,
env: &BTreeMap<String, String>,
_cfg: &SpawnCfg,
) -> ToolResult<Captured> {
self.calls.lock().unwrap().push(stages.to_vec());
self.envs.lock().unwrap().push(env.clone());
if self.block_ms > 0 {
std::thread::sleep(Duration::from_millis(self.block_ms));
}
Ok(Captured {
exit_code: self
.exit_by_program
.get(prog(&stages[0]))
.copied()
.unwrap_or(0),
stdout: String::new(),
stderr: String::new(),
net_denials: self.net_denials.clone(),
..Default::default()
})
}
}
fn ctx(granted: Caveats) -> ToolContext {
Gate::new(0)
.authorize(&ShellTool::new(), &granted)
.expect("authorize")
}
fn ctx_strong(granted: Caveats) -> ToolContext {
Gate::new(0)
.with_strength_floor(agent_bridle_core::AxisEnforcement::Kernel)
.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 calls(mock: &Arc<MockSpawner>) -> Vec<Vec<Command>> {
mock.calls.lock().unwrap().clone()
}
fn envs(mock: &Arc<MockSpawner>) -> Vec<BTreeMap<String, String>> {
mock.envs.lock().unwrap().clone()
}
#[tokio::test]
async fn strong_principal_fails_closed_on_unenforceable_exec() {
let granted = exec_only(&["echo"]);
let exec_is_kernel_confined = enforcement_report(
&granted,
intended_sandbox_kind(&granted, &Arc::new(SandboxPolicy::default())),
)
.exec
== Some(agent_bridle_core::AxisEnforcement::Kernel);
let mock = Arc::new(MockSpawner::default());
let out = ShellTool::with_spawner(mock.clone())
.invoke(
serde_json::json!({"cmd": "echo hi"}),
&ctx_strong(granted.clone()),
)
.await
.expect("invoke");
if exec_is_kernel_confined {
assert_ne!(
out["denied"],
serde_json::json!(true),
"kernel-confined exec must run for a strong principal: {out}"
);
assert_eq!(
out["enforcement"]["exec"], "kernel",
"exec is reported kernel-confined: {out}"
);
assert_eq!(ran_programs(&mock), ["echo"], "the program spawned: {out}");
} else {
assert_eq!(
out["denied"], true,
"strong principal must fail closed on unenforceable exec: {out}"
);
assert!(ran_programs(&mock).is_empty(), "nothing may spawn: {out}");
}
let mock = Arc::new(MockSpawner::default());
let out = ShellTool::with_spawner(mock.clone())
.invoke(serde_json::json!({"cmd": "echo hi"}), &ctx(granted))
.await
.expect("invoke");
assert_ne!(
out["denied"],
serde_json::json!(true),
"default principal still runs: {out}"
);
}
#[tokio::test]
async fn net_refusal_surfaces_as_a_net_denial_in_the_envelope() {
let mock = Arc::new(MockSpawner::with_net_denials(vec![Denial {
kind: DenialKind::Net,
target: "github.com".to_string(),
reason: "net does not permit 'github.com'".to_string(),
}]));
let out = ShellTool::with_spawner(mock)
.invoke(
serde_json::json!({ "cmd": "echo hi" }),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(
out["denied"],
serde_json::json!(true),
"a net denial sets denied: {out}"
);
assert_eq!(out["denials"][0]["kind"], "net");
assert_eq!(out["denials"][0]["target"], "github.com");
assert!(out.get("exit_code").is_some(), "command still ran: {out}");
}
fn ran_programs(mock: &Arc<MockSpawner>) -> Vec<String> {
calls(mock)
.iter()
.map(|pipeline| prog(&pipeline[0]).to_string())
.collect()
}
#[tokio::test]
async fn env_map_is_passed_to_the_spawner() {
let mock = Arc::new(MockSpawner::default());
let out = ShellTool::with_spawner(mock.clone())
.invoke(
serde_json::json!({
"program": "echo",
"args": ["hi"],
"env": { "FOO": "bar", "VIRTUAL_ENV": "/venv" },
}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
let envs = envs(&mock);
assert_eq!(envs.len(), 1, "one pipeline ran");
assert_eq!(envs[0].get("FOO").map(String::as_str), Some("bar"));
assert_eq!(
envs[0].get("VIRTUAL_ENV").map(String::as_str),
Some("/venv"),
"every env entry reaches the child: {:?}",
envs[0]
);
}
#[tokio::test]
async fn env_does_not_change_the_program_the_leash_checks() {
let mock = Arc::new(MockSpawner::default());
let out = ShellTool::with_spawner(mock.clone())
.invoke(
serde_json::json!({
"cmd": "hostname; uname -s",
"env": { "FOO": "bar" },
}),
&ctx(exec_only(&["hostname", "uname"])),
)
.await
.expect("invoke");
assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
let programs = ran_programs(&mock);
assert_eq!(
programs,
vec!["hostname".to_string(), "uname".to_string()],
"the leash/spawner see the real programs, never `export`/env keys: {programs:?}"
);
for e in envs(&mock) {
assert_eq!(e.get("FOO").map(String::as_str), Some("bar"));
}
}
#[test]
fn parse_env_field_present_and_absent() {
let parsed = ShellArgs::parse(
&serde_json::json!({
"program": "echo",
"env": { "FOO": "bar", "BAZ": "qux" },
}),
&agent_bridle_core::LimitsPolicy::default(),
)
.expect("parse");
assert_eq!(parsed.env.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(parsed.env.get("BAZ").map(String::as_str), Some("qux"));
assert_eq!(parsed.env.len(), 2);
let parsed = ShellArgs::parse(
&serde_json::json!({ "program": "echo" }),
&agent_bridle_core::LimitsPolicy::default(),
)
.expect("parse");
assert!(parsed.env.is_empty(), "absent env defaults to empty");
}
#[test]
fn parse_timeout_uses_configured_limits() {
let limits = agent_bridle_core::LimitsPolicy {
max_timeout_secs: 5,
default_timeout_secs: 3,
..agent_bridle_core::LimitsPolicy::default()
};
let over = ShellArgs::parse(
&serde_json::json!({ "program": "echo", "timeout_secs": 9999 }),
&limits,
)
.expect("parse");
assert_eq!(over.timeout, std::time::Duration::from_secs(5));
let dflt =
ShellArgs::parse(&serde_json::json!({ "program": "echo" }), &limits).expect("parse");
assert_eq!(dflt.timeout, std::time::Duration::from_secs(3));
}
struct FakeEnv(HashMap<String, String>);
impl EnvProvider for FakeEnv {
fn get(&self, name: &str) -> Option<String> {
self.0.get(name).cloned()
}
}
fn fake_env(pairs: &[(&str, &str)]) -> Arc<dyn EnvProvider> {
Arc::new(FakeEnv(
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
))
}
struct MapLister(HashMap<String, Vec<GlobEntry>>);
impl DirLister for MapLister {
fn list(&self, dir: &Path) -> Vec<GlobEntry> {
let key = dir.to_string_lossy().replace('\\', "/");
self.0.get(&key).cloned().unwrap_or_default()
}
}
fn ent(name: &str, is_dir: bool) -> GlobEntry {
GlobEntry {
name: name.to_string(),
is_dir,
}
}
fn map_lister(dirs: &[(&str, Vec<GlobEntry>)]) -> Arc<dyn DirLister> {
Arc::new(MapLister(
dirs.iter()
.map(|(d, es)| ((*d).to_string(), es.clone()))
.collect(),
))
}
#[tokio::test]
async fn redirect_var_is_expanded_and_reaches_spawner_resolved() {
let tmp = std::env::temp_dir().to_string_lossy().into_owned();
let mock = Arc::new(MockSpawner::default());
let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
let out = tool
.invoke(
serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_ne!(
out["denied"],
serde_json::json!(true),
"in-scope var: {out}"
);
let redir = &calls(&mock)[0][0].redirects[0];
assert_eq!(
*redir,
Redirect::Stdout {
path: vec![Seg::Lit(format!("{tmp}/out"))],
append: false,
}
);
}
#[tokio::test]
async fn redirect_var_not_in_allowlist_is_denied() {
let mock = Arc::new(MockSpawner::default());
let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/x")]));
let out = tool
.invoke(
serde_json::json!({"cmd": "echo hi > $SECRET"}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(out["denied"], true, "non-allowlisted redirect var: {out}");
assert!(
ran_programs(&mock).is_empty(),
"no spawn on a denied redirect"
);
assert!(out["denials"][0]["reason"]
.as_str()
.unwrap_or_default()
.contains("SECRET"));
}
#[test]
fn expand_varglob_keeps_value_metachars_literal_and_refuses_basename_var() {
let env = FakeEnv(HashMap::from([("TMPDIR".to_string(), "/a*b".to_string())]));
let allow = agent_bridle_core::LimitsPolicy::default().var_allowlist;
let pattern = expand_varglob(
&[Seg::Var("TMPDIR".into()), Seg::Lit("/*.rs".into())],
&env,
&allow,
)
.unwrap();
assert_eq!(pattern, "/a*b/*.rs");
let err = expand_varglob(
&[Seg::Var("TMPDIR".into()), Seg::Lit("*.rs".into())],
&env,
&allow,
);
assert!(err.is_err(), "var in glob basename must be refused");
}
#[tokio::test]
async fn glob_var_expands_to_resolved_matches_before_spawn() {
let mock = Arc::new(MockSpawner::default());
let lister = map_lister(&[
(".", vec![ent("proj", true)]),
("./proj", vec![ent("a.rs", false), ent("b.rs", false)]),
]);
let tool = ShellTool::with_seams(mock.clone(), fake_env(&[("TMPDIR", "proj")]), lister);
let out = tool
.invoke(
serde_json::json!({"cmd": "ls $TMPDIR/*.rs"}), &ctx(exec_only(&["ls"])),
)
.await
.expect("invoke");
assert_ne!(
out["denied"],
serde_json::json!(true),
"in-scope glob var: {out}"
);
assert_eq!(
calls(&mock)[0][0].argv,
vec![
Arg::Lit("ls".into()),
Arg::Lit("proj/a.rs".into()),
Arg::Lit("proj/b.rs".into())
]
);
}
#[tokio::test]
async fn glob_var_in_basename_is_denied() {
let mock = Arc::new(MockSpawner::default());
let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("PREFIX", "foo")]));
let out = tool
.invoke(
serde_json::json!({"cmd": "ls $PREFIX*.rs"}),
&ctx(exec_only(&["ls"])),
)
.await
.expect("invoke");
assert_eq!(out["denied"], true, "var in glob basename refused: {out}");
assert!(ran_programs(&mock).is_empty());
}
#[tokio::test]
async fn glob_var_not_in_allowlist_is_denied() {
let mock = Arc::new(MockSpawner::default());
let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/s")]));
let out = tool
.invoke(
serde_json::json!({"cmd": "ls $SECRET/*.rs"}),
&ctx(exec_only(&["ls"])),
)
.await
.expect("invoke");
assert_eq!(out["denied"], true, "non-allowlisted glob var: {out}");
assert!(ran_programs(&mock).is_empty());
}
#[tokio::test]
async fn redirect_var_resolved_path_out_of_fs_write_scope_denied() {
let tmp = std::env::temp_dir().to_string_lossy().into_owned();
let mock = Arc::new(MockSpawner::default());
let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
let granted = Caveats {
exec: Scope::only(["echo".to_string()]),
fs_write: Scope::only(["/nonexistent-grant-root".to_string()]),
..Caveats::top()
};
let out = tool
.invoke(
serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
&ctx(granted),
)
.await
.expect("invoke");
assert_eq!(out["denied"], true, "resolved path outside fs_write: {out}");
assert!(ran_programs(&mock).is_empty());
}
#[tokio::test]
async fn and_short_circuits_on_failure() {
let mock = Arc::new(MockSpawner::with_exit("false", 1));
ShellTool::with_spawner(mock.clone())
.invoke(
serde_json::json!({"cmd": "false && echo hi"}),
&ctx(exec_only(&["false", "echo"])),
)
.await
.expect("invoke");
assert_eq!(ran_programs(&mock), vec!["false"], "echo must be skipped");
}
#[tokio::test]
async fn out_of_scope_anywhere_denies_the_whole_script() {
let mock = Arc::new(MockSpawner::default());
let out = ShellTool::with_spawner(mock.clone())
.invoke(
serde_json::json!({"cmd": "echo ok ; rm -rf x"}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
assert_eq!(out["denied"], true);
assert!(ran_programs(&mock).is_empty());
}
#[tokio::test]
async fn glob_arg_expanded_to_matches_before_spawn() {
let mock = Arc::new(MockSpawner::default());
let lister = map_lister(&[(
".",
vec![ent("a.rs", false), ent("b.rs", false), ent("c.txt", false)],
)]);
ShellTool::with_seams(mock.clone(), fake_env(&[]), lister)
.invoke(
serde_json::json!({"cmd": "ls *.rs"}), &ctx(exec_only(&["ls"])),
)
.await
.expect("invoke");
assert_eq!(
calls(&mock)[0][0].argv,
vec![
Arg::Lit("ls".into()),
Arg::Lit("a.rs".into()),
Arg::Lit("b.rs".into())
]
);
}
#[tokio::test]
async fn glob_as_program_name_denied() {
let mock = Arc::new(MockSpawner::default());
let out = ShellTool::with_spawner(mock.clone())
.invoke(serde_json::json!({"cmd": "*.sh foo"}), &ctx(Caveats::top()))
.await
.expect("invoke");
assert_eq!(out["denied"], true);
assert!(ran_programs(&mock).is_empty());
}
#[tokio::test]
async fn glob_dir_out_of_fs_read_scope_denied() {
let mock = Arc::new(MockSpawner::default());
let granted = Caveats {
exec: Scope::only(["echo".to_string()]),
fs_read: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
..Caveats::top()
};
let out = ShellTool::with_spawner(mock.clone())
.invoke(serde_json::json!({"cmd": "echo *"}), &ctx(granted))
.await
.expect("invoke");
assert_eq!(out["denied"], true);
assert_eq!(out["denials"][0]["kind"], "open");
assert!(ran_programs(&mock).is_empty());
}
#[tokio::test]
async fn allowlisted_var_reaches_spawner() {
let mock = Arc::new(MockSpawner::default());
ShellTool::with_spawner(mock.clone())
.invoke(
serde_json::json!({"cmd": "echo $HOME"}),
&ctx(exec_only(&["echo"])),
)
.await
.expect("invoke");
let c = calls(&mock);
assert_eq!(
c[0][0].argv,
vec![
Arg::Lit("echo".into()),
Arg::Var(vec![Seg::Var("HOME".into())]),
]
);
}
#[tokio::test]
async fn non_allowlisted_var_denied() {
let mock = Arc::new(MockSpawner::default());
let out = ShellTool::with_spawner(mock.clone())
.invoke(
serde_json::json!({"cmd": "echo $AWS_SECRET_KEY"}),
&ctx(Caveats::top()),
)
.await
.expect("invoke");
assert_eq!(out["denied"], true);
assert_eq!(out["denials"][0]["target"], "$AWS_SECRET_KEY");
assert!(ran_programs(&mock).is_empty());
}
#[tokio::test]
async fn var_as_program_name_denied() {
let mock = Arc::new(MockSpawner::default());
let out = ShellTool::with_spawner(mock.clone())
.invoke(
serde_json::json!({"cmd": "$HOME foo"}),
&ctx(Caveats::top()),
)
.await
.expect("invoke");
assert_eq!(out["denied"], true);
assert!(ran_programs(&mock).is_empty());
}
#[tokio::test]
async fn stderr_to_file_out_of_scope_denied() {
let mock = Arc::new(MockSpawner::default());
let granted = Caveats {
exec: Scope::only(["cmd".to_string()]),
fs_write: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
..Caveats::top()
};
let out = ShellTool::with_spawner(mock.clone())
.invoke(
serde_json::json!({"cmd": "cmd 2> /etc/passwd"}),
&ctx(granted),
)
.await
.expect("invoke");
assert_eq!(out["denied"], true);
assert_eq!(out["denials"][0]["kind"], "open");
assert_eq!(out["denials"][0]["target"], "/etc/passwd");
assert!(ran_programs(&mock).is_empty());
}
#[tokio::test]
async fn stderr_merge_reaches_spawner() {
let mock = Arc::new(MockSpawner::default());
ShellTool::with_spawner(mock.clone())
.invoke(
serde_json::json!({"cmd": "cmd 2>&1"}),
&ctx(exec_only(&["cmd"])),
)
.await
.expect("invoke");
let c = calls(&mock);
assert_eq!(c[0][0].stderr_disposition(), StderrTo::Stdout);
}
#[tokio::test]
async fn both_program_and_cmd_is_a_hard_error() {
let res = ShellTool::new()
.invoke(
serde_json::json!({"program": "echo", "cmd": "echo hi"}),
&ctx(Caveats::top()),
)
.await;
assert!(res.is_err());
}
#[tokio::test]
async fn timeout_is_reported() {
let mock = Arc::new(MockSpawner {
block_ms: 1500,
..Default::default()
});
let out = ShellTool::with_spawner(mock)
.invoke(
serde_json::json!({"program": "anything", "timeout_secs": 1}),
&ctx(exec_only(&["anything"])),
)
.await
.expect("invoke");
assert_eq!(out["timed_out"], true);
}
#[test]
fn fnmatch_basics() {
assert!(fnmatch("*.rs", "a.rs"));
assert!(!fnmatch("*.rs", "a.txt"));
assert!(fnmatch("a?c", "abc"));
assert!(!fnmatch("a?c", "ac"));
assert!(fnmatch("*", ""));
assert!(fnmatch("a*", "a"));
assert!(fnmatch("[abc]x", "bx"));
assert!(!fnmatch("[abc]x", "dx"));
assert!(fnmatch("[!abc]x", "dx"));
assert!(fnmatch("[a-c]", "b"));
assert!(!fnmatch("[a-c]", "d"));
assert!(fnmatch("foo*bar", "fooXYbar"));
}
#[test]
fn read_capped_bounds_buffering_and_flags_truncation() {
const CAP: usize = 1 << 20;
struct Endless {
served: usize,
}
impl Read for Endless {
fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
self.served = self.served.saturating_add(b.len());
assert!(
self.served <= CAP + 64 * 1024,
"read_capped over-read {} bytes (cap {CAP})",
self.served
);
b.fill(b'x');
Ok(b.len())
}
}
let (buf, truncated) = read_capped(Endless { served: 0 }, CAP);
assert_eq!(buf.len(), CAP, "peak buffering bounded by the cap");
assert!(
truncated,
"a source longer than the cap is flagged truncated"
);
let (buf2, trunc2) = read_capped(&b"hello"[..], CAP);
assert_eq!(buf2, b"hello");
assert!(!trunc2, "a sub-cap source is not truncated");
}
#[test]
fn glob_walk_single_segment_and_subpath() {
let lister = map_lister(&[
(
".",
vec![
ent("a.rs", false),
ent("b.rs", false),
ent("c.txt", false),
ent(".hidden.rs", false),
ent("src", true),
],
),
("./src", vec![ent("a.rs", false), ent("b.rs", false)]),
]);
let mut allow = |_d: &Path| Ok(());
assert_eq!(
expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
vec!["a.rs", "b.rs"]
);
assert_eq!(
expand_glob_walk("zzz*", None, &*lister, &mut allow, 64, 4096).unwrap(),
vec!["zzz*"]
);
assert_eq!(
expand_glob_walk("src/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
vec!["src/a.rs", "src/b.rs"]
);
}
#[test]
fn glob_walk_multi_segment_and_recursive() {
let lister = map_lister(&[
(
".",
vec![ent("a", true), ent("b", true), ent("x.rs", false)],
),
("./a", vec![ent("foo.rs", false), ent("sub", true)]),
("./b", vec![ent("bar.rs", false)]),
("./a/sub", vec![ent("deep.rs", false)]),
]);
let mut allow = |_d: &Path| Ok(());
assert_eq!(
expand_glob_walk("*/foo.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
vec!["a/foo.rs"]
);
assert_eq!(
expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
vec!["a/foo.rs", "a/sub/deep.rs", "b/bar.rs", "x.rs"]
);
}
#[test]
fn glob_walk_leashes_every_directory_and_denies_out_of_scope() {
let lister = map_lister(&[
(".", vec![ent("a", true), ent("x.rs", false)]),
("./a", vec![ent("secret.rs", false)]),
]);
let mut deny_a = |d: &Path| {
if d.to_string_lossy().contains("a") {
Err(ToolError::denied("out of fs_read scope"))
} else {
Ok(())
}
};
assert!(expand_glob_walk("**/*.rs", None, &*lister, &mut deny_a, 64, 4096).is_err());
}
#[test]
fn glob_walk_respects_configured_match_cap() {
let lister = map_lister(&[(
".",
vec![
ent("a.rs", false),
ent("b.rs", false),
ent("c.rs", false),
ent("d.rs", false),
],
)]);
let mut allow = |_d: &Path| Ok(());
let got = expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 2).unwrap();
assert_eq!(got.len(), 2, "match cap of 2 must bound the result set");
}
#[test]
fn glob_walk_respects_configured_depth_cap() {
let lister = map_lister(&[
(".", vec![ent("a", true), ent("x.rs", false)]),
("./a", vec![ent("foo.rs", false), ent("sub", true)]),
("./a/sub", vec![ent("deep.rs", false)]),
]);
let mut allow = |_d: &Path| Ok(());
let got = expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 1, 4096).unwrap();
assert!(
!got.iter().any(|m| m.contains("deep.rs")),
"depth cap of 1 must not reach a/sub/deep.rs; got {got:?}"
);
}
#[test]
fn var_allowlist_is_config_driven() {
let allow_custom = vec!["MY_CUSTOM_VAR".to_string()];
let env = FakeEnv(HashMap::from([(
"MY_CUSTOM_VAR".to_string(),
"/data".to_string(),
)]));
let out = expand_redirect_target(&[Seg::Var("MY_CUSTOM_VAR".into())], &env, &allow_custom)
.unwrap();
assert_eq!(out, "/data");
assert!(!is_allowed_var("HOME", &["PWD".to_string()]));
assert!(is_allowed_var("PWD", &["PWD".to_string()]));
}
#[test]
fn net_audit_sink_is_config_driven() {
use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
let ev = NetAuditEvent {
ts_ms: 0,
host: "example.test".to_string(),
port: 443,
kind: NetKind::Connect,
decision: NetDecision::Allowed,
bytes_up: 1,
bytes_down: 2,
dur_ms: 3,
};
net_audit_sink(None).record(&ev);
let path = std::env::temp_dir().join(format!("ab-audit-{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&path);
let sink = net_audit_sink(path.to_str());
sink.record(&ev);
drop(sink);
let contents = std::fs::read_to_string(&path).expect("configured audit file written");
assert!(
contents.contains("example.test"),
"the configured sink must write the event: {contents}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn net_audit_sink_bad_path_degrades_to_null() {
use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
let ev = NetAuditEvent {
ts_ms: 0,
host: "example.test".to_string(),
port: 443,
kind: NetKind::Http,
decision: NetDecision::Allowed,
bytes_up: 1,
bytes_down: 2,
dur_ms: 3,
};
let bad = std::env::temp_dir()
.join(format!("ab-nope-{}", std::process::id()))
.join("does/not/exist/audit.jsonl");
let sink = net_audit_sink(bad.to_str());
sink.record(&ev); assert!(
!bad.exists(),
"a bad audit path must not create a file (degraded to null): {bad:?}"
);
}
}