Skip to main content

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}