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