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