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