agent_bridle_tool_shell/shell_tool.rs
1//! [`ShellTool`] — the confined shell, **argv + safe-subset engine** (ADR 0005).
2//!
3//! Per ADR 0005, the object-capability *boundary* is L3 (kernel) and this engine
4//! is the L2 *convenience*: `agent-bridle` is the exec funnel — it parses the
5//! request itself (see [`crate::parse`]), checks the `exec`/`fs` leash, spawns
6//! the program(s) directly, and **refuses the dynamic constructs by design**.
7//! When an L3 backstop will actually confine the run — the Landlock `fs_write`
8//! (and restricted `fs_read`) axes on a capable Linux build (`linux-landlock`),
9//! or the macOS Seatbelt equivalent (`macos-seatbelt`), with a filesystem axis
10//! restricted — the children spawn inside a kernel-enforced boundary (a Landlock
11//! ruleset applied on a dedicated thread, or a `sandbox-exec` profile wrapping
12//! each stage), and `sandbox_kind` reports [`SandboxKind::Landlock`] /
13//! [`SandboxKind::Seatbelt`]; this blocks a *permitted* program's own
14//! out-of-scope reads/writes, which L2 cannot see once it has spawned. Otherwise
15//! the run is honestly *advisory* and `sandbox_kind` is [`SandboxKind::None`] —
16//! never overclaiming (I9). The `exec`/`net` axes (#31/#57) and the Windows
17//! backend (#51) are follow-ups; see ADR 0006/0009 for the per-OS backend design.
18//!
19//! The engine (agent-bridle#34 Track A + #45): a sequence of pipelines joined by
20//! `&&`/`||`/`;`, each pipeline simple commands with quoted arguments,
21//! redirections (`> out`, `>> out`, `< in`, `2> err`, `2>&1`), filename globbing
22//! (`*`/`?`/`[…]`), and **allowlisted `$VAR` expansion**. Because `agent-bridle`
23//! performs each redirect's open and each glob's directory listing itself, those
24//! filesystem touches are leash-checked (`fs_write`/`fs_read`) *before any stage
25//! spawns*; a `$VAR` is expanded only if its name is on a small secret-free
26//! allowlist (the configured `var_allowlist`), checked before any spawn — a real enforcement
27//! point, unlike a spawned program's own opens (L3's job). `2>&1` uses a shared
28//! `std::io::pipe()` writer cloned into both stdout and stderr. Process spawning
29//! is behind a [`Spawner`] seam (mocked in unit tests; real path in
30//! `tests/real_spawn.rs`).
31
32use std::collections::BTreeMap;
33use std::io::{PipeReader, PipeWriter, Read};
34#[cfg(windows)]
35use std::io::{Seek, SeekFrom};
36use std::path::{Path, PathBuf};
37use std::process::{Child, Stdio};
38use std::sync::{Arc, LazyLock};
39use std::time::Duration;
40
41use agent_bridle_core::{
42 best_available_sandbox, confinement_unenforceable, effective_sandbox_kind, enforcement_report,
43 human_gate, is_unbridled, loopback_fenced_caveats, net_egress_proxy_hosts, Caveats, Denial,
44 DenialKind, Disclosure, EnforcementReport, LimitsPolicy, SandboxKind, SandboxPolicy, Tool,
45 ToolContext, ToolEnvelope, ToolError, ToolResult,
46};
47use async_trait::async_trait;
48
49use crate::net_proxy;
50use crate::parse::{
51 classify, seg_literal, Arg, Command, Redirect, Refusal, Script, ScriptItem, Seg, Sep, StderrTo,
52};
53
54/// What a finished pipeline produced (the last stage's exit code; concatenated
55/// output). The unit of the [`Spawner`] seam. The captured output is bounded by
56/// the configured cap ([`LimitsPolicy::max_output_bytes`]) so a chatty command
57/// cannot return unbounded output. Streaming caps are a follow-up; the timeout
58/// bounds runaway producers in the meantime.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub(crate) struct Captured {
61 pub exit_code: i32,
62 pub stdout: String,
63 pub stderr: String,
64 /// Whether stdout was clipped at the configured output cap (more was produced).
65 pub stdout_truncated: bool,
66 /// Whether stderr was clipped at the configured output cap (more was produced).
67 pub stderr_truncated: bool,
68 /// #196: structured `net` denials observed DURING the run — one per
69 /// out-of-allow-list host the egress proxy refused. Unlike `exec`/`open`
70 /// denials (decided at pre-spawn admission), a net refusal is only known
71 /// after the child has run, so it rides back on the capture and is attached
72 /// to the result envelope by the caller. Empty on the common path.
73 pub net_denials: Vec<Denial>,
74}
75
76/// The pipeline-execution seam.
77///
78/// The real implementation ([`OsSpawner`]) spawns processes (and expands globs
79/// against the real filesystem); tests inject a mock so the parse + leash +
80/// sequencing logic is verified without real subprocesses (the workspace norm:
81/// no real process/fs in unit tests). A `Spawner` only ever receives a pipeline
82/// that already passed the `exec` **and** `fs` (redirect + glob-dir) leash —
83/// admission happens in [`ShellTool::invoke`] *before* the spawner runs.
84/// Per-invocation spawn *mechanism* config, threaded from `ShellTool`'s fields to
85/// the spawner. It rides an explicit parameter, **never** `ToolContext` (which
86/// carries only authority — authority≠mechanism, ADR 0017 D2). Bundles the tuning
87/// knobs so the `Spawner` seam takes one config, not a growing list of scalars.
88pub(crate) struct SpawnCfg {
89 /// Captured stdout/stderr cap ([`LimitsPolicy::max_output_bytes`]).
90 pub max_output: usize,
91 /// Egress audit sink path ([`LimitsPolicy::audit_sink`]; `None` = off).
92 pub audit_sink: Option<String>,
93 /// Sandbox read/exec allow-lists + ABI floors ([`SandboxPolicy`]).
94 pub sandbox: Arc<SandboxPolicy>,
95 /// Process is **unbridled** (ADR 0018): drop the L3 OS sandbox and run the
96 /// pipeline natively. The L2 grant checks in `invoke` still gate (advisory);
97 /// only the kernel mechanism is skipped. Read once from the process marker.
98 pub unbridled: bool,
99}
100
101pub(crate) trait Spawner: Send + Sync {
102 /// Run one leash-approved pipeline to completion, capturing its output. The
103 /// effective `caveats` are passed so the real spawner can apply the L3 OS
104 /// sandbox (Landlock) before spawning; the mock ignores them. `env` is the
105 /// host/operator-supplied environment (the env seam, newt #783): the real
106 /// spawner sets these vars on each spawned child (additive over the inherited
107 /// ambient env). `env` is structured host input, never model-authored command
108 /// text, so it grants no new authority — the exec/fs leash is unaffected.
109 /// `cfg` carries the mechanism tuning (output cap, audit sink, sandbox policy).
110 fn run(
111 &self,
112 stages: &[Command],
113 cwd: Option<&str>,
114 caveats: &Caveats,
115 env: &BTreeMap<String, String>,
116 cfg: &SpawnCfg,
117 ) -> ToolResult<Captured>;
118}
119
120/// The real spawner: a `std::process` pipeline wired with OS pipes + redirects,
121/// expanding globs against the real filesystem, optionally inside an L3 sandbox.
122struct OsSpawner;
123
124impl Spawner for OsSpawner {
125 fn run(
126 &self,
127 stages: &[Command],
128 cwd: Option<&str>,
129 caveats: &Caveats,
130 env: &BTreeMap<String, String>,
131 cfg: &SpawnCfg,
132 ) -> ToolResult<Captured> {
133 // Unbridled (ADR 0018): the operator explicitly dropped the L3 mechanism —
134 // run natively, no OS sandbox and no egress proxy. The L2 grant checks in
135 // `invoke` already gated this run (advisory); confinement is off by consent.
136 if cfg.unbridled {
137 return run_pipeline(stages, cwd, &[], env, cfg.max_output);
138 }
139 // A general remote-host `net` allow-list that cannot be named in SBPL is
140 // enforced by the loopback egress proxy (#124, ADR 0016): fence the child
141 // to loopback and route it through the proxy. Self-gating — `Some` only
142 // where the fence is actually emittable (macOS + seatbelt).
143 if let Some((allow_hosts, fenced)) = egress_proxy_plan(caveats, &cfg.sandbox) {
144 return run_with_egress_proxy(stages, cwd, &fenced, env, allow_hosts, cfg);
145 }
146 // When an OS sandbox (Landlock/Seatbelt) will actually confine this run,
147 // confine it on a dedicated thread before spawning (ADR 0005 L3 / ADR
148 // 0006 D4). Otherwise run directly — no need to spend a thread.
149 if intended_sandbox_kind(caveats, &cfg.sandbox) == SandboxKind::None {
150 run_pipeline(stages, cwd, &[], env, cfg.max_output)
151 } else {
152 run_confined(stages, cwd, caveats, env, cfg)
153 }
154 }
155}
156
157/// The egress-proxy plan for `caveats`, or `None` to fall through to the ordinary
158/// confinement paths (#124, ADR 0016). `Some((allow_hosts, fenced))` **iff** the
159/// grant is a general remote-host `net` allow-list ([`net_egress_proxy_hosts`])
160/// *and* the loopback fence it needs is actually emittable on this host — i.e.
161/// [`loopback_fenced_caveats`] engages a real backend
162/// ([`intended_sandbox_kind`] ≠ `None`; today only macOS Seatbelt). This one
163/// helper feeds BOTH the spawn routing ([`OsSpawner::run`]) and the reported
164/// `sandbox_kind` ([`ShellTool::invoke`]), so check and routing cannot disagree.
165fn egress_proxy_plan(
166 caveats: &Caveats,
167 sandbox: &Arc<SandboxPolicy>,
168) -> Option<(Vec<String>, Caveats)> {
169 let allow_hosts = net_egress_proxy_hosts(caveats)?;
170 let fenced = loopback_fenced_caveats(caveats);
171 if intended_sandbox_kind(&fenced, sandbox) == SandboxKind::None {
172 return None; // no loopback fence available → not enforceable; fall through
173 }
174 Some((allow_hosts, fenced))
175}
176
177/// Run the pipeline under the loopback egress proxy (#124, ADR 0016). Mirrors
178/// [`run_confined`] but, before spawning: (1) starts a loopback forward proxy
179/// bound to the `allow_hosts` — **fail-closed** if it cannot bind; (2) computes
180/// the fence prefix from the loopback-`fenced` caveats — fail-closed if the
181/// wrapper is missing; (3) injects `*_PROXY` into a clone of the env-seam map so
182/// the child routes its HTTP/HTTPS out through the proxy. The [`ProxyHandle`] is
183/// held until the confined child has been reaped, then dropped (tearing the
184/// listener down) — so the proxy's lifetime brackets the child's.
185fn run_with_egress_proxy(
186 stages: &[Command],
187 cwd: Option<&str>,
188 fenced: &Caveats,
189 env: &BTreeMap<String, String>,
190 allow_hosts: Vec<String>,
191 cfg: &SpawnCfg,
192) -> ToolResult<Captured> {
193 // (1) Fence prefix first (pure, cheap) — fail-closed if the wrapper is gone.
194 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(fenced)?;
195 // (2) Start the proxy — fail-closed if it cannot bind loopback (never spawn
196 // an unfenced child that would then egress freely). Audit is opt-in via the
197 // configured audit sink (observability only; off = zero overhead).
198 let proxy = net_proxy::start(
199 allow_hosts,
200 Arc::new(net_proxy::StdResolver),
201 net_audit_sink(cfg.audit_sink.as_deref()),
202 )
203 .map_err(ToolError::Exec)?;
204 // (3) Point the child at the proxy via the env seam (a clone — never mutate
205 // the caller's map).
206 let mut env = env.clone();
207 for (k, v) in proxy.proxy_env() {
208 env.insert(k, v);
209 }
210
211 let stages = stages.to_vec();
212 let cwd = cwd.map(str::to_string);
213 let fenced = fenced.clone();
214 let max_output = cfg.max_output;
215 let sandbox = cfg.sandbox.clone();
216 let captured = std::thread::Builder::new()
217 .name("agent-bridle-confined".to_string())
218 .spawn(move || {
219 best_available_sandbox(&sandbox).apply(&fenced)?;
220 run_pipeline(&stages, cwd.as_deref(), &prefix, &env, max_output)
221 })
222 .map_err(ToolError::Exec)?
223 .join()
224 .map_err(|_| {
225 ToolError::Exec(std::io::Error::other("confined execution thread panicked"))
226 })?;
227 // #196: the child is reaped, so every proxy connection is complete — read the
228 // hosts the proxy refused (out of the allow-list) BEFORE tearing it down, and
229 // surface each as a structured `net` denial on the capture.
230 let refused = proxy.refused_hosts();
231 drop(proxy); // hold the proxy until the child is reaped, then tear it down
232 let mut captured = captured?;
233 captured.net_denials = refused
234 .into_iter()
235 .map(|host| Denial {
236 kind: DenialKind::Net,
237 reason: format!("net does not permit '{host}'"),
238 target: host,
239 })
240 .collect();
241 Ok(captured)
242}
243
244/// Build the egress audit sink from the configured audit path (#124, ADR 0016;
245/// `LimitsPolicy::audit_sink`, which the config loader maps from the legacy
246/// `BRIDLE_NET_AUDIT` setting — I6, #145). `None`/empty → **no audit** (the
247/// default; zero overhead). A path → append each proxied connection as one JSON
248/// line (host, port, decision, bytes, duration) for `bridle-netmon` to render
249/// live. Audit is **observability only** — it never changes an enforcement
250/// decision — so a path that cannot be opened falls back to the null sink rather
251/// than failing the run.
252fn net_audit_sink(configured: Option<&str>) -> Arc<dyn net_proxy::AuditSink> {
253 match configured {
254 Some(path) if !path.is_empty() => std::fs::OpenOptions::new()
255 .create(true)
256 .append(true)
257 .open(path)
258 .map(|f| Arc::new(net_proxy::JsonlSink::new(f)) as Arc<dyn net_proxy::AuditSink>)
259 .unwrap_or_else(|_| Arc::new(net_proxy::NullSink)),
260 _ => Arc::new(net_proxy::NullSink),
261 }
262}
263
264/// The L3 `SandboxKind` that will actually be enforced for these caveats in this
265/// build, on this host — the value reported in the result envelope (I9 / ADR
266/// 0006 D3). [`effective_sandbox_kind`] is the shared honesty rule: the strongest
267/// available backend's kind when a filesystem axis is restricted (so it confines
268/// something), else `None` — the fs-only ruleset governs nothing, so never
269/// overclaim. The same rule backs the subprocess primitive in core.
270fn intended_sandbox_kind(caveats: &Caveats, sandbox: &Arc<SandboxPolicy>) -> SandboxKind {
271 effective_sandbox_kind(best_available_sandbox(sandbox).kind(), caveats)
272}
273
274/// Run the pipeline on a dedicated thread that first applies the OS sandbox.
275///
276/// Two confinement mechanisms, honored uniformly (ADR 0006): a thread-confining
277/// backend (Landlock) restricts this very thread in `apply` — per-thread,
278/// irreversible, inherited across `fork`/`execve`, so it must run on a throwaway
279/// thread (never the shared blocking pool) immediately before spawning the
280/// children. A wrapper backend (macOS Seatbelt) returns a `sandbox-exec` argv
281/// prefix from `command_prefix`, prepended to every stage so the child is
282/// spawned already confined. Both are fail-closed (ADR 0006 D4): if confinement
283/// cannot be established the run errors rather than proceeding unconfined.
284fn run_confined(
285 stages: &[Command],
286 cwd: Option<&str>,
287 caveats: &Caveats,
288 env: &BTreeMap<String, String>,
289 cfg: &SpawnCfg,
290) -> ToolResult<Captured> {
291 // Computed before the spawn so a fail-closed wrapper error aborts the run.
292 let prefix = best_available_sandbox(&cfg.sandbox).command_prefix(caveats)?;
293 let stages = stages.to_vec();
294 let cwd = cwd.map(str::to_string);
295 let caveats = caveats.clone();
296 let env = env.clone();
297 let max_output = cfg.max_output;
298 let sandbox = cfg.sandbox.clone();
299 std::thread::Builder::new()
300 .name("agent-bridle-confined".to_string())
301 .spawn(move || {
302 best_available_sandbox(&sandbox).apply(&caveats)?;
303 run_pipeline(&stages, cwd.as_deref(), &prefix, &env, max_output)
304 })
305 .map_err(ToolError::Exec)?
306 .join()
307 .map_err(|_| ToolError::Exec(std::io::Error::other("confined execution thread panicked")))?
308}
309
310/// The tool's input schema, parsed once from the embedded `shell_tool.schema.json`
311/// data file — the schema is *knowledge*, so it lives in plain-text data, not an
312/// inline `json!` literal (three-Cs: knowledge in data, not logic). `include_str!`
313/// binds it at compile time, so a malformed edit fails the build's tests, never a
314/// live dispatch. The per-instance `timeout_secs` ceiling is injected by
315/// [`Tool::schema`] over this base.
316static SHELL_SCHEMA: LazyLock<serde_json::Value> = LazyLock::new(|| {
317 serde_json::from_str(include_str!("shell_tool.schema.json"))
318 .expect("embedded shell_tool.schema.json must be valid JSON")
319});
320
321/// The confined shell tool.
322///
323/// Registers under `"shell"`. Accepts either argv form (`program` + `args`) or a
324/// free-form `cmd` string parsed by the safe-subset engine. Leash refusals
325/// (out-of-scope `exec`/`fs`, a refused construct) are returned as a **structured
326/// denied envelope** (`denied: true`), not a hard error.
327#[derive(Clone)]
328pub struct ShellTool {
329 spawner: Arc<dyn Spawner>,
330 env: Arc<dyn EnvProvider>,
331 lister: Arc<dyn DirLister>,
332 limits: LimitsPolicy,
333 /// Sandbox mechanism policy (read/exec allow-lists, ABI floors) the L3 backend
334 /// enforces (I5-B, #144). Rides the tool, not the `ToolContext`.
335 sandbox: Arc<SandboxPolicy>,
336}
337
338impl std::fmt::Debug for ShellTool {
339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 f.write_str("ShellTool")
341 }
342}
343
344impl ShellTool {
345 /// Construct the tool with the real OS spawner, environment, and dir lister,
346 /// and the default [`LimitsPolicy`].
347 #[must_use]
348 pub fn new() -> Self {
349 Self::with_config(LimitsPolicy::default())
350 }
351
352 /// Construct with the real seams and a caller-supplied [`LimitsPolicy`] — the
353 /// configurability seam (agent-bridle#143): tune timeouts / output / glob caps.
354 #[must_use]
355 pub fn with_config(limits: LimitsPolicy) -> Self {
356 Self {
357 spawner: Arc::new(OsSpawner),
358 env: Arc::new(RealEnv),
359 lister: Arc::new(RealDirLister),
360 limits,
361 sandbox: Arc::new(SandboxPolicy::default()),
362 }
363 }
364
365 /// Set the sandbox mechanism policy (read/exec allow-lists, ABI floors) the L3
366 /// backend enforces (I5-B, #144). The default is today's built-in allow-lists.
367 #[must_use]
368 pub fn with_sandbox_policy(mut self, sandbox: SandboxPolicy) -> Self {
369 self.sandbox = Arc::new(sandbox);
370 self
371 }
372
373 /// Construct with an injected spawner; real environment + dir lister (tests).
374 #[cfg(test)]
375 fn with_spawner(spawner: Arc<dyn Spawner>) -> Self {
376 Self {
377 spawner,
378 env: Arc::new(RealEnv),
379 lister: Arc::new(RealDirLister),
380 limits: LimitsPolicy::default(),
381 sandbox: Arc::new(SandboxPolicy::default()),
382 }
383 }
384
385 /// Construct with an injected spawner **and** a fake environment (tests only),
386 /// so the `$VAR` allowlist + expansion + the resolved-path leash are
387 /// exercised without touching the real process environment.
388 #[cfg(test)]
389 fn with_spawner_and_env(spawner: Arc<dyn Spawner>, env: Arc<dyn EnvProvider>) -> Self {
390 Self {
391 spawner,
392 env,
393 lister: Arc::new(RealDirLister),
394 limits: LimitsPolicy::default(),
395 sandbox: Arc::new(SandboxPolicy::default()),
396 }
397 }
398
399 /// Construct with all three seams injected (tests only): a fake spawner, env,
400 /// and directory lister, so glob expansion + the per-directory `fs_read`
401 /// leash are exercised without a real filesystem (#47).
402 #[cfg(test)]
403 fn with_seams(
404 spawner: Arc<dyn Spawner>,
405 env: Arc<dyn EnvProvider>,
406 lister: Arc<dyn DirLister>,
407 ) -> Self {
408 Self {
409 spawner,
410 env,
411 lister,
412 limits: LimitsPolicy::default(),
413 sandbox: Arc::new(SandboxPolicy::default()),
414 }
415 }
416}
417
418impl Default for ShellTool {
419 fn default() -> Self {
420 Self::new()
421 }
422}
423
424#[async_trait]
425impl Tool for ShellTool {
426 fn name(&self) -> &str {
427 "shell"
428 }
429
430 fn schema(&self) -> serde_json::Value {
431 // Structure + descriptions live in the `shell_tool.schema.json` data
432 // file (knowledge in data, not an inline literal). The `timeout_secs`
433 // ceiling is a per-instance property — the configured `LimitsPolicy`
434 // (`with_config`) — so it is injected over the data-file base here rather
435 // than baked into the file, keeping the bound's source of truth in Rust.
436 let mut schema = SHELL_SCHEMA.clone();
437 schema["properties"]["timeout_secs"]["maximum"] =
438 serde_json::Value::from(self.limits.max_timeout_secs);
439 schema
440 }
441
442 async fn invoke(
443 &self,
444 args: serde_json::Value,
445 cx: &ToolContext,
446 ) -> ToolResult<serde_json::Value> {
447 let parsed = ShellArgs::parse(&args, &self.limits)?;
448 // Unbridled (ADR 0018): the operator dropped the L3 mechanism. Report
449 // `None` (no OS sandbox) — the per-axis report then honestly shows each
450 // restricted axis at `advisory` (the L2 interceptor, which still gates the
451 // configured grant below), never `kernel`. Authority is unchanged; only the
452 // mechanism is off. Every envelope discloses `unbridled` (D5).
453 let unbridled = is_unbridled();
454 // Honest reporting (ADR 0005 D1 / I9 / ADR 0006 D3): report the L3 kind
455 // that will actually be enforced for these caveats on this kernel —
456 // Landlock when `fs_write` is restricted on a capable Linux build, else
457 // None (advisory). `OsSpawner` applies exactly this, fail-closed.
458 //
459 // On the egress-proxy path (#124, ADR 0016) the run is governed by the
460 // loopback-`fenced` caveats — a real Seatbelt kernel boundary — so the
461 // coarse kind is reported from those, derived from the SAME
462 // `egress_proxy_plan` helper `OsSpawner::run` routes on (they cannot
463 // disagree). The per-axis `net` stays Advisory below (the report is
464 // computed from the ORIGINAL grant, whose remote host SBPL cannot confine)
465 // — the proxy over-delivers above that floor, it does not raise the claim.
466 let sandbox_kind = if unbridled {
467 SandboxKind::None
468 } else {
469 match egress_proxy_plan(cx.caveats(), &self.sandbox) {
470 Some((_, fenced)) => intended_sandbox_kind(&fenced, &self.sandbox),
471 None => intended_sandbox_kind(cx.caveats(), &self.sandbox),
472 }
473 };
474 // Axis-granular honesty (ADR 0004 D1 / #30): every envelope this run
475 // returns carries the per-axis report alongside the coarse sandbox_kind.
476 let enforcement = enforcement_report(cx.caveats(), sandbox_kind);
477
478 // Resolve to a script (sequence of pipelines), or surface a refusal.
479 let mut script = match parsed.script() {
480 Ok(s) => s,
481 Err(refusal) => return Ok(refused_envelope(sandbox_kind, enforcement, &refusal)),
482 };
483
484 // Lower `$VAR` (#46) through the env seam so the RESOLVED value is what
485 // the fs leash checks below and the spawner opens — never a literal
486 // `$VAR`. Glob+variable words (`$DIR/*.rs`) lower to a resolved glob (with
487 // the re-injection guard); redirect targets (`> $TMPDIR/out`) to a literal
488 // path. A non-allowlisted (or basename-injected) variable denies pre-spawn.
489 for item in &mut script {
490 for stage in &mut item.pipeline {
491 // Expand globs (and glob+var words) to literal matches, leash-
492 // checking EVERY directory the walk lists (#47) — multi-segment
493 // (`*/foo.rs`) and recursive (`**/*.rs`), all before any spawn.
494 // argv[0] is left intact; the program-position check below refuses
495 // a glob/var program (we never exec a pattern).
496 let mut new_argv: Vec<Arg> = Vec::with_capacity(stage.argv.len());
497 for (i, arg) in stage.argv.drain(..).enumerate() {
498 let pattern: Option<String> = if i == 0 {
499 None
500 } else {
501 match &arg {
502 Arg::Glob(p) => Some(p.clone()),
503 Arg::VarGlob(segs) => {
504 match expand_varglob(segs, &*self.env, &self.limits.var_allowlist) {
505 Ok(p) => Some(p),
506 Err((target, e)) => {
507 return Ok(deny(
508 sandbox_kind,
509 enforcement,
510 DenialKind::Exec,
511 &target,
512 &e,
513 ))
514 }
515 }
516 }
517 _ => None,
518 }
519 };
520 match pattern {
521 Some(p) => {
522 let mut leash = |dir: &Path| cx.check_path_read(dir);
523 match expand_glob_walk(
524 &p,
525 parsed.cwd.as_deref(),
526 &*self.lister,
527 &mut leash,
528 self.limits.max_glob_depth,
529 self.limits.max_glob_matches,
530 ) {
531 Ok(ms) => new_argv.extend(ms.into_iter().map(Arg::Lit)),
532 Err(e) => {
533 return Ok(deny(
534 sandbox_kind,
535 enforcement,
536 DenialKind::Open,
537 &p,
538 &e,
539 ))
540 }
541 }
542 }
543 None => new_argv.push(arg),
544 }
545 }
546 stage.argv = new_argv;
547 for redirect in &mut stage.redirects {
548 let segs = match redirect {
549 Redirect::Stdout { path, .. }
550 | Redirect::Stderr { path, .. }
551 | Redirect::Stdin { path } => path,
552 Redirect::StderrToStdout => continue,
553 };
554 match expand_redirect_target(segs, &*self.env, &self.limits.var_allowlist) {
555 Ok(resolved) => *segs = vec![Seg::Lit(resolved)],
556 Err((target, e)) => {
557 return Ok(deny(
558 sandbox_kind,
559 enforcement,
560 DenialKind::Open,
561 &target,
562 &e,
563 ))
564 }
565 }
566 }
567 }
568 }
569
570 // Atomic admission (ADR 0001): across the WHOLE script, every program
571 // (`exec`), every redirect target (`fs_write`/`fs_read`), and every glob's
572 // listed directory (`fs_read`) — all filesystem touches bridle performs —
573 // must pass *before any stage spawns*. One out-of-scope element denies the
574 // whole script with no partial side effects.
575 for item in &script {
576 for stage in &item.pipeline {
577 match stage.argv.first() {
578 Some(Arg::Lit(program)) => {
579 if let Err(e) = cx.check_exec(program) {
580 return Ok(deny(
581 sandbox_kind,
582 enforcement,
583 DenialKind::Exec,
584 program,
585 &e,
586 ));
587 }
588 }
589 Some(Arg::Glob(pattern)) => {
590 return Ok(deny(
591 sandbox_kind,
592 enforcement,
593 DenialKind::Exec,
594 pattern,
595 &ToolError::denied("a glob pattern is not allowed as a program name"),
596 ));
597 }
598 Some(Arg::Var(_segs)) => {
599 return Ok(deny(
600 sandbox_kind,
601 enforcement,
602 DenialKind::Exec,
603 "$VAR",
604 &ToolError::denied("a variable is not allowed as a program name"),
605 ));
606 }
607 // A glob+var word lowers to `Arg::Glob` above; this arm is for
608 // exhaustiveness and mirrors the glob-program refusal.
609 Some(Arg::VarGlob(_)) => {
610 return Ok(deny(
611 sandbox_kind,
612 enforcement,
613 DenialKind::Exec,
614 "$VAR/glob",
615 &ToolError::denied("a glob pattern is not allowed as a program name"),
616 ));
617 }
618 None => {} // the parser guarantees a non-empty stage
619 }
620 for arg in &stage.argv {
621 match arg {
622 // Every variable referenced must be on the env allowlist
623 // (no secret leak), checked by name before any spawn.
624 Arg::Var(segs) => {
625 for seg in segs {
626 if let Seg::Var(name) = seg {
627 if !is_allowed_var(name, &self.limits.var_allowlist) {
628 return Ok(deny(
629 sandbox_kind,
630 enforcement,
631 DenialKind::Exec,
632 &format!("${name}"),
633 &ToolError::denied(format!(
634 "variable ${name} is not in the confined shell's allowlist"
635 )),
636 ));
637 }
638 }
639 }
640 }
641 // Globs / glob+var words were expanded to literals (with
642 // the per-directory fs_read leash) in the pass above.
643 Arg::Glob(_) => unreachable!("glob expanded at admission"),
644 Arg::VarGlob(_) => unreachable!("VarGlob expanded at admission"),
645 Arg::Lit(_) => {}
646 }
647 }
648 for redirect in &stage.redirects {
649 // Redirect targets were lowered above, so each path is a
650 // single resolved literal — leash-check that resolved path.
651 let (path, checked) = match redirect {
652 Redirect::Stdout { path, .. } | Redirect::Stderr { path, .. } => {
653 let p = seg_literal(path).expect("redirect target lowered");
654 (p, cx.check_path_write(Path::new(p)))
655 }
656 Redirect::Stdin { path } => {
657 let p = seg_literal(path).expect("redirect target lowered");
658 (p, cx.check_path_read(Path::new(p)))
659 }
660 // `2>&1` opens no file — nothing to leash-check.
661 Redirect::StderrToStdout => continue,
662 };
663 if let Err(e) = checked {
664 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, path, &e));
665 }
666 }
667 }
668 }
669 // Leash: a provided cwd must be within fs_read scope.
670 if let Some(cwd) = &parsed.cwd {
671 if let Err(e) = cx.check_path_read(Path::new(cwd)) {
672 return Ok(deny(sandbox_kind, enforcement, DenialKind::Open, cwd, &e));
673 }
674 }
675
676 // Fail closed (ADR 0012 D4) — AFTER L2 admission (so a specific
677 // out-of-scope glob/redirect/exec denial is reported first) but before any
678 // spawn: refuse when a restricted axis cannot be enforced on this host at
679 // the principal's strength floor. Decided against `sandbox_kind` — the kind
680 // that ACTUALLY governs the spawn (`effective_sandbox_kind`, what
681 // `OsSpawner` routes through), NOT the raw probe: a backend the run path
682 // does not route through collapses to `None` here, so an fs-restricted
683 // run on it fails closed instead of executing unconfined via
684 // `run_pipeline` (the adversarial-review fix — the check and the routing
685 // must agree). The filesystem axes always
686 // fail closed when restricted-but-unenforceable (closing the run-unconfined
687 // gap the shell shared with ConfinedCommand); exec/net fail closed only for
688 // a strong principal (the default floor is permissive).
689 // Unbridled skips this fail-closed guard by consent: dropping the L3
690 // mechanism is *exactly* what the operator acknowledged (ADR 0018 D1). The
691 // L2 grant checks above still ran (advisory), and every axis reports
692 // advisory + `disclosure.unbridled` — honest, not silent.
693 if !unbridled && confinement_unenforceable(sandbox_kind, cx.caveats(), cx.strength_floor())
694 {
695 return Ok(deny(
696 sandbox_kind,
697 enforcement,
698 DenialKind::Exec,
699 "confinement",
700 &ToolError::denied(format!(
701 "a restricted filesystem/exec/net axis cannot be enforced on this host \
702 at the required strength floor ({:?}); refusing to run unconfined",
703 cx.strength_floor()
704 )),
705 ));
706 }
707
708 // Run on a blocking thread, bounded by the timeout. On timeout the
709 // blocking task is detached and a timeout envelope is returned.
710 let spawner = Arc::clone(&self.spawner);
711 let cwd = parsed.cwd.clone();
712 let timeout = parsed.timeout;
713 let cfg = SpawnCfg {
714 max_output: self.limits.max_output_bytes,
715 audit_sink: self.limits.audit_sink.clone(),
716 sandbox: Arc::clone(&self.sandbox),
717 unbridled,
718 };
719 // Disclosed on every envelope this run returns (ADR 0018 D5/D11 / I11).
720 let disclosure = Disclosure {
721 unbridled,
722 human_gate: human_gate(),
723 ..Disclosure::default()
724 };
725 // Host/operator-supplied environment (the env seam, newt #783): carried
726 // through to the child processes. Empty when the dispatch omits `env`.
727 let env = parsed.env;
728 let caveats = cx.caveats().clone();
729 let run = tokio::task::spawn_blocking(move || {
730 run_script(&*spawner, &script, cwd.as_deref(), &caveats, &env, &cfg)
731 });
732 match tokio::time::timeout(timeout, run).await {
733 Ok(joined) => {
734 let captured = joined
735 .map_err(|e| ToolError::Other(anyhow::anyhow!("shell task panicked: {e}")))??;
736 // #196: a run that reached an out-of-allow-list host was refused
737 // by the egress proxy — surface those as structured `net` denials
738 // (sets `denied: true`; empty is a no-op on the common path).
739 Ok(ToolEnvelope::new(sandbox_kind)
740 .with_enforcement(enforcement)
741 .with_disclosure(disclosure)
742 .with_exit_code(captured.exit_code)
743 .with_truncation(captured.stdout_truncated, captured.stderr_truncated)
744 .with_stdout(captured.stdout)
745 .with_stderr(captured.stderr)
746 .with_denials(captured.net_denials)
747 .with_timed_out(false)
748 .into_json())
749 }
750 Err(_elapsed) => Ok(ToolEnvelope::new(sandbox_kind)
751 .with_enforcement(enforcement)
752 .with_disclosure(disclosure)
753 .with_stderr(format!("command timed out after {}s", timeout.as_secs()))
754 .with_timed_out(true)
755 .into_json()),
756 }
757 }
758}
759
760/// Execute a [`Script`] with `&&`/`||`/`;` short-circuit semantics, concatenating
761/// the output of the pipelines that actually run. The script's exit code is that
762/// of the last pipeline that ran (bash AND-OR-list semantics).
763fn run_script(
764 spawner: &dyn Spawner,
765 script: &[ScriptItem],
766 cwd: Option<&str>,
767 caveats: &Caveats,
768 env: &BTreeMap<String, String>,
769 cfg: &SpawnCfg,
770) -> ToolResult<Captured> {
771 let mut stdout = String::new();
772 let mut stderr = String::new();
773 let mut status: i32 = 0;
774 let mut stdout_truncated = false;
775 let mut stderr_truncated = false;
776 // #196: net denials accumulate across every pipeline stage that runs.
777 let mut net_denials: Vec<Denial> = Vec::new();
778
779 for item in script {
780 let run_it = match item.sep {
781 Sep::Seq => true,
782 Sep::And => status == 0,
783 Sep::Or => status != 0,
784 };
785 if run_it {
786 let captured = spawner.run(&item.pipeline, cwd, caveats, env, cfg)?;
787 stdout.push_str(&captured.stdout);
788 stderr.push_str(&captured.stderr);
789 stdout_truncated |= captured.stdout_truncated;
790 stderr_truncated |= captured.stderr_truncated;
791 net_denials.extend(captured.net_denials);
792 status = captured.exit_code;
793 }
794 }
795
796 // The concatenation across pipelines may itself exceed the cap; flag that.
797 let stdout_truncated = stdout_truncated || stdout.len() > cfg.max_output;
798 let stderr_truncated = stderr_truncated || stderr.len() > cfg.max_output;
799
800 Ok(Captured {
801 exit_code: status,
802 stdout: cap_string(stdout, cfg.max_output),
803 stderr: cap_string(stderr, cfg.max_output),
804 net_denials,
805 stdout_truncated,
806 stderr_truncated,
807 })
808}
809
810/// Build a structured `denied` envelope for a leash refusal.
811fn deny(
812 sandbox_kind: SandboxKind,
813 enforcement: EnforcementReport,
814 kind: DenialKind,
815 target: &str,
816 err: &ToolError,
817) -> serde_json::Value {
818 ToolEnvelope::new(sandbox_kind)
819 .with_enforcement(enforcement)
820 .with_disclosure(unbridle_disclosure())
821 .with_denials(vec![Denial {
822 kind,
823 target: target.to_string(),
824 reason: err.to_string(),
825 }])
826 .into_json()
827}
828
829/// The disclosure block stamped on **every** envelope (ADR 0018 D5): reads the
830/// process-level unbridle marker so a denied/refused result is as honest about
831/// the posture as a successful one.
832fn unbridle_disclosure() -> Disclosure {
833 Disclosure {
834 unbridled: is_unbridled(),
835 human_gate: human_gate(),
836 ..Disclosure::default()
837 }
838}
839
840/// Build a structured `denied` envelope for a parser [`Refusal`].
841fn refused_envelope(
842 sandbox_kind: SandboxKind,
843 enforcement: EnforcementReport,
844 refusal: &Refusal,
845) -> serde_json::Value {
846 ToolEnvelope::new(sandbox_kind)
847 .with_enforcement(enforcement)
848 .with_disclosure(unbridle_disclosure())
849 .with_denials(vec![Denial {
850 kind: DenialKind::Exec,
851 target: refusal.construct(),
852 reason: refusal.to_string(),
853 }])
854 .into_json()
855}
856
857/// Parsed, validated `shell` arguments.
858struct ShellArgs {
859 program: Option<String>,
860 args: Vec<String>,
861 cmd: Option<String>,
862 cwd: Option<String>,
863 /// Host/operator-supplied environment for the spawned child(ren) (the env
864 /// seam, newt #783). Empty when the dispatch omits `env` (back-compat). Only
865 /// string values are taken; non-string entries are ignored.
866 env: BTreeMap<String, String>,
867 timeout: Duration,
868}
869
870impl ShellArgs {
871 fn parse(v: &serde_json::Value, limits: &LimitsPolicy) -> ToolResult<Self> {
872 let obj = v
873 .as_object()
874 .ok_or_else(|| ToolError::denied("shell args must be a JSON object"))?;
875
876 let program = obj
877 .get("program")
878 .and_then(|x| x.as_str())
879 .map(String::from);
880 let cmd = obj.get("cmd").and_then(|x| x.as_str()).map(String::from);
881 let args = obj
882 .get("args")
883 .and_then(|x| x.as_array())
884 .map(|a| {
885 a.iter()
886 .filter_map(|x| x.as_str().map(String::from))
887 .collect::<Vec<_>>()
888 })
889 .unwrap_or_default();
890 let cwd = obj.get("cwd").and_then(|x| x.as_str()).map(String::from);
891 // The env seam (newt #783): a `"env": { "KEY": "VALUE", … }` object whose
892 // string values are set on the spawned child(ren). Absent → empty map
893 // (back-compat). Non-string values are dropped (the schema is string-only).
894 let env = obj
895 .get("env")
896 .and_then(|x| x.as_object())
897 .map(|m| {
898 m.iter()
899 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
900 .collect::<BTreeMap<String, String>>()
901 })
902 .unwrap_or_default();
903 let timeout_secs = obj
904 .get("timeout_secs")
905 .and_then(serde_json::Value::as_u64)
906 .unwrap_or(limits.default_timeout_secs)
907 .clamp(1, limits.max_timeout_secs);
908
909 match (&program, &cmd) {
910 (Some(_), Some(_)) => {
911 return Err(ToolError::denied(
912 "provide exactly one of `program` or `cmd`, not both",
913 ))
914 }
915 (None, None) => return Err(ToolError::denied("provide one of `program` or `cmd`")),
916 _ => {}
917 }
918 if program.is_none() && !args.is_empty() {
919 return Err(ToolError::denied(
920 "`args` may only be used together with `program`",
921 ));
922 }
923
924 Ok(Self {
925 program,
926 args,
927 cmd,
928 cwd,
929 env,
930 timeout: Duration::from_secs(timeout_secs),
931 })
932 }
933
934 /// Resolve to a script. Argv form is a one-pipeline, one-stage script whose
935 /// args are all **literal** (no globbing/parsing); free-form is parsed by the
936 /// safe-subset engine.
937 fn script(&self) -> Result<Script, Refusal> {
938 if let Some(program) = &self.program {
939 let mut argv = Vec::with_capacity(1 + self.args.len());
940 argv.push(Arg::Lit(program.clone()));
941 argv.extend(self.args.iter().cloned().map(Arg::Lit));
942 Ok(vec![ScriptItem {
943 sep: Sep::Seq,
944 pipeline: vec![Command {
945 argv,
946 redirects: Vec::new(),
947 }],
948 }])
949 } else {
950 classify(self.cmd.as_deref().unwrap_or(""))
951 }
952 }
953}
954
955// ── variable expansion (allowlist) ──────────────────────────────────────────
956
957/// The environment variables the confined engine will expand (ADR 0005 D3,
958/// allowlist-only). Deliberately small and secret-free: no `PATH`, no tokens.
959/// A `$VAR` outside this set is denied — so a confined run can never splice a
960/// secret (e.g. `$AWS_SECRET_KEY`) into an argument, even when `exec` is tight.
961/// Whether `name` may be expanded from the environment, against the configured
962/// allowlist ([`LimitsPolicy::var_allowlist`]).
963fn is_allowed_var(name: &str, allowlist: &[String]) -> bool {
964 allowlist.iter().any(|v| v == name)
965}
966
967/// The environment seam (#46): the engine reads `$VAR` values through this, so the
968/// allowlist + expansion + the resolved-path `fs` leash stay unit-testable
969/// without touching the real process environment (a fake map in tests). Only
970/// allowlisted names (the configured `var_allowlist`) are ever read.
971pub(crate) trait EnvProvider: Send + Sync {
972 /// The value of `name`, or `None` if unset.
973 fn get(&self, name: &str) -> Option<String>;
974}
975
976/// The real process environment (`std::env::var`).
977pub(crate) struct RealEnv;
978impl EnvProvider for RealEnv {
979 fn get(&self, name: &str) -> Option<String> {
980 std::env::var(name).ok()
981 }
982}
983
984/// Expand a redirect target's segments to a literal path, reading allowlisted
985/// `$VAR` through the env seam. Single-literal substitution: the value is **not**
986/// re-split or re-globbed (no re-injection). `Err((target, reason))` names a
987/// non-allowlisted variable for a structured denial.
988fn expand_redirect_target(
989 segs: &[Seg],
990 env: &dyn EnvProvider,
991 allowlist: &[String],
992) -> Result<String, (String, ToolError)> {
993 let mut out = String::new();
994 for seg in segs {
995 match seg {
996 Seg::Lit(s) => out.push_str(s),
997 Seg::Var(name) => {
998 if !is_allowed_var(name, allowlist) {
999 return Err((
1000 format!("${name}"),
1001 ToolError::denied(format!(
1002 "variable ${name} is not in the confined shell's allowlist"
1003 )),
1004 ));
1005 }
1006 out.push_str(&env.get(name).unwrap_or_default());
1007 }
1008 }
1009 }
1010 Ok(out)
1011}
1012
1013/// Expand a glob+variable word (e.g. `$DIR/*.rs`) into a resolved glob pattern,
1014/// reading allowlisted `$VAR` through the env seam.
1015///
1016/// **Re-injection guard:** a variable may only contribute to the directory
1017/// *prefix* (everything up to the last `/`), never to the glob *basename* — so a
1018/// var value can never inject a glob metachar that widens the match. The existing
1019/// single-segment globber then treats the (var-derived) directory as a literal
1020/// path and globs only the source-literal basename. A variable in the basename is
1021/// refused. `Err((target, reason))` names a non-allowlisted var or the refusal.
1022fn expand_varglob(
1023 segs: &[Seg],
1024 env: &dyn EnvProvider,
1025 allowlist: &[String],
1026) -> Result<String, (String, ToolError)> {
1027 let mut out = String::new();
1028 let mut last_var_byte: Option<usize> = None; // byte index of the last var-origin char
1029 let mut last_slash_byte: Option<usize> = None; // byte index of the last '/'
1030 for seg in segs {
1031 match seg {
1032 Seg::Lit(s) => {
1033 for ch in s.chars() {
1034 if ch == '/' {
1035 last_slash_byte = Some(out.len());
1036 }
1037 out.push(ch);
1038 }
1039 }
1040 Seg::Var(name) => {
1041 if !is_allowed_var(name, allowlist) {
1042 return Err((
1043 format!("${name}"),
1044 ToolError::denied(format!(
1045 "variable ${name} is not in the confined shell's allowlist"
1046 )),
1047 ));
1048 }
1049 for ch in env.get(name).unwrap_or_default().chars() {
1050 if ch == '/' {
1051 last_slash_byte = Some(out.len());
1052 }
1053 last_var_byte = Some(out.len());
1054 out.push(ch);
1055 }
1056 }
1057 }
1058 }
1059 // A var char in the basename (at/after the char following the last '/') could
1060 // inject a glob metachar from its value — refuse (re-injection guard).
1061 let basename_start = last_slash_byte.map_or(0, |i| i + 1);
1062 if last_var_byte.is_some_and(|v| v >= basename_start) {
1063 return Err((
1064 "$VAR".to_string(),
1065 ToolError::denied(
1066 "a variable in a glob's basename is not supported (re-injection guard); \
1067 put the variable in the directory prefix, e.g. $DIR/*.rs",
1068 ),
1069 ));
1070 }
1071 Ok(out)
1072}
1073
1074// ── glob expansion (multi-segment + recursive `**`) ─────────────────────────
1075
1076/// One directory entry the glob walker sees: a name and whether it is a directory
1077/// (needed to recurse for `**`).
1078#[derive(Debug, Clone, PartialEq, Eq)]
1079pub(crate) struct GlobEntry {
1080 pub name: String,
1081 pub is_dir: bool,
1082}
1083
1084/// Lists a directory's entries — the filesystem seam for the glob walker, so unit
1085/// tests drive multi-segment / `**` expansion without a real filesystem (#47).
1086pub(crate) trait DirLister: Send + Sync {
1087 /// The entries of `dir` (names + is-dir), or empty if it cannot be read.
1088 fn list(&self, dir: &Path) -> Vec<GlobEntry>;
1089}
1090
1091/// The real filesystem lister.
1092pub(crate) struct RealDirLister;
1093impl DirLister for RealDirLister {
1094 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
1095 std::fs::read_dir(dir)
1096 .map(|rd| {
1097 rd.filter_map(|e| {
1098 let e = e.ok()?;
1099 let name = e.file_name().into_string().ok()?;
1100 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1101 Some(GlobEntry { name, is_dir })
1102 })
1103 .collect()
1104 })
1105 .unwrap_or_default()
1106 }
1107}
1108
1109/// Append `name` to the result path `rel`, preserving the pattern's form
1110/// (relative vs absolute).
1111fn join_rel(rel: &str, name: &str) -> String {
1112 if rel.is_empty() {
1113 name.to_string()
1114 } else if rel == "/" {
1115 format!("/{name}")
1116 } else {
1117 format!("{rel}/{name}")
1118 }
1119}
1120
1121/// Collect every descendant directory of `(real, rel)` (bounded depth),
1122/// leash-checking + listing each — the `**` expansion. Hidden directories are not
1123/// descended (bash globstar default).
1124fn descend_all(
1125 real: &Path,
1126 rel: &str,
1127 list: &dyn DirLister,
1128 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1129 depth: usize,
1130 max_matches: usize,
1131 out: &mut Vec<(PathBuf, String)>,
1132) -> ToolResult<()> {
1133 if depth == 0 || out.len() >= max_matches {
1134 return Ok(());
1135 }
1136 leash(real)?;
1137 let mut entries = list.list(real);
1138 entries.sort_by(|a, b| a.name.cmp(&b.name));
1139 for e in entries {
1140 if e.is_dir && !e.name.starts_with('.') {
1141 let child_real = real.join(&e.name);
1142 let child_rel = join_rel(rel, &e.name);
1143 out.push((child_real.clone(), child_rel.clone()));
1144 if out.len() >= max_matches {
1145 break;
1146 }
1147 descend_all(
1148 &child_real,
1149 &child_rel,
1150 list,
1151 leash,
1152 depth - 1,
1153 max_matches,
1154 out,
1155 )?;
1156 }
1157 }
1158 Ok(())
1159}
1160
1161/// Expand a glob pattern (multi-segment and recursive `**`) against the
1162/// filesystem via `list`, **leash-checking every directory before listing it**
1163/// (`leash`) — so the whole walk stays within `fs_read` scope, *before any stage
1164/// spawns* (atomic admission). Per-component matching uses [`fnmatch`]
1165/// (`*`/`?`/`[…]` do not cross `/`); `**` matches zero or more directory levels.
1166/// Bounded by depth + match count. nullglob-off: no match → the literal pattern.
1167/// A `leash` `Err` (an out-of-scope directory) propagates and denies the command.
1168fn expand_glob_walk(
1169 pattern: &str,
1170 cwd: Option<&str>,
1171 list: &dyn DirLister,
1172 leash: &mut dyn FnMut(&Path) -> ToolResult<()>,
1173 max_depth: usize,
1174 max_matches: usize,
1175) -> ToolResult<Vec<String>> {
1176 let absolute = pattern.starts_with('/');
1177 let segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
1178
1179 let base_real = if absolute {
1180 PathBuf::from("/")
1181 } else {
1182 cwd.map_or_else(|| PathBuf::from("."), PathBuf::from)
1183 };
1184 let base_rel = if absolute {
1185 "/".to_string()
1186 } else {
1187 String::new()
1188 };
1189 let mut frontier: Vec<(PathBuf, String)> = vec![(base_real, base_rel)];
1190
1191 for seg in &segments {
1192 let mut next: Vec<(PathBuf, String)> = Vec::new();
1193 if *seg == "**" {
1194 for (real, rel) in &frontier {
1195 next.push((real.clone(), rel.clone())); // `**` matches zero levels too
1196 descend_all(real, rel, list, leash, max_depth, max_matches, &mut next)?;
1197 }
1198 } else {
1199 let seg_hidden = seg.starts_with('.');
1200 for (real, rel) in &frontier {
1201 leash(real)?;
1202 let mut entries = list.list(real);
1203 entries.sort_by(|a, b| a.name.cmp(&b.name));
1204 for e in entries {
1205 if (seg_hidden || !e.name.starts_with('.')) && fnmatch(seg, &e.name) {
1206 next.push((real.join(&e.name), join_rel(rel, &e.name)));
1207 if next.len() >= max_matches {
1208 break;
1209 }
1210 }
1211 }
1212 }
1213 }
1214 frontier = next;
1215 if frontier.is_empty() {
1216 break;
1217 }
1218 }
1219
1220 let mut matches: Vec<String> = frontier.into_iter().map(|(_, rel)| rel).collect();
1221 matches.retain(|m| !m.is_empty()); // drop the "zero-levels" cwd self-match
1222 matches.sort();
1223 matches.dedup();
1224 if matches.is_empty() {
1225 Ok(vec![pattern.to_string()])
1226 } else {
1227 Ok(matches)
1228 }
1229}
1230
1231/// Glob match: `*` (any run), `?` (one char), `[…]` (class with ranges and
1232/// `!`/`^` negation). `*`/`?`/`[` do not cross `/` (single-segment matching).
1233fn fnmatch(pattern: &str, name: &str) -> bool {
1234 let p: Vec<char> = pattern.chars().collect();
1235 let n: Vec<char> = name.chars().collect();
1236 fnmatch_inner(&p, &n)
1237}
1238
1239fn fnmatch_inner(p: &[char], n: &[char]) -> bool {
1240 match p.first() {
1241 None => n.is_empty(),
1242 Some('*') => fnmatch_inner(&p[1..], n) || (!n.is_empty() && fnmatch_inner(p, &n[1..])),
1243 Some('?') => !n.is_empty() && fnmatch_inner(&p[1..], &n[1..]),
1244 Some('[') => {
1245 if n.is_empty() {
1246 return false;
1247 }
1248 match match_class(&p[1..], n[0]) {
1249 Some((matched, rest)) => matched && fnmatch_inner(rest, &n[1..]),
1250 // Malformed class (no closing `]`): treat `[` as a literal.
1251 None => n[0] == '[' && fnmatch_inner(&p[1..], &n[1..]),
1252 }
1253 }
1254 Some(&c) => !n.is_empty() && c == n[0] && fnmatch_inner(&p[1..], &n[1..]),
1255 }
1256}
1257
1258/// Match a `[...]` class against `c`. `p` begins just after `[`. Returns
1259/// `(matched, remaining pattern after ])`, or `None` if there is no closing `]`.
1260fn match_class(p: &[char], c: char) -> Option<(bool, &[char])> {
1261 let mut i = 0;
1262 let negate = matches!(p.first(), Some('!' | '^'));
1263 if negate {
1264 i = 1;
1265 }
1266 let mut matched = false;
1267 let mut first = true;
1268 while i < p.len() {
1269 if p[i] == ']' && !first {
1270 return Some((matched ^ negate, &p[i + 1..]));
1271 }
1272 first = false;
1273 if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
1274 if c >= p[i] && c <= p[i + 2] {
1275 matched = true;
1276 }
1277 i += 3;
1278 } else {
1279 if c == p[i] {
1280 matched = true;
1281 }
1282 i += 1;
1283 }
1284 }
1285 None
1286}
1287
1288// ── process execution ───────────────────────────────────────────────────────
1289
1290/// Open a file for an `fs_write` redirect target (`>` truncates, `>>` appends).
1291fn open_for_write(path: &str, append: bool) -> std::io::Result<std::fs::File> {
1292 #[cfg(windows)]
1293 if append {
1294 let mut file = std::fs::OpenOptions::new()
1295 .write(true)
1296 .create(true)
1297 .truncate(false)
1298 .open(path)?;
1299 file.seek(SeekFrom::End(0))?;
1300 return Ok(file);
1301 }
1302
1303 std::fs::OpenOptions::new()
1304 .write(true)
1305 .create(true)
1306 .truncate(!append)
1307 .append(append)
1308 .open(path)
1309}
1310
1311/// Kill (and reap) any stages already spawned, so a mid-pipeline error does not
1312/// orphan processes.
1313fn kill_all(children: &mut [Child]) {
1314 for child in children.iter_mut() {
1315 let _ = child.kill();
1316 let _ = child.wait();
1317 }
1318}
1319
1320/// Lower a stage's [`Arg`] list into a concrete argv: literals as-is, globs
1321/// expanded against the real filesystem, and (allowlisted) variables read from
1322/// the environment as a single literal (no re-split / no re-glob of the value).
1323/// The allowlist is enforced earlier in [`ShellTool::invoke`].
1324fn expand_stage_argv(stage: &Command, _cwd: Option<&str>) -> Vec<String> {
1325 let mut argv = Vec::with_capacity(stage.argv.len());
1326 for arg in &stage.argv {
1327 match arg {
1328 Arg::Lit(s) => argv.push(s.clone()),
1329 // Concatenate the segments: literals as-is, variables (already
1330 // allowlisted in `invoke`) read from the env as a single literal —
1331 // no re-split / no re-glob of the value.
1332 Arg::Var(segs) => {
1333 let mut word = String::new();
1334 for seg in segs {
1335 match seg {
1336 Seg::Lit(s) => word.push_str(s),
1337 Seg::Var(name) => word.push_str(&std::env::var(name).unwrap_or_default()),
1338 }
1339 }
1340 argv.push(word);
1341 }
1342 // Globs (and glob+var words) are expanded to literal matches at
1343 // admission (with the per-directory fs_read leash), so the spawner
1344 // never sees them.
1345 Arg::Glob(_) => unreachable!("glob expanded at admission"),
1346 Arg::VarGlob(_) => unreachable!("VarGlob lowered/expanded at admission"),
1347 }
1348 }
1349 argv
1350}
1351
1352/// Spawn a pipeline of commands wired with OS pipes and file redirections,
1353/// capturing the last stage's stdout (unless it is redirected to a file) and
1354/// every stage's stderr. The pipeline's exit code is the last stage's (bash
1355/// semantics without `pipefail`).
1356///
1357/// Deadlock-free: every stage's stderr and the last stage's stdout are drained by
1358/// their own threads, so no pipe can fill while we `wait()` the children.
1359///
1360/// `wrap` is the OS-sandbox command prefix (macOS Seatbelt's `sandbox-exec -p
1361/// <profile>`), prepended to **every** stage so each spawned program is confined;
1362/// it is empty for thread-confining (Landlock) and unconfined runs.
1363fn run_pipeline(
1364 stages: &[Command],
1365 cwd: Option<&str>,
1366 wrap: &[String],
1367 env: &BTreeMap<String, String>,
1368 max_output: usize,
1369) -> ToolResult<Captured> {
1370 debug_assert!(!stages.is_empty(), "the parser guarantees ≥1 stage");
1371 let n = stages.len();
1372 let last = n - 1;
1373
1374 let mut children: Vec<Child> = Vec::with_capacity(n);
1375 // The read end feeding the NEXT stage's stdin (from the prior stage's stdout).
1376 let mut prev_stdin: Option<PipeReader> = None;
1377 // The read end capturing final stdout (last stage, when not redirected).
1378 let mut stdout_capture: Option<PipeReader> = None;
1379 // Reader threads for stages whose stderr is captured separately. Each yields
1380 // (captured bytes ≤ cap, truncated?).
1381 let mut stderr_threads: Vec<std::thread::JoinHandle<(Vec<u8>, bool)>> = Vec::new();
1382
1383 for (i, stage) in stages.iter().enumerate() {
1384 let is_last = i == last;
1385 let stage_argv = expand_stage_argv(stage, cwd);
1386 // Prepend the sandbox wrapper (Seatbelt) so the program is spawned
1387 // confined: `sandbox-exec -p <profile> <program> <args…>`. Empty wrap is
1388 // the identity. `sandbox-exec` forwards stdio + cwd to the child, so the
1389 // pipe/redirect plumbing below is unchanged.
1390 let argv: Vec<String> = if wrap.is_empty() {
1391 stage_argv
1392 } else {
1393 wrap.iter().cloned().chain(stage_argv).collect()
1394 };
1395 let mut cmd = std::process::Command::new(&argv[0]);
1396 cmd.args(&argv[1..]);
1397 if let Some(dir) = cwd {
1398 cmd.current_dir(dir);
1399 }
1400 // Host/operator-supplied environment (the env seam, newt #783): set the
1401 // provided vars on the child, additive over the inherited ambient env.
1402 // The values are structured host input (never model-authored command
1403 // text), so they grant no new authority — the exec/fs leash that already
1404 // admitted this stage checked the *real* program (argv[0]), not env. When
1405 // a Seatbelt `wrap` prefix is present, `sandbox-exec` forwards its own
1406 // environment to the wrapped program, so setting it here still reaches the
1407 // confined child.
1408 for (k, v) in env {
1409 cmd.env(k, v);
1410 }
1411
1412 // ── stdin: a `< file` redirect wins over the incoming pipe ──────────
1413 if let Some(path) = stage.stdin_path() {
1414 let file = ok_or_kill(std::fs::File::open(path), &mut children)?;
1415 cmd.stdin(Stdio::from(file));
1416 prev_stdin = None;
1417 } else {
1418 cmd.stdin(match prev_stdin.take() {
1419 Some(reader) => Stdio::from(reader),
1420 None => Stdio::null(),
1421 });
1422 }
1423
1424 // ── stdout (+ the handle stderr clones for `2>&1`) ──────────────────
1425 // A `> file` redirect goes to the file; otherwise a `std::io::pipe()` is
1426 // used so its writer can be cloned for `2>&1` in any position.
1427 let dup_source: DupSource;
1428 if let Some((path, append)) = stage.stdout_redirect() {
1429 let file = ok_or_kill(open_for_write(path, append), &mut children)?;
1430 let clone = ok_or_kill(file.try_clone(), &mut children)?;
1431 cmd.stdout(Stdio::from(file));
1432 dup_source = DupSource::File(clone);
1433 } else {
1434 let (reader, writer) = ok_or_kill(std::io::pipe(), &mut children)?;
1435 let clone = ok_or_kill(writer.try_clone(), &mut children)?;
1436 cmd.stdout(Stdio::from(writer));
1437 if is_last {
1438 stdout_capture = Some(reader);
1439 } else {
1440 prev_stdin = Some(reader);
1441 }
1442 dup_source = DupSource::Pipe(clone);
1443 }
1444
1445 // ── stderr ──────────────────────────────────────────────────────────
1446 match stage.stderr_disposition() {
1447 // `2>&1`: stderr writes to the stdout destination (the dup is moved
1448 // into the child; nothing captured separately).
1449 StderrTo::Stdout => match dup_source {
1450 DupSource::File(f) => {
1451 cmd.stderr(Stdio::from(f));
1452 }
1453 DupSource::Pipe(w) => {
1454 cmd.stderr(Stdio::from(w));
1455 }
1456 },
1457 // `2> file`: stderr to its own file.
1458 StderrTo::File { path, append } => {
1459 let file = ok_or_kill(open_for_write(&path, append), &mut children)?;
1460 cmd.stderr(Stdio::from(file));
1461 // `dup_source` is dropped here (unused) — never retain a writer.
1462 }
1463 // Default: capture stderr separately via a piped fd.
1464 StderrTo::Capture => {
1465 cmd.stderr(Stdio::piped());
1466 }
1467 }
1468
1469 let mut child = ok_or_kill(cmd.spawn(), &mut children)?;
1470
1471 if matches!(stage.stderr_disposition(), StderrTo::Capture) {
1472 let err = child.stderr.take().expect("stderr is piped");
1473 stderr_threads.push(std::thread::spawn(move || read_capped(err, max_output)));
1474 }
1475 children.push(child);
1476 }
1477
1478 // The parent now holds no pipe writers, so a captured reader sees EOF once
1479 // the child(ren) exit. Read stdout (bounded by the cap) concurrently with
1480 // waiting; a child producing past the cap is cut off via EPIPE.
1481 let stdout_thread =
1482 stdout_capture.map(|reader| std::thread::spawn(move || read_capped(reader, max_output)));
1483
1484 // Wait all stages; the pipeline's exit code is the last stage's.
1485 let mut exit_code = -1;
1486 for (i, child) in children.iter_mut().enumerate() {
1487 let status = child.wait().map_err(ToolError::Exec)?;
1488 if i == last {
1489 exit_code = status.code().unwrap_or(-1);
1490 }
1491 }
1492
1493 let (stdout, stdout_truncated) =
1494 stdout_thread.map_or((Vec::new(), false), |h| h.join().unwrap_or_default());
1495 let mut stderr = Vec::new();
1496 let mut stderr_truncated = false;
1497 for h in stderr_threads {
1498 let (buf, trunc) = h.join().unwrap_or_default();
1499 stderr.extend(buf);
1500 stderr_truncated |= trunc;
1501 }
1502 // Concatenated stderr across stages may itself exceed the cap; `capped_utf8`
1503 // clips it and we flag that too.
1504 let stderr_truncated = stderr_truncated || stderr.len() > max_output;
1505
1506 Ok(Captured {
1507 exit_code,
1508 stdout: capped_utf8(&stdout, max_output),
1509 stderr: capped_utf8(&stderr, max_output),
1510 stdout_truncated,
1511 stderr_truncated,
1512 // #196: net denials are attached by run_with_egress_proxy (which owns the
1513 // proxy handle), not here — a bare pipeline run observes no proxy refusals.
1514 net_denials: Vec::new(),
1515 })
1516}
1517
1518/// What a stage's stderr clones from for `2>&1` (the stdout destination).
1519enum DupSource {
1520 File(std::fs::File),
1521 Pipe(PipeWriter),
1522}
1523
1524/// Map an `io::Result`, killing already-spawned children on error so a failure
1525/// mid-pipeline never orphans processes.
1526fn ok_or_kill<T>(result: std::io::Result<T>, children: &mut [Child]) -> ToolResult<T> {
1527 result.map_err(|e| {
1528 kill_all(children);
1529 ToolError::Exec(e)
1530 })
1531}
1532
1533/// Read **at most** `max_output` bytes from `reader` into memory, then probe one
1534/// more byte to decide whether the source had more. Returns the captured bytes
1535/// (≤ cap) and whether it was truncated.
1536///
1537/// Crucially, peak buffering is bounded by the cap **regardless of how much the
1538/// child produces** — closing the DoS where a fast producer (`yes`,
1539/// `cat /dev/zero`) balloons host memory up to the timeout window (#73). The
1540/// remainder is **not** drained: dropping `reader` closes the pipe read end, so a
1541/// still-writing child gets `EPIPE`/`SIGPIPE` on its next write (the `| head`
1542/// model) rather than blocking us — and we never read past `cap + 1` bytes.
1543fn read_capped(mut reader: impl Read, max_output: usize) -> (Vec<u8>, bool) {
1544 let mut buf = Vec::new();
1545 // `take` bounds total bytes read into `buf` to the cap.
1546 let _ = (&mut reader).take(max_output as u64).read_to_end(&mut buf);
1547 // One probe read: any byte beyond the cap means the source was truncated.
1548 let mut probe = [0u8; 1];
1549 let truncated = matches!(reader.read(&mut probe), Ok(n) if n > 0);
1550 (buf, truncated)
1551}
1552
1553/// Lossy-decode captured output (already bounded to ≤ `max_output` by
1554/// [`read_capped`]). The `min` is a defensive belt-and-suspenders. Truncation at
1555/// a byte boundary is safe: [`String::from_utf8_lossy`] replaces any partial
1556/// trailing sequence rather than panicking.
1557fn capped_utf8(bytes: &[u8], max_output: usize) -> String {
1558 let slice = &bytes[..bytes.len().min(max_output)];
1559 String::from_utf8_lossy(slice).into_owned()
1560}
1561
1562/// Cap an already-decoded string to `max_output` at a char boundary
1563/// (used for the concatenated output of a multi-pipeline script).
1564fn cap_string(mut s: String, max_output: usize) -> String {
1565 if s.len() > max_output {
1566 let mut end = max_output;
1567 while !s.is_char_boundary(end) {
1568 end -= 1;
1569 }
1570 s.truncate(end);
1571 }
1572 s
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577 use super::*;
1578 use agent_bridle_core::{Caveats, Gate, Scope};
1579 use std::collections::HashMap;
1580 use std::sync::Mutex;
1581
1582 /// The schema loads from the embedded `shell_tool.schema.json` data file (not
1583 /// an inline literal) with the expected shape. Guards the data file against
1584 /// corruption — a bad edit fails here, not in prod.
1585 #[test]
1586 fn schema_loads_from_data_file_with_expected_shape() {
1587 let s = ShellTool::new().schema();
1588 assert_eq!(s["type"], "object");
1589 assert_eq!(s["additionalProperties"], false);
1590 for key in ["program", "args", "cmd", "cwd", "env", "timeout_secs"] {
1591 assert!(
1592 s["properties"].get(key).is_some(),
1593 "schema is missing the `{key}` property: {s}"
1594 );
1595 }
1596 }
1597
1598 /// The `timeout_secs.maximum` is a per-instance property injected over the
1599 /// data-file base — it tracks the configured `LimitsPolicy`, so `with_config`
1600 /// changes the advertised ceiling.
1601 #[test]
1602 fn schema_timeout_maximum_tracks_the_configured_limits() {
1603 let limits = agent_bridle_core::LimitsPolicy {
1604 max_timeout_secs: 7,
1605 ..agent_bridle_core::LimitsPolicy::default()
1606 };
1607 let s = ShellTool::with_config(limits).schema();
1608 assert_eq!(s["properties"]["timeout_secs"]["maximum"], 7);
1609 // The data file itself carries no `maximum` — the bound is Rust-owned.
1610 assert!(SHELL_SCHEMA["properties"]["timeout_secs"]
1611 .get("maximum")
1612 .is_none());
1613 }
1614
1615 /// A spawner that records every pipeline it runs and returns a canned exit
1616 /// code per program (argv0), default 0 — no real processes.
1617 #[derive(Default)]
1618 struct MockSpawner {
1619 calls: Mutex<Vec<Vec<Command>>>,
1620 /// The env map handed to each `run` call (parallel to `calls`), so the env
1621 /// seam (newt #783) is verified without a real process.
1622 envs: Mutex<Vec<BTreeMap<String, String>>>,
1623 exit_by_program: HashMap<String, i32>,
1624 block_ms: u64,
1625 /// #196: net denials the spawner reports back — the shape
1626 /// `run_with_egress_proxy` produces from the proxy's refused hosts, so the
1627 /// Captured→envelope wiring is verified without a real proxy/child.
1628 net_denials: Vec<Denial>,
1629 }
1630
1631 impl MockSpawner {
1632 fn with_exit(program: &str, code: i32) -> Self {
1633 let mut m = Self::default();
1634 m.exit_by_program.insert(program.to_string(), code);
1635 m
1636 }
1637
1638 /// #196: a mock whose `run` reports these net denials (as the real proxy
1639 /// path would for refused hosts).
1640 fn with_net_denials(denials: Vec<Denial>) -> Self {
1641 Self {
1642 net_denials: denials,
1643 ..Self::default()
1644 }
1645 }
1646 }
1647
1648 /// A stage's program word (argv[0]) for test assertions. (A variable in the
1649 /// program position is denied in `invoke`, so it never reaches the spawner.)
1650 fn prog(stage: &Command) -> &str {
1651 match stage.argv.first() {
1652 Some(Arg::Lit(s) | Arg::Glob(s)) => s,
1653 Some(Arg::Var(_) | Arg::VarGlob(_)) | None => "",
1654 }
1655 }
1656
1657 impl Spawner for MockSpawner {
1658 fn run(
1659 &self,
1660 stages: &[Command],
1661 _cwd: Option<&str>,
1662 _caveats: &Caveats,
1663 env: &BTreeMap<String, String>,
1664 _cfg: &SpawnCfg,
1665 ) -> ToolResult<Captured> {
1666 self.calls.lock().unwrap().push(stages.to_vec());
1667 self.envs.lock().unwrap().push(env.clone());
1668 if self.block_ms > 0 {
1669 std::thread::sleep(Duration::from_millis(self.block_ms));
1670 }
1671 Ok(Captured {
1672 exit_code: self
1673 .exit_by_program
1674 .get(prog(&stages[0]))
1675 .copied()
1676 .unwrap_or(0),
1677 stdout: String::new(),
1678 stderr: String::new(),
1679 net_denials: self.net_denials.clone(),
1680 ..Default::default()
1681 })
1682 }
1683 }
1684
1685 fn ctx(granted: Caveats) -> ToolContext {
1686 Gate::new(0)
1687 .authorize(&ShellTool::new(), &granted)
1688 .expect("authorize")
1689 }
1690
1691 /// A context for a **strong** principal (fence-strength floor = `Kernel`):
1692 /// any restricted axis the real backend can't kernel-confine fails closed.
1693 fn ctx_strong(granted: Caveats) -> ToolContext {
1694 Gate::new(0)
1695 .with_strength_floor(agent_bridle_core::AxisEnforcement::Kernel)
1696 .authorize(&ShellTool::new(), &granted)
1697 .expect("authorize")
1698 }
1699
1700 fn exec_only(names: &[&str]) -> Caveats {
1701 Caveats {
1702 exec: Scope::only(names.iter().map(|s| (*s).to_string())),
1703 ..Caveats::top()
1704 }
1705 }
1706
1707 fn calls(mock: &Arc<MockSpawner>) -> Vec<Vec<Command>> {
1708 mock.calls.lock().unwrap().clone()
1709 }
1710
1711 /// The env map handed to each `run` call, in order (the env seam, newt #783).
1712 fn envs(mock: &Arc<MockSpawner>) -> Vec<BTreeMap<String, String>> {
1713 mock.envs.lock().unwrap().clone()
1714 }
1715
1716 /// ADR 0012 D4/D8 + ADR 0014: a STRONG principal (floor = `Kernel`) refuses to
1717 /// run unconfined when a restricted axis cannot be kernel-confined on this host.
1718 ///
1719 /// For the `exec` axis the outcome is **backend-dependent** since ADR 0014
1720 /// closed #57 for macOS: under an active Seatbelt backend `exec` is
1721 /// kernel-confined via `process-exec*`, so the strong principal *runs*
1722 /// (reporting `exec → kernel`); under Landlock or a Noop host the exec axis is
1723 /// still held (#31/#57), so it fails closed *before any spawn*. The default
1724 /// (permissive, Advisory-floor) principal runs in either case. This closes the
1725 /// shell's run-unconfined gap and matches `ConfinedCommand`'s fail-closed
1726 /// posture. The test's expectation is derived from the *same* honesty rule the
1727 /// production path uses (`intended_sandbox_kind` + `enforcement_report`), so the
1728 /// two cannot disagree across platforms/features.
1729 #[tokio::test]
1730 async fn strong_principal_fails_closed_on_unenforceable_exec() {
1731 let granted = exec_only(&["echo"]);
1732 // Does the backend that will actually govern this run kernel-confine `exec`?
1733 // Seatbelt does (`process-exec*`, ADR 0014); Landlock/Noop do not (#31/#57).
1734 let exec_is_kernel_confined = enforcement_report(
1735 &granted,
1736 intended_sandbox_kind(&granted, &Arc::new(SandboxPolicy::default())),
1737 )
1738 .exec
1739 == Some(agent_bridle_core::AxisEnforcement::Kernel);
1740
1741 let mock = Arc::new(MockSpawner::default());
1742 let out = ShellTool::with_spawner(mock.clone())
1743 .invoke(
1744 serde_json::json!({"cmd": "echo hi"}),
1745 &ctx_strong(granted.clone()),
1746 )
1747 .await
1748 .expect("invoke");
1749 if exec_is_kernel_confined {
1750 // Seatbelt confines `exec` in the kernel, so the strong principal runs —
1751 // kernel-confined, not refused.
1752 assert_ne!(
1753 out["denied"],
1754 serde_json::json!(true),
1755 "kernel-confined exec must run for a strong principal: {out}"
1756 );
1757 assert_eq!(
1758 out["enforcement"]["exec"], "kernel",
1759 "exec is reported kernel-confined: {out}"
1760 );
1761 assert_eq!(ran_programs(&mock), ["echo"], "the program spawned: {out}");
1762 } else {
1763 // The exec axis is held (Landlock/Noop): a Kernel floor cannot be met, so
1764 // refuse before any spawn.
1765 assert_eq!(
1766 out["denied"], true,
1767 "strong principal must fail closed on unenforceable exec: {out}"
1768 );
1769 assert!(ran_programs(&mock).is_empty(), "nothing may spawn: {out}");
1770 }
1771
1772 // The default (permissive, Advisory-floor) principal runs the same command
1773 // regardless of backend.
1774 let mock = Arc::new(MockSpawner::default());
1775 let out = ShellTool::with_spawner(mock.clone())
1776 .invoke(serde_json::json!({"cmd": "echo hi"}), &ctx(granted))
1777 .await
1778 .expect("invoke");
1779 assert_ne!(
1780 out["denied"],
1781 serde_json::json!(true),
1782 "default principal still runs: {out}"
1783 );
1784 }
1785
1786 /// #196: a net refusal reported by the spawner (the shape
1787 /// `run_with_egress_proxy` produces from the proxy's refused hosts) reaches
1788 /// the result envelope as a structured `net` denial with `denied: true` — the
1789 /// exact signal a consumer (newt) lifts into a per-host prompt. Unlike an
1790 /// `exec`/`open` refusal, the command still RAN (the refusal is observed
1791 /// during the run, not at pre-spawn admission).
1792 #[tokio::test]
1793 async fn net_refusal_surfaces_as_a_net_denial_in_the_envelope() {
1794 let mock = Arc::new(MockSpawner::with_net_denials(vec![Denial {
1795 kind: DenialKind::Net,
1796 target: "github.com".to_string(),
1797 reason: "net does not permit 'github.com'".to_string(),
1798 }]));
1799 let out = ShellTool::with_spawner(mock)
1800 .invoke(
1801 serde_json::json!({ "cmd": "echo hi" }),
1802 &ctx(exec_only(&["echo"])),
1803 )
1804 .await
1805 .expect("invoke");
1806 assert_eq!(
1807 out["denied"],
1808 serde_json::json!(true),
1809 "a net denial sets denied: {out}"
1810 );
1811 assert_eq!(out["denials"][0]["kind"], "net");
1812 assert_eq!(out["denials"][0]["target"], "github.com");
1813 // The command still executed — a success envelope (has exit_code), not a
1814 // pre-spawn refused envelope.
1815 assert!(out.get("exit_code").is_some(), "command still ran: {out}");
1816 }
1817
1818 fn ran_programs(mock: &Arc<MockSpawner>) -> Vec<String> {
1819 calls(mock)
1820 .iter()
1821 .map(|pipeline| prog(&pipeline[0]).to_string())
1822 .collect()
1823 }
1824
1825 // ── the env seam (newt #783) ────────────────────────────────────────────
1826
1827 /// A dispatch carrying `"env": { "FOO": "bar" }` reaches the spawner with that
1828 /// var on the child's environment map — the seam newt #783 needs so it can
1829 /// pass the venv environment as real env instead of an `export …;` prefix.
1830 #[tokio::test]
1831 async fn env_map_is_passed_to_the_spawner() {
1832 let mock = Arc::new(MockSpawner::default());
1833 let out = ShellTool::with_spawner(mock.clone())
1834 .invoke(
1835 serde_json::json!({
1836 "program": "echo",
1837 "args": ["hi"],
1838 "env": { "FOO": "bar", "VIRTUAL_ENV": "/venv" },
1839 }),
1840 &ctx(exec_only(&["echo"])),
1841 )
1842 .await
1843 .expect("invoke");
1844 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
1845 let envs = envs(&mock);
1846 assert_eq!(envs.len(), 1, "one pipeline ran");
1847 assert_eq!(envs[0].get("FOO").map(String::as_str), Some("bar"));
1848 assert_eq!(
1849 envs[0].get("VIRTUAL_ENV").map(String::as_str),
1850 Some("/venv"),
1851 "every env entry reaches the child: {:?}",
1852 envs[0]
1853 );
1854 }
1855
1856 /// The env map is NEVER part of the leash decision: the leash still checks the
1857 /// real program. A compound command (`hostname; uname`) with `env` set must
1858 /// check `hostname` first — never `export`/`env`/an env KEY. This is the exact
1859 /// newt #783 root cause: prepending `export VIRTUAL_ENV=…;` made the first
1860 /// stage's argv[0] the literal `export` builtin, which the leash denied. With
1861 /// env carried as a real map there is no `export` stage at all.
1862 #[tokio::test]
1863 async fn env_does_not_change_the_program_the_leash_checks() {
1864 let mock = Arc::new(MockSpawner::default());
1865 // Grant exactly the two real programs; `export`/`env`/the env keys are NOT
1866 // granted, so if any of them were checked the run would be denied.
1867 let out = ShellTool::with_spawner(mock.clone())
1868 .invoke(
1869 serde_json::json!({
1870 "cmd": "hostname; uname -s",
1871 "env": { "FOO": "bar" },
1872 }),
1873 &ctx(exec_only(&["hostname", "uname"])),
1874 )
1875 .await
1876 .expect("invoke");
1877 assert_ne!(out["denied"], serde_json::json!(true), "must run: {out}");
1878 // The FIRST program the spawner saw is the real `hostname`, not `export`.
1879 let programs = ran_programs(&mock);
1880 assert_eq!(
1881 programs,
1882 vec!["hostname".to_string(), "uname".to_string()],
1883 "the leash/spawner see the real programs, never `export`/env keys: {programs:?}"
1884 );
1885 // And the env still reached each child.
1886 for e in envs(&mock) {
1887 assert_eq!(e.get("FOO").map(String::as_str), Some("bar"));
1888 }
1889 }
1890
1891 /// `ShellArgs::parse`: the `env` field is populated from the dispatch JSON
1892 /// `"env"` object when present, and is empty (back-compat) when absent.
1893 #[test]
1894 fn parse_env_field_present_and_absent() {
1895 // Present → populated (string values only).
1896 let parsed = ShellArgs::parse(
1897 &serde_json::json!({
1898 "program": "echo",
1899 "env": { "FOO": "bar", "BAZ": "qux" },
1900 }),
1901 &agent_bridle_core::LimitsPolicy::default(),
1902 )
1903 .expect("parse");
1904 assert_eq!(parsed.env.get("FOO").map(String::as_str), Some("bar"));
1905 assert_eq!(parsed.env.get("BAZ").map(String::as_str), Some("qux"));
1906 assert_eq!(parsed.env.len(), 2);
1907
1908 // Absent → empty map (existing dispatches are unaffected).
1909 let parsed = ShellArgs::parse(
1910 &serde_json::json!({ "program": "echo" }),
1911 &agent_bridle_core::LimitsPolicy::default(),
1912 )
1913 .expect("parse");
1914 assert!(parsed.env.is_empty(), "absent env defaults to empty");
1915 }
1916
1917 /// #143: the timeout is bounded/defaulted by the configured `LimitsPolicy`,
1918 /// not the old hard-coded 300/60. A tuned policy clamps and defaults to its
1919 /// own values.
1920 #[test]
1921 fn parse_timeout_uses_configured_limits() {
1922 let limits = agent_bridle_core::LimitsPolicy {
1923 max_timeout_secs: 5,
1924 default_timeout_secs: 3,
1925 ..agent_bridle_core::LimitsPolicy::default()
1926 };
1927 // A request over the configured max is clamped to it.
1928 let over = ShellArgs::parse(
1929 &serde_json::json!({ "program": "echo", "timeout_secs": 9999 }),
1930 &limits,
1931 )
1932 .expect("parse");
1933 assert_eq!(over.timeout, std::time::Duration::from_secs(5));
1934 // No timeout specified → the configured default.
1935 let dflt =
1936 ShellArgs::parse(&serde_json::json!({ "program": "echo" }), &limits).expect("parse");
1937 assert_eq!(dflt.timeout, std::time::Duration::from_secs(3));
1938 }
1939
1940 /// A fake environment for the `$VAR` tests — exercises the allowlist +
1941 /// expansion + resolved-path leash without touching the real process env.
1942 struct FakeEnv(HashMap<String, String>);
1943 impl EnvProvider for FakeEnv {
1944 fn get(&self, name: &str) -> Option<String> {
1945 self.0.get(name).cloned()
1946 }
1947 }
1948 fn fake_env(pairs: &[(&str, &str)]) -> Arc<dyn EnvProvider> {
1949 Arc::new(FakeEnv(
1950 pairs
1951 .iter()
1952 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1953 .collect(),
1954 ))
1955 }
1956
1957 /// A fake directory lister keyed by path string — drives the glob walker
1958 /// without a real filesystem (#47).
1959 struct MapLister(HashMap<String, Vec<GlobEntry>>);
1960 impl DirLister for MapLister {
1961 fn list(&self, dir: &Path) -> Vec<GlobEntry> {
1962 // Normalize to forward slashes so test maps written with `/` work on
1963 // Windows where PathBuf::join produces `\`-separated paths.
1964 let key = dir.to_string_lossy().replace('\\', "/");
1965 self.0.get(&key).cloned().unwrap_or_default()
1966 }
1967 }
1968 fn ent(name: &str, is_dir: bool) -> GlobEntry {
1969 GlobEntry {
1970 name: name.to_string(),
1971 is_dir,
1972 }
1973 }
1974 fn map_lister(dirs: &[(&str, Vec<GlobEntry>)]) -> Arc<dyn DirLister> {
1975 Arc::new(MapLister(
1976 dirs.iter()
1977 .map(|(d, es)| ((*d).to_string(), es.clone()))
1978 .collect(),
1979 ))
1980 }
1981
1982 // ── $VAR in redirect targets (#46, via the env seam) ────────────────────
1983
1984 /// `> $TMPDIR/out` expands the allowlisted var through the seam and the
1985 /// spawner receives the RESOLVED path (never a literal `$VAR`); the resolved
1986 /// path is what the fs leash checked.
1987 #[tokio::test]
1988 async fn redirect_var_is_expanded_and_reaches_spawner_resolved() {
1989 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
1990 let mock = Arc::new(MockSpawner::default());
1991 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
1992 // fs_write is All (default), so the resolved path passes the leash.
1993 let out = tool
1994 .invoke(
1995 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
1996 &ctx(exec_only(&["echo"])),
1997 )
1998 .await
1999 .expect("invoke");
2000 assert_ne!(
2001 out["denied"],
2002 serde_json::json!(true),
2003 "in-scope var: {out}"
2004 );
2005 let redir = &calls(&mock)[0][0].redirects[0];
2006 assert_eq!(
2007 *redir,
2008 Redirect::Stdout {
2009 path: vec![Seg::Lit(format!("{tmp}/out"))],
2010 append: false,
2011 }
2012 );
2013 }
2014
2015 /// A non-allowlisted variable in a redirect target denies before any spawn.
2016 #[tokio::test]
2017 async fn redirect_var_not_in_allowlist_is_denied() {
2018 let mock = Arc::new(MockSpawner::default());
2019 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/x")]));
2020 let out = tool
2021 .invoke(
2022 serde_json::json!({"cmd": "echo hi > $SECRET"}),
2023 &ctx(exec_only(&["echo"])),
2024 )
2025 .await
2026 .expect("invoke");
2027 assert_eq!(out["denied"], true, "non-allowlisted redirect var: {out}");
2028 assert!(
2029 ran_programs(&mock).is_empty(),
2030 "no spawn on a denied redirect"
2031 );
2032 assert!(out["denials"][0]["reason"]
2033 .as_str()
2034 .unwrap_or_default()
2035 .contains("SECRET"));
2036 }
2037
2038 // ── glob + variable in one word (#46, $DIR/*.rs) ────────────────────────
2039
2040 /// The re-injection guard, unit-tested directly: a `*` in the VAR VALUE stays
2041 /// in the (literal) directory prefix and never globs; a variable in the glob
2042 /// BASENAME is refused.
2043 #[test]
2044 fn expand_varglob_keeps_value_metachars_literal_and_refuses_basename_var() {
2045 // TMPDIR is allowlisted; give it a value containing a glob metachar.
2046 let env = FakeEnv(HashMap::from([("TMPDIR".to_string(), "/a*b".to_string())]));
2047 let allow = agent_bridle_core::LimitsPolicy::default().var_allowlist;
2048 // `$TMPDIR/*.rs` → "/a*b/*.rs": the var's `*` is in the dir prefix
2049 // (literal); only the source `*.rs` basename globs.
2050 let pattern = expand_varglob(
2051 &[Seg::Var("TMPDIR".into()), Seg::Lit("/*.rs".into())],
2052 &env,
2053 &allow,
2054 )
2055 .unwrap();
2056 assert_eq!(pattern, "/a*b/*.rs");
2057 // A variable in the glob basename is refused (would re-inject metachars).
2058 let err = expand_varglob(
2059 &[Seg::Var("TMPDIR".into()), Seg::Lit("*.rs".into())],
2060 &env,
2061 &allow,
2062 );
2063 assert!(err.is_err(), "var in glob basename must be refused");
2064 }
2065
2066 /// `$DIR/*.rs` lowers the var (env seam) AND expands the glob at admission
2067 /// (per-directory fs_read leash), so the spawner receives the literal matches.
2068 #[tokio::test]
2069 async fn glob_var_expands_to_resolved_matches_before_spawn() {
2070 let mock = Arc::new(MockSpawner::default());
2071 let lister = map_lister(&[
2072 (".", vec![ent("proj", true)]),
2073 ("./proj", vec![ent("a.rs", false), ent("b.rs", false)]),
2074 ]);
2075 let tool = ShellTool::with_seams(mock.clone(), fake_env(&[("TMPDIR", "proj")]), lister);
2076 let out = tool
2077 .invoke(
2078 serde_json::json!({"cmd": "ls $TMPDIR/*.rs"}), // fs_read All by default
2079 &ctx(exec_only(&["ls"])),
2080 )
2081 .await
2082 .expect("invoke");
2083 assert_ne!(
2084 out["denied"],
2085 serde_json::json!(true),
2086 "in-scope glob var: {out}"
2087 );
2088 assert_eq!(
2089 calls(&mock)[0][0].argv,
2090 vec![
2091 Arg::Lit("ls".into()),
2092 Arg::Lit("proj/a.rs".into()),
2093 Arg::Lit("proj/b.rs".into())
2094 ]
2095 );
2096 }
2097
2098 /// A variable in the glob basename (`$PREFIX*.rs`) is refused at admission.
2099 #[tokio::test]
2100 async fn glob_var_in_basename_is_denied() {
2101 let mock = Arc::new(MockSpawner::default());
2102 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("PREFIX", "foo")]));
2103 let out = tool
2104 .invoke(
2105 serde_json::json!({"cmd": "ls $PREFIX*.rs"}),
2106 &ctx(exec_only(&["ls"])),
2107 )
2108 .await
2109 .expect("invoke");
2110 assert_eq!(out["denied"], true, "var in glob basename refused: {out}");
2111 assert!(ran_programs(&mock).is_empty());
2112 }
2113
2114 /// A non-allowlisted variable in a glob word denies before any spawn.
2115 #[tokio::test]
2116 async fn glob_var_not_in_allowlist_is_denied() {
2117 let mock = Arc::new(MockSpawner::default());
2118 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("SECRET", "/s")]));
2119 let out = tool
2120 .invoke(
2121 serde_json::json!({"cmd": "ls $SECRET/*.rs"}),
2122 &ctx(exec_only(&["ls"])),
2123 )
2124 .await
2125 .expect("invoke");
2126 assert_eq!(out["denied"], true, "non-allowlisted glob var: {out}");
2127 assert!(ran_programs(&mock).is_empty());
2128 }
2129
2130 /// The RESOLVED redirect path is leash-checked: an allowlisted var whose value
2131 /// lands outside `fs_write` scope denies (proving the leash sees the resolved
2132 /// path, not the literal `$VAR`).
2133 #[tokio::test]
2134 async fn redirect_var_resolved_path_out_of_fs_write_scope_denied() {
2135 let tmp = std::env::temp_dir().to_string_lossy().into_owned();
2136 let mock = Arc::new(MockSpawner::default());
2137 let tool = ShellTool::with_spawner_and_env(mock.clone(), fake_env(&[("TMPDIR", &tmp)]));
2138 let granted = Caveats {
2139 exec: Scope::only(["echo".to_string()]),
2140 fs_write: Scope::only(["/nonexistent-grant-root".to_string()]),
2141 ..Caveats::top()
2142 };
2143 let out = tool
2144 .invoke(
2145 serde_json::json!({"cmd": "echo hi > $TMPDIR/out"}),
2146 &ctx(granted),
2147 )
2148 .await
2149 .expect("invoke");
2150 assert_eq!(out["denied"], true, "resolved path outside fs_write: {out}");
2151 assert!(ran_programs(&mock).is_empty());
2152 }
2153
2154 // ── sequencing / leash (carried from earlier increments) ────────────────
2155
2156 #[tokio::test]
2157 async fn and_short_circuits_on_failure() {
2158 let mock = Arc::new(MockSpawner::with_exit("false", 1));
2159 ShellTool::with_spawner(mock.clone())
2160 .invoke(
2161 serde_json::json!({"cmd": "false && echo hi"}),
2162 &ctx(exec_only(&["false", "echo"])),
2163 )
2164 .await
2165 .expect("invoke");
2166 assert_eq!(ran_programs(&mock), vec!["false"], "echo must be skipped");
2167 }
2168
2169 #[tokio::test]
2170 async fn out_of_scope_anywhere_denies_the_whole_script() {
2171 let mock = Arc::new(MockSpawner::default());
2172 let out = ShellTool::with_spawner(mock.clone())
2173 .invoke(
2174 serde_json::json!({"cmd": "echo ok ; rm -rf x"}),
2175 &ctx(exec_only(&["echo"])),
2176 )
2177 .await
2178 .expect("invoke");
2179 assert_eq!(out["denied"], true);
2180 assert!(ran_programs(&mock).is_empty());
2181 }
2182
2183 // ── globbing (increment 5) ──────────────────────────────────────────────
2184
2185 /// A glob arg is EXPANDED at admission (with the per-directory fs_read leash)
2186 /// to its literal matches before the spawner runs (#47).
2187 #[tokio::test]
2188 async fn glob_arg_expanded_to_matches_before_spawn() {
2189 let mock = Arc::new(MockSpawner::default());
2190 let lister = map_lister(&[(
2191 ".",
2192 vec![ent("a.rs", false), ent("b.rs", false), ent("c.txt", false)],
2193 )]);
2194 ShellTool::with_seams(mock.clone(), fake_env(&[]), lister)
2195 .invoke(
2196 serde_json::json!({"cmd": "ls *.rs"}), // fs_read is All by default
2197 &ctx(exec_only(&["ls"])),
2198 )
2199 .await
2200 .expect("invoke");
2201 assert_eq!(
2202 calls(&mock)[0][0].argv,
2203 vec![
2204 Arg::Lit("ls".into()),
2205 Arg::Lit("a.rs".into()),
2206 Arg::Lit("b.rs".into())
2207 ]
2208 );
2209 }
2210
2211 /// A glob in the program position is refused (we never exec a pattern).
2212 #[tokio::test]
2213 async fn glob_as_program_name_denied() {
2214 let mock = Arc::new(MockSpawner::default());
2215 let out = ShellTool::with_spawner(mock.clone())
2216 .invoke(serde_json::json!({"cmd": "*.sh foo"}), &ctx(Caveats::top()))
2217 .await
2218 .expect("invoke");
2219 assert_eq!(out["denied"], true);
2220 assert!(ran_programs(&mock).is_empty());
2221 }
2222
2223 /// The directory a glob lists is an `fs_read`; out of scope ⇒ denied, no spawn.
2224 #[tokio::test]
2225 async fn glob_dir_out_of_fs_read_scope_denied() {
2226 let mock = Arc::new(MockSpawner::default());
2227 let granted = Caveats {
2228 exec: Scope::only(["echo".to_string()]),
2229 // fs_read restricted to the temp dir; the cwd glob lists elsewhere.
2230 fs_read: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2231 ..Caveats::top()
2232 };
2233 let out = ShellTool::with_spawner(mock.clone())
2234 .invoke(serde_json::json!({"cmd": "echo *"}), &ctx(granted))
2235 .await
2236 .expect("invoke");
2237 assert_eq!(out["denied"], true);
2238 assert_eq!(out["denials"][0]["kind"], "open");
2239 assert!(ran_programs(&mock).is_empty());
2240 }
2241
2242 // ── variable expansion / allowlist (increment 6) ────────────────────────
2243
2244 /// An allowlisted variable reaches the spawner as an (unexpanded) `Var`.
2245 #[tokio::test]
2246 async fn allowlisted_var_reaches_spawner() {
2247 let mock = Arc::new(MockSpawner::default());
2248 ShellTool::with_spawner(mock.clone())
2249 .invoke(
2250 serde_json::json!({"cmd": "echo $HOME"}),
2251 &ctx(exec_only(&["echo"])),
2252 )
2253 .await
2254 .expect("invoke");
2255 let c = calls(&mock);
2256 assert_eq!(
2257 c[0][0].argv,
2258 vec![
2259 Arg::Lit("echo".into()),
2260 Arg::Var(vec![Seg::Var("HOME".into())]),
2261 ]
2262 );
2263 }
2264
2265 /// A variable NOT on the allowlist is denied — the spawner is never called,
2266 /// so a secret like `$AWS_SECRET_KEY` can never be spliced into an argument.
2267 #[tokio::test]
2268 async fn non_allowlisted_var_denied() {
2269 let mock = Arc::new(MockSpawner::default());
2270 let out = ShellTool::with_spawner(mock.clone())
2271 .invoke(
2272 serde_json::json!({"cmd": "echo $AWS_SECRET_KEY"}),
2273 &ctx(Caveats::top()),
2274 )
2275 .await
2276 .expect("invoke");
2277 assert_eq!(out["denied"], true);
2278 assert_eq!(out["denials"][0]["target"], "$AWS_SECRET_KEY");
2279 assert!(ran_programs(&mock).is_empty());
2280 }
2281
2282 /// A variable in the program position is refused (we never exec a variable).
2283 #[tokio::test]
2284 async fn var_as_program_name_denied() {
2285 let mock = Arc::new(MockSpawner::default());
2286 let out = ShellTool::with_spawner(mock.clone())
2287 .invoke(
2288 serde_json::json!({"cmd": "$HOME foo"}),
2289 &ctx(Caveats::top()),
2290 )
2291 .await
2292 .expect("invoke");
2293 assert_eq!(out["denied"], true);
2294 assert!(ran_programs(&mock).is_empty());
2295 }
2296
2297 // ── stderr redirects / 2>&1 (issue #45) ─────────────────────────────────
2298
2299 /// A `2> file` target is leash-checked (`fs_write`) before any spawn.
2300 #[tokio::test]
2301 async fn stderr_to_file_out_of_scope_denied() {
2302 let mock = Arc::new(MockSpawner::default());
2303 let granted = Caveats {
2304 exec: Scope::only(["cmd".to_string()]),
2305 fs_write: Scope::only([std::env::temp_dir().to_string_lossy().into_owned()]),
2306 ..Caveats::top()
2307 };
2308 let out = ShellTool::with_spawner(mock.clone())
2309 .invoke(
2310 serde_json::json!({"cmd": "cmd 2> /etc/passwd"}),
2311 &ctx(granted),
2312 )
2313 .await
2314 .expect("invoke");
2315 assert_eq!(out["denied"], true);
2316 assert_eq!(out["denials"][0]["kind"], "open");
2317 assert_eq!(out["denials"][0]["target"], "/etc/passwd");
2318 assert!(ran_programs(&mock).is_empty());
2319 }
2320
2321 /// `2>&1` parses to a merge and reaches the spawner (no separate file open).
2322 #[tokio::test]
2323 async fn stderr_merge_reaches_spawner() {
2324 let mock = Arc::new(MockSpawner::default());
2325 ShellTool::with_spawner(mock.clone())
2326 .invoke(
2327 serde_json::json!({"cmd": "cmd 2>&1"}),
2328 &ctx(exec_only(&["cmd"])),
2329 )
2330 .await
2331 .expect("invoke");
2332 let c = calls(&mock);
2333 assert_eq!(c[0][0].stderr_disposition(), StderrTo::Stdout);
2334 }
2335
2336 #[tokio::test]
2337 async fn both_program_and_cmd_is_a_hard_error() {
2338 let res = ShellTool::new()
2339 .invoke(
2340 serde_json::json!({"program": "echo", "cmd": "echo hi"}),
2341 &ctx(Caveats::top()),
2342 )
2343 .await;
2344 assert!(res.is_err());
2345 }
2346
2347 #[tokio::test]
2348 async fn timeout_is_reported() {
2349 let mock = Arc::new(MockSpawner {
2350 block_ms: 1500,
2351 ..Default::default()
2352 });
2353 let out = ShellTool::with_spawner(mock)
2354 .invoke(
2355 serde_json::json!({"program": "anything", "timeout_secs": 1}),
2356 &ctx(exec_only(&["anything"])),
2357 )
2358 .await
2359 .expect("invoke");
2360 assert_eq!(out["timed_out"], true);
2361 }
2362
2363 // ── pure glob matching / expansion (no real fs) ─────────────────────────
2364
2365 #[test]
2366 fn fnmatch_basics() {
2367 assert!(fnmatch("*.rs", "a.rs"));
2368 assert!(!fnmatch("*.rs", "a.txt"));
2369 assert!(fnmatch("a?c", "abc"));
2370 assert!(!fnmatch("a?c", "ac"));
2371 assert!(fnmatch("*", ""));
2372 assert!(fnmatch("a*", "a"));
2373 assert!(fnmatch("[abc]x", "bx"));
2374 assert!(!fnmatch("[abc]x", "dx"));
2375 assert!(fnmatch("[!abc]x", "dx"));
2376 assert!(fnmatch("[a-c]", "b"));
2377 assert!(!fnmatch("[a-c]", "d"));
2378 assert!(fnmatch("foo*bar", "fooXYbar"));
2379 }
2380
2381 /// #73 regression: `read_capped` bounds peak buffering to the cap and flags
2382 /// truncation, without slurping the whole stream. The reader panics if asked
2383 /// for far more than the cap — which `read_to_end` (the old path) would do on
2384 /// an endless producer.
2385 #[test]
2386 fn read_capped_bounds_buffering_and_flags_truncation() {
2387 // The default output cap (LimitsPolicy::max_output_bytes == 1 MiB).
2388 const CAP: usize = 1 << 20;
2389 // An endless 'x' source that asserts it is never asked for more than the
2390 // cap plus a small probe/pipe slack.
2391 struct Endless {
2392 served: usize,
2393 }
2394 impl Read for Endless {
2395 fn read(&mut self, b: &mut [u8]) -> std::io::Result<usize> {
2396 self.served = self.served.saturating_add(b.len());
2397 assert!(
2398 self.served <= CAP + 64 * 1024,
2399 "read_capped over-read {} bytes (cap {CAP})",
2400 self.served
2401 );
2402 b.fill(b'x');
2403 Ok(b.len())
2404 }
2405 }
2406 let (buf, truncated) = read_capped(Endless { served: 0 }, CAP);
2407 assert_eq!(buf.len(), CAP, "peak buffering bounded by the cap");
2408 assert!(
2409 truncated,
2410 "a source longer than the cap is flagged truncated"
2411 );
2412
2413 // A short source is captured whole and NOT flagged.
2414 let (buf2, trunc2) = read_capped(&b"hello"[..], CAP);
2415 assert_eq!(buf2, b"hello");
2416 assert!(!trunc2, "a sub-cap source is not truncated");
2417 }
2418
2419 #[test]
2420 fn glob_walk_single_segment_and_subpath() {
2421 let lister = map_lister(&[
2422 (
2423 ".",
2424 vec![
2425 ent("a.rs", false),
2426 ent("b.rs", false),
2427 ent("c.txt", false),
2428 ent(".hidden.rs", false),
2429 ent("src", true),
2430 ],
2431 ),
2432 ("./src", vec![ent("a.rs", false), ent("b.rs", false)]),
2433 ]);
2434 let mut allow = |_d: &Path| Ok(());
2435 // *.rs matches the two .rs files (sorted), hidden excluded.
2436 assert_eq!(
2437 expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2438 vec!["a.rs", "b.rs"]
2439 );
2440 // No match → the literal pattern (nullglob off).
2441 assert_eq!(
2442 expand_glob_walk("zzz*", None, &*lister, &mut allow, 64, 4096).unwrap(),
2443 vec!["zzz*"]
2444 );
2445 // Sub-path keeps the directory prefix on each match.
2446 assert_eq!(
2447 expand_glob_walk("src/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2448 vec!["src/a.rs", "src/b.rs"]
2449 );
2450 }
2451
2452 #[test]
2453 fn glob_walk_multi_segment_and_recursive() {
2454 let lister = map_lister(&[
2455 (
2456 ".",
2457 vec![ent("a", true), ent("b", true), ent("x.rs", false)],
2458 ),
2459 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
2460 ("./b", vec![ent("bar.rs", false)]),
2461 ("./a/sub", vec![ent("deep.rs", false)]),
2462 ]);
2463 let mut allow = |_d: &Path| Ok(());
2464 // Multi-segment: `*/foo.rs` matches only where foo.rs exists.
2465 assert_eq!(
2466 expand_glob_walk("*/foo.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2467 vec!["a/foo.rs"]
2468 );
2469 // Recursive `**`: `*.rs` at every level (cwd + all subdirs).
2470 assert_eq!(
2471 expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 64, 4096).unwrap(),
2472 vec!["a/foo.rs", "a/sub/deep.rs", "b/bar.rs", "x.rs"]
2473 );
2474 }
2475
2476 #[test]
2477 fn glob_walk_leashes_every_directory_and_denies_out_of_scope() {
2478 let lister = map_lister(&[
2479 (".", vec![ent("a", true), ent("x.rs", false)]),
2480 ("./a", vec![ent("secret.rs", false)]),
2481 ]);
2482 // A leash that refuses to read `./a` denies the whole recursive walk
2483 // (every directory the walk lists is fs_read-checked before listing).
2484 let mut deny_a = |d: &Path| {
2485 if d.to_string_lossy().contains("a") {
2486 Err(ToolError::denied("out of fs_read scope"))
2487 } else {
2488 Ok(())
2489 }
2490 };
2491 assert!(expand_glob_walk("**/*.rs", None, &*lister, &mut deny_a, 64, 4096).is_err());
2492 }
2493
2494 /// #143: the total-match cap is config-driven, not a hard-coded const — a
2495 /// `max_matches` of 2 truncates a 4-match single-segment glob.
2496 #[test]
2497 fn glob_walk_respects_configured_match_cap() {
2498 let lister = map_lister(&[(
2499 ".",
2500 vec![
2501 ent("a.rs", false),
2502 ent("b.rs", false),
2503 ent("c.rs", false),
2504 ent("d.rs", false),
2505 ],
2506 )]);
2507 let mut allow = |_d: &Path| Ok(());
2508 let got = expand_glob_walk("*.rs", None, &*lister, &mut allow, 64, 2).unwrap();
2509 assert_eq!(got.len(), 2, "match cap of 2 must bound the result set");
2510 }
2511
2512 /// #143: the `**` recursion-depth cap is config-driven — a `max_depth` of 1
2513 /// descends a single level and never reaches the deeper `sub` directory.
2514 #[test]
2515 fn glob_walk_respects_configured_depth_cap() {
2516 let lister = map_lister(&[
2517 (".", vec![ent("a", true), ent("x.rs", false)]),
2518 ("./a", vec![ent("foo.rs", false), ent("sub", true)]),
2519 ("./a/sub", vec![ent("deep.rs", false)]),
2520 ]);
2521 let mut allow = |_d: &Path| Ok(());
2522 // depth 1: cwd + one level of dirs; `a/sub/deep.rs` is out of reach.
2523 let got = expand_glob_walk("**/*.rs", None, &*lister, &mut allow, 1, 4096).unwrap();
2524 assert!(
2525 !got.iter().any(|m| m.contains("deep.rs")),
2526 "depth cap of 1 must not reach a/sub/deep.rs; got {got:?}"
2527 );
2528 }
2529
2530 /// #143: the variable allowlist is config-driven — a name absent from the
2531 /// default set is expandable when configured, and a default name is denied
2532 /// when configured out. Proves `is_allowed_var` reads the passed allowlist.
2533 #[test]
2534 fn var_allowlist_is_config_driven() {
2535 // A custom var (not in the default set) is allowed when configured.
2536 let allow_custom = vec!["MY_CUSTOM_VAR".to_string()];
2537 let env = FakeEnv(HashMap::from([(
2538 "MY_CUSTOM_VAR".to_string(),
2539 "/data".to_string(),
2540 )]));
2541 let out = expand_redirect_target(&[Seg::Var("MY_CUSTOM_VAR".into())], &env, &allow_custom)
2542 .unwrap();
2543 assert_eq!(out, "/data");
2544 // A default-allowlisted name (HOME) is denied when configured out.
2545 assert!(!is_allowed_var("HOME", &["PWD".to_string()]));
2546 assert!(is_allowed_var("PWD", &["PWD".to_string()]));
2547 }
2548
2549 /// #145 (I6): the egress audit sink is built from the configured path
2550 /// (`LimitsPolicy::audit_sink`), not a direct `BRIDLE_NET_AUDIT` env read.
2551 /// `None` ⇒ the null sink (no file); `Some(path)` ⇒ a JSONL sink writing to
2552 /// exactly that path. Would fail on the old env-only path.
2553 #[test]
2554 fn net_audit_sink_is_config_driven() {
2555 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
2556 let ev = NetAuditEvent {
2557 ts_ms: 0,
2558 host: "example.test".to_string(),
2559 port: 443,
2560 kind: NetKind::Connect,
2561 decision: NetDecision::Allowed,
2562 bytes_up: 1,
2563 bytes_down: 2,
2564 dur_ms: 3,
2565 };
2566 // None → null sink: records silently, no file.
2567 net_audit_sink(None).record(&ev);
2568
2569 // Some(path) → JSONL sink appends the event to that exact path.
2570 let path = std::env::temp_dir().join(format!("ab-audit-{}.jsonl", std::process::id()));
2571 let _ = std::fs::remove_file(&path);
2572 let sink = net_audit_sink(path.to_str());
2573 sink.record(&ev);
2574 drop(sink);
2575 let contents = std::fs::read_to_string(&path).expect("configured audit file written");
2576 assert!(
2577 contents.contains("example.test"),
2578 "the configured sink must write the event: {contents}"
2579 );
2580 let _ = std::fs::remove_file(&path);
2581 }
2582
2583 /// #138 (audit robustness): a *bad* audit path must degrade to the null sink so
2584 /// the run continues — a broken audit config can never break confinement. The
2585 /// sink records without panic and no file is created at the unopenable path.
2586 #[test]
2587 fn net_audit_sink_bad_path_degrades_to_null() {
2588 use crate::net_proxy::{NetAuditEvent, NetDecision, NetKind};
2589 let ev = NetAuditEvent {
2590 ts_ms: 0,
2591 host: "example.test".to_string(),
2592 port: 443,
2593 kind: NetKind::Http,
2594 decision: NetDecision::Allowed,
2595 bytes_up: 1,
2596 bytes_down: 2,
2597 dur_ms: 3,
2598 };
2599 // A path under a nonexistent directory can't be created → NullSink fallback.
2600 let bad = std::env::temp_dir()
2601 .join(format!("ab-nope-{}", std::process::id()))
2602 .join("does/not/exist/audit.jsonl");
2603 let sink = net_audit_sink(bad.to_str());
2604 sink.record(&ev); // must not panic
2605 assert!(
2606 !bad.exists(),
2607 "a bad audit path must not create a file (degraded to null): {bad:?}"
2608 );
2609 }
2610}