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