Skip to main content

agent_bridle_tool_shell/
shell_tool.rs

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