Skip to main content

agent_bridle_core/
spawn.rs

1//! Spawn an **arbitrary** child process confined by a [`ToolContext`]'s caveats.
2//!
3//! The in-process leash (L2) gates operations the bridle process can observe.
4//! But a host often needs to launch a separate program — an MCP capability
5//! server, a language runtime — and put *its own syscalls* under the leash. L2
6//! cannot follow a child across a process boundary; that requires an available
7//! native L3 backend ([`crate::sandbox`]).
8//!
9//! [`ConfinedCommand`] is that primitive. It is deliberately *not* a confused
10//! deputy: the parent attenuates **before** the spawn (the child is never trusted
11//! to confine itself), the environment is **cleared** so nothing ambient leaks
12//! (only explicitly-granted vars reach the child — the external-boundary
13//! invariant), and exec is admission-checked against the granted `exec` scope.
14//!
15//! Mechanism (mirrors [`crate::sandbox`]'s contract): thread-confining backends
16//! such as Landlock are applied on a fresh throwaway thread immediately before
17//! spawn; wrapper backends such as Seatbelt and AppContainer prefix the child
18//! launch. In either case the confined child and its descendants inherit the
19//! active OS boundary.
20//!
21//! Honesty & fail-closed: the achieved [`SandboxKind`] is returned on the
22//! [`ConfinedChild`]. A restricted filesystem axis that no active backend can
23//! kernel-enforce is **refused** rather than launched unconfined. Restricted
24//! `exec` and `net` axes are checked against the principal's requested strength
25//! floor because their kernel coverage differs by backend and scope. The
26//! per-axis enforcement report is the authoritative statement of what held.
27
28use std::ffi::{OsStr, OsString};
29#[cfg(any(target_os = "linux", target_os = "macos"))]
30use std::io::{Read, Write};
31use std::path::{Path, PathBuf};
32use std::process::{Child, Command, Stdio};
33
34use std::sync::Arc;
35use std::time::Duration;
36
37use crate::{
38    best_available_sandbox, effective_sandbox_kind, enforcement_report, fence_strength,
39    AxisEnforcement, Caveats, SandboxKind, SandboxPolicy, ToolContext, ToolError, ToolResult,
40};
41use agent_mesh_protocol::Fingerprint;
42use serde::de::DeserializeOwned;
43use serde::{Deserialize, Serialize};
44// Used only by the test modules below (each `use super::*`); kept here so all
45// three (`tests`, `landlock_child_tests`, `seatbelt_child_tests`) see it without
46// an unused-import warning in the non-test build.
47#[cfg(test)]
48use crate::Scope;
49
50/// A spawned child together with the OS sandbox actually in force around it.
51///
52/// The caller owns `child` (it does its own `wait`/`kill`/pipe plumbing).
53/// `sandbox_kind` is the honest record of what confinement was achieved —
54/// [`SandboxKind::None`] means the leash on this child is advisory only.
55#[derive(Debug)]
56pub struct ConfinedChild {
57    /// The spawned process.
58    pub child: Child,
59    /// The OS-level sandbox actually applied to the child.
60    pub sandbox_kind: SandboxKind,
61}
62
63/// A fixed internal worker together with its take-once parent control channel.
64///
65/// Unlike an ordinary [`ConfinedChild`], a trusted worker is launched with a
66/// kernel object that model-selected commands do not receive. The worker
67/// validates that channel and its peer before accepting any authority-bearing
68/// request. The channel is private by default and can be taken only once by the
69/// trusted supervisor.
70#[derive(Debug)]
71pub struct SandboxedWorkerChild {
72    /// The spawned worker process.
73    pub child: Child,
74    /// The OS-level sandbox actually applied to the worker.
75    pub sandbox_kind: SandboxKind,
76    control: Option<TrustedWorkerControl>,
77}
78
79impl SandboxedWorkerChild {
80    /// Authenticate the fixed worker and send one authority-bearing request.
81    ///
82    /// Core—not the caller—serializes the effective caveats, strength floor,
83    /// and launch nonce captured by [`SandboxedWorker::spawn`]. `payload`
84    /// contains only tool-specific, non-authority fields. The control endpoint
85    /// is consumed and closed after this frame, so a launch can authorize at
86    /// most one request.
87    pub fn send_payload<T: Serialize>(&mut self, payload: &T, timeout: Duration) -> ToolResult<()> {
88        let mut control = self
89            .control
90            .take()
91            .ok_or_else(|| ToolError::denied("trusted worker request was already sent"))?;
92        control.send(payload, self.child.id(), timeout)
93    }
94}
95
96/// The supervisor-owned end of a trusted worker's private control channel.
97///
98/// The stream and authority are intentionally private. Callers can only send a
99/// non-authority payload through [`SandboxedWorkerChild::send_payload`].
100#[derive(Debug)]
101struct TrustedWorkerControl {
102    #[cfg(any(target_os = "linux", target_os = "macos"))]
103    stream: std::os::unix::net::UnixStream,
104    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
105    unavailable: (),
106    nonce: String,
107    caveats: Caveats,
108    strength_floor: AxisEnforcement,
109}
110
111impl TrustedWorkerControl {
112    #[cfg(any(target_os = "linux", target_os = "macos"))]
113    fn send<T: Serialize>(
114        &mut self,
115        payload: &T,
116        child_pid: u32,
117        timeout: Duration,
118    ) -> ToolResult<()> {
119        self.stream
120            .set_read_timeout(Some(timeout))
121            .map_err(ToolError::from)?;
122        self.stream
123            .set_write_timeout(Some(timeout))
124            .map_err(ToolError::from)?;
125
126        // Establish kernel peer metadata before the worker snapshots its
127        // parent. On macOS LOCAL_PEERTOKEN is populated only after a write
128        // from that peer; this fixed prelude carries no authority.
129        self.stream
130            .write_all(&TRUSTED_WORKER_BOOTSTRAP)
131            .and_then(|()| self.stream.flush())
132            .map_err(ToolError::from)?;
133        let mut hello = [0_u8; TRUSTED_WORKER_HELLO_LEN];
134        self.stream
135            .read_exact(&mut hello)
136            .map_err(ToolError::from)?;
137        let (reported_pid, challenge) = decode_trusted_worker_hello(&hello)
138            .map_err(|error| ToolError::denied(format!("invalid worker hello: {error}")))?;
139        if reported_pid != child_pid {
140            return Err(ToolError::denied(format!(
141                "worker hello PID mismatch: spawned {child_pid}, reported {reported_pid}"
142            )));
143        }
144
145        let request = TrustedWorkerRequest {
146            version: TRUSTED_WORKER_PROTOCOL_VERSION,
147            nonce: self.nonce.clone(),
148            caveats: self.caveats.clone(),
149            strength_floor: self.strength_floor,
150            payload,
151        };
152        let body = serde_json::to_vec(&request)
153            .map_err(|error| ToolError::denied(format!("encode worker request: {error}")))?;
154        if body.len() > TRUSTED_WORKER_MAX_BODY {
155            return Err(ToolError::denied(
156                "trusted worker request exceeds its 1 MiB cap",
157            ));
158        }
159        let header = encode_trusted_worker_frame_header(
160            challenge,
161            trusted_worker_frame_digest(&challenge, &body),
162            body.len(),
163        )
164        .map_err(ToolError::denied)?;
165        self.stream.write_all(&header).map_err(ToolError::from)?;
166        self.stream.write_all(&body).map_err(ToolError::from)?;
167        self.stream.flush().map_err(ToolError::from)?;
168        let mut ack = [0_u8; TRUSTED_WORKER_ACK.len()];
169        self.stream.read_exact(&mut ack).map_err(ToolError::from)?;
170        if ack != TRUSTED_WORKER_ACK {
171            return Err(ToolError::denied(
172                "trusted worker returned an invalid authentication ACK",
173            ));
174        }
175        // The control object is consumed immediately after this method. The
176        // worker may close its endpoint as soon as it writes the ACK, so a
177        // racing ENOTCONN here is not an authentication failure.
178        let _ = self.stream.shutdown(std::net::Shutdown::Write);
179        Ok(())
180    }
181
182    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
183    fn send<T: Serialize>(
184        &mut self,
185        payload: &T,
186        child_pid: u32,
187        timeout: Duration,
188    ) -> ToolResult<()> {
189        let _ = (
190            &self.unavailable,
191            &self.nonce,
192            &self.caveats,
193            self.strength_floor,
194            payload,
195            child_pid,
196            timeout,
197        );
198        Err(ToolError::denied(
199            "trusted worker control channels are unavailable on this platform",
200        ))
201    }
202}
203
204/// Version of the private trusted-worker authority envelope.
205pub const TRUSTED_WORKER_PROTOCOL_VERSION: u8 = 1;
206/// Maximum serialized trusted-worker request body.
207pub const TRUSTED_WORKER_MAX_BODY: usize = 1024 * 1024;
208/// Fixed, non-authority prelude used to establish kernel peer metadata.
209pub const TRUSTED_WORKER_BOOTSTRAP: [u8; 8] = *b"ABTW-B1\0";
210/// Fixed acknowledgement emitted only after a worker authenticates its frame.
211pub const TRUSTED_WORKER_ACK: [u8; 8] = *b"ABTW-A1\0";
212const TRUSTED_WORKER_HELLO_MAGIC: [u8; 8] = *b"ABTW-H1\0";
213const TRUSTED_WORKER_FRAME_MAGIC: [u8; 8] = *b"ABTW-R1\0";
214/// Exact byte length of a trusted-worker hello frame.
215pub const TRUSTED_WORKER_HELLO_LEN: usize = 8 + 4 + 32;
216/// Exact byte length of a trusted-worker response header.
217pub const TRUSTED_WORKER_FRAME_HEADER_LEN: usize = 8 + 4 + 32 + 32;
218const TRUSTED_WORKER_DIGEST_DOMAIN: &[u8] = b"agent-bridle/trusted-worker-frame/v1";
219
220/// Core-owned authority envelope received by a fixed trusted worker.
221///
222/// `P` is tool-specific data only. Authority fields are captured from the
223/// supervisor's minted [`ToolContext`] and cannot be supplied through
224/// [`SandboxedWorkerChild::send_payload`].
225#[derive(Debug, Serialize, Deserialize)]
226pub struct TrustedWorkerRequest<P> {
227    version: u8,
228    nonce: String,
229    caveats: Caveats,
230    strength_floor: AxisEnforcement,
231    payload: P,
232}
233
234impl<P> TrustedWorkerRequest<P> {
235    /// Consume the envelope into its core-authenticated authority and payload.
236    #[must_use]
237    pub fn into_parts(self) -> (String, Caveats, AxisEnforcement, P) {
238        (self.nonce, self.caveats, self.strength_floor, self.payload)
239    }
240
241    /// Whether the envelope uses the protocol version understood by this core.
242    #[must_use]
243    pub fn has_supported_version(&self) -> bool {
244        self.version == TRUSTED_WORKER_PROTOCOL_VERSION
245    }
246}
247
248/// Encode the child-to-supervisor hello that carries a fresh challenge.
249#[must_use]
250pub fn encode_trusted_worker_hello(child_pid: u32, challenge: [u8; 32]) -> [u8; 44] {
251    let mut frame = [0_u8; TRUSTED_WORKER_HELLO_LEN];
252    frame[..8].copy_from_slice(&TRUSTED_WORKER_HELLO_MAGIC);
253    frame[8..12].copy_from_slice(&child_pid.to_le_bytes());
254    frame[12..].copy_from_slice(&challenge);
255    frame
256}
257
258/// Decode and validate a child-to-supervisor hello.
259pub fn decode_trusted_worker_hello(frame: &[u8]) -> Result<(u32, [u8; 32]), String> {
260    if frame.len() != TRUSTED_WORKER_HELLO_LEN || frame[..8] != TRUSTED_WORKER_HELLO_MAGIC {
261        return Err("bad trusted-worker hello framing".to_string());
262    }
263    let pid = u32::from_le_bytes(
264        frame[8..12]
265            .try_into()
266            .map_err(|_| "bad trusted-worker PID field")?,
267    );
268    let challenge = frame[12..]
269        .try_into()
270        .map_err(|_| "bad trusted-worker challenge field")?;
271    Ok((pid, challenge))
272}
273
274/// Encode a supervisor-to-worker header binding challenge, body length, and
275/// content digest.
276pub fn encode_trusted_worker_frame_header(
277    challenge: [u8; 32],
278    digest: [u8; 32],
279    body_len: usize,
280) -> Result<[u8; TRUSTED_WORKER_FRAME_HEADER_LEN], String> {
281    let body_len = u32::try_from(body_len)
282        .map_err(|_| "trusted-worker request length does not fit its frame".to_string())?;
283    let mut frame = [0_u8; TRUSTED_WORKER_FRAME_HEADER_LEN];
284    frame[..8].copy_from_slice(&TRUSTED_WORKER_FRAME_MAGIC);
285    frame[8..12].copy_from_slice(&body_len.to_le_bytes());
286    frame[12..44].copy_from_slice(&challenge);
287    frame[44..].copy_from_slice(&digest);
288    Ok(frame)
289}
290
291/// Decode a supervisor-to-worker frame header.
292pub fn decode_trusted_worker_frame_header(
293    frame: &[u8],
294) -> Result<(usize, [u8; 32], [u8; 32]), String> {
295    if frame.len() != TRUSTED_WORKER_FRAME_HEADER_LEN || frame[..8] != TRUSTED_WORKER_FRAME_MAGIC {
296        return Err("bad trusted-worker response framing".to_string());
297    }
298    let body_len = u32::from_le_bytes(
299        frame[8..12]
300            .try_into()
301            .map_err(|_| "bad trusted-worker length field")?,
302    ) as usize;
303    if body_len > TRUSTED_WORKER_MAX_BODY {
304        return Err("trusted-worker request exceeds its 1 MiB cap".to_string());
305    }
306    let challenge = frame[12..44]
307        .try_into()
308        .map_err(|_| "bad trusted-worker challenge field")?;
309    let digest = frame[44..]
310        .try_into()
311        .map_err(|_| "bad trusted-worker digest field")?;
312    Ok((body_len, challenge, digest))
313}
314
315/// Content digest binding one trusted-worker request to its fresh challenge.
316#[must_use]
317pub fn trusted_worker_frame_digest(challenge: &[u8; 32], body: &[u8]) -> [u8; 32] {
318    let mut framed =
319        Vec::with_capacity(TRUSTED_WORKER_DIGEST_DOMAIN.len() + challenge.len() + body.len());
320    framed.extend_from_slice(TRUSTED_WORKER_DIGEST_DOMAIN);
321    framed.extend_from_slice(challenge);
322    framed.extend_from_slice(body);
323    Fingerprint::of_bytes(&framed).0
324}
325
326/// Deserialize a verified trusted-worker request body.
327pub fn decode_trusted_worker_request<P: DeserializeOwned>(
328    body: &[u8],
329) -> Result<TrustedWorkerRequest<P>, String> {
330    serde_json::from_slice(body).map_err(|error| format!("invalid trusted-worker request: {error}"))
331}
332
333/// Builder for a subprocess confined by a [`ToolContext`].
334///
335/// Like [`std::process::Command`], but: the environment starts **empty** (only
336/// vars added with [`ConfinedCommand::env`] reach the child), and
337/// [`ConfinedCommand::spawn`] admission-checks `exec`, applies the OS sandbox,
338/// and fails closed when a restricted axis cannot meet its required
339/// enforcement floor.
340#[derive(Debug)]
341pub struct ConfinedCommand {
342    program: String,
343    args: Vec<OsString>,
344    envs: Vec<(OsString, OsString)>,
345    cwd: Option<PathBuf>,
346    stdin: Option<Stdio>,
347    stdout: Option<Stdio>,
348    stderr: Option<Stdio>,
349    /// Put the child in a fresh process group so a supervising caller can
350    /// terminate the complete descendant tree at a timeout boundary.
351    new_process_group: bool,
352    /// The sandbox mechanism config (read/exec allow-lists). Rides the builder —
353    /// NOT the `ToolContext`, which carries only authority (I5-B, #144, ADR 0017
354    /// D2). Defaults to today's built-in allow-lists.
355    sandbox_policy: Arc<SandboxPolicy>,
356    #[cfg(all(unix, feature = "spawn-tokio"))]
357    private_hosts: std::collections::HashSet<String>,
358}
359
360impl ConfinedCommand {
361    /// Start building a confined spawn of `program` (no inherited environment).
362    pub fn new(program: impl Into<String>) -> Self {
363        Self {
364            program: program.into(),
365            args: Vec::new(),
366            envs: Vec::new(),
367            cwd: None,
368            stdin: None,
369            stdout: None,
370            stderr: None,
371            new_process_group: false,
372            sandbox_policy: Arc::new(SandboxPolicy::default()),
373            #[cfg(all(unix, feature = "spawn-tokio"))]
374            private_hosts: std::collections::HashSet::new(),
375        }
376    }
377
378    /// Set the sandbox mechanism policy (read/exec allow-lists, ABI floors) the
379    /// OS backend will enforce. The default is today's built-in allow-lists.
380    #[must_use]
381    pub fn sandbox_policy(mut self, policy: Arc<SandboxPolicy>) -> Self {
382        self.sandbox_policy = policy;
383        self
384    }
385
386    /// Append a single argument.
387    #[must_use]
388    pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
389        self.args.push(arg.as_ref().to_os_string());
390        self
391    }
392
393    /// Append several arguments.
394    #[must_use]
395    pub fn args<I, S>(mut self, args: I) -> Self
396    where
397        I: IntoIterator<Item = S>,
398        S: AsRef<OsStr>,
399    {
400        self.args
401            .extend(args.into_iter().map(|a| a.as_ref().to_os_string()));
402        self
403    }
404
405    /// Grant one environment variable to the child. This is the **only** way an
406    /// env var reaches the child — there is no ambient inheritance.
407    #[must_use]
408    pub fn env(mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> Self {
409        self.envs
410            .push((key.as_ref().to_os_string(), val.as_ref().to_os_string()));
411        self
412    }
413
414    /// Set the child's working directory.
415    #[must_use]
416    pub fn current_dir(mut self, dir: impl AsRef<Path>) -> Self {
417        self.cwd = Some(dir.as_ref().to_path_buf());
418        self
419    }
420
421    /// Configure the child's stdin (e.g. [`Stdio::piped`] for an MCP server).
422    #[must_use]
423    pub fn stdin(mut self, cfg: Stdio) -> Self {
424        self.stdin = Some(cfg);
425        self
426    }
427
428    /// Configure the child's stdout.
429    #[must_use]
430    pub fn stdout(mut self, cfg: Stdio) -> Self {
431        self.stdout = Some(cfg);
432        self
433    }
434
435    /// Configure the child's stderr.
436    #[must_use]
437    pub fn stderr(mut self, cfg: Stdio) -> Self {
438        self.stderr = Some(cfg);
439        self
440    }
441
442    /// Start the child as leader of a fresh process group.
443    ///
444    /// This is used by trusted worker supervisors that must terminate the
445    /// worker and every descendant together. It is currently effective on
446    /// Unix; other platforms retain their native child-process behavior.
447    #[must_use]
448    pub fn new_process_group(mut self) -> Self {
449        self.new_process_group = true;
450        self
451    }
452
453    /// Admission-check, confine, and spawn the child.
454    ///
455    /// Order: (1) `cx.check_exec(program)` — deny before doing anything; (2)
456    /// derive the backend's per-axis enforcement and refuse if a restricted
457    /// axis cannot meet its floor; (3) apply the selected thread- or
458    /// wrapper-based sandbox, then spawn inside that boundary.
459    pub fn spawn(self, cx: &ToolContext) -> ToolResult<ConfinedChild> {
460        let effective = cx.caveats().clone();
461        self.spawn_with_effective(cx, effective)
462    }
463
464    /// The spawn body, parameterized over the **effective** caveats the OS
465    /// sandbox confines to (#257). `spawn` passes the context's caveats
466    /// verbatim; the egress-proxy path (`spawn_tokio` under a proxied-net
467    /// grant) passes [`crate::loopback_fenced_caveats`] — same fs/exec axes,
468    /// `net` swapped for the loopback fence. The exec admission-check always
469    /// runs against the REAL context (the fence never widens or narrows exec).
470    fn spawn_with_effective(
471        self,
472        cx: &ToolContext,
473        effective: Caveats,
474    ) -> ToolResult<ConfinedChild> {
475        self.spawn_authorized(cx, effective, SpawnAuthority::ModelSelected)
476    }
477
478    /// Shared spawn funnel for model-selected programs and fixed internal
479    /// workers. The latter skips only the model-facing executable admission
480    /// check; its executable and entrypoint are fixed by [`SandboxedWorker`].
481    fn spawn_authorized(
482        self,
483        cx: &ToolContext,
484        effective: Caveats,
485        authority: SpawnAuthority,
486    ) -> ToolResult<ConfinedChild> {
487        // (1) Admission: model-selected programs must be in the exec grant.
488        // A trusted worker transition is not model-selected; its fixed program
489        // is added only to the mechanism policy below.
490        if authority == SpawnAuthority::ModelSelected {
491            cx.check_exec(&self.program)?;
492        }
493
494        let sandbox = best_available_sandbox(&self.sandbox_policy);
495        let kind = sandbox.kind();
496        // The kind that actually GOVERNS this spawn: the backend's kind only when
497        // it will actually confine something (fs or net restricted), else `None`.
498        // The fail-closed
499        // check is decided against THIS, not the raw probe, so the check and the
500        // routing cannot disagree (the adversarial-review fix: a raw
501        // `enforcement_report` claim of fs→Kernel for a backend that is not
502        // actually applied would otherwise pass a run the path executes
503        // unconfined). Also the honest kind reported on the child (I9 / ADR 0006 D3).
504        let reported_kind = effective_sandbox_kind(kind, &effective);
505
506        // (2) Fail closed: a restricted axis the governing backend cannot enforce
507        // at the principal's strength floor is a grant we'd be lying about.
508        if confinement_unenforceable(reported_kind, &effective, cx.strength_floor()) {
509            return Err(ToolError::denied(format!(
510                "refusing to spawn {:?}: a restricted filesystem/exec/net axis cannot be \
511                 enforced on a subprocess at the required strength floor ({:?}) by the \
512                 governing sandbox ({:?})",
513                self.program,
514                cx.strength_floor(),
515                reported_kind
516            )));
517        }
518
519        // For a wrapper-based backend (Seatbelt/AppContainer) this is the argv
520        // prefix that confines the child; empty for thread-confining backends
521        // (Landlock, via `apply`) and Noop. Computed here so a fail-closed wrapper
522        // error aborts *before* we spawn the thread.
523        // A fixed worker executable is an internal transition, not authority
524        // delegated to the model. Allowlist-based kernel exec policies need its
525        // exact path to launch it; AppContainer must instead preserve exec
526        // deny-all so its launcher applies the child-process block. Neither
527        // changes the reported/effective authority.
528        let mechanism_effective = if authority == SpawnAuthority::TrustedWorker {
529            trusted_worker_mechanism_caveats(kind, &effective, &self.program)?
530        } else {
531            effective.clone()
532        };
533        let prefix = sandbox.command_prefix(&mechanism_effective)?;
534
535        // (3) Apply the sandbox on a throwaway thread, then spawn on it so the
536        //     child inherits the OS confinement — the per-thread, fork/exec-
537        //     inherited Landlock domain or the selected process wrapper.
538        let Self {
539            program,
540            args,
541            envs,
542            cwd,
543            stdin,
544            stdout,
545            stderr,
546            new_process_group,
547            // Already consumed above into `sandbox` via `best_available_sandbox`.
548            sandbox_policy: _,
549            #[cfg(all(unix, feature = "spawn-tokio"))]
550                private_hosts: _,
551        } = self;
552
553        let spawned = std::thread::spawn(move || -> ToolResult<Child> {
554            // Thread-confining backends (Landlock): apply the sandbox on this
555            // throwaway thread before the spawn so the child inherits the
556            // Landlock domain. `apply` is fail-closed: if the kernel did not
557            // actually enforce, it returns Err and we never spawn.
558            //
559            // Wrapper-based backends (Seatbelt, AppContainer): confinement is
560            // achieved by the `command_prefix` wrapper — no per-thread state is
561            // involved, and calling `apply` would be wrong (AppContainer fails
562            // closed; Seatbelt is a no-op). Skip `apply` when the prefix is
563            // non-empty.
564            if prefix.is_empty() {
565                sandbox.apply(&mechanism_effective)?;
566            }
567
568            // Wrap the child in the backend's command prefix when it confines via
569            // a wrapper (Seatbelt, AppContainer); otherwise spawn the program directly.
570            let (spawn_program, spawn_args) = wrap_argv(&prefix, &program, &args);
571            let mut cmd = Command::new(&spawn_program);
572            cmd.args(&spawn_args);
573            cmd.env_clear(); // no ambient environment crosses the boundary …
574            for (k, v) in &envs {
575                cmd.env(k, v); // … only the explicitly-granted vars.
576            }
577            if let Some(dir) = &cwd {
578                cmd.current_dir(dir);
579            }
580            if let Some(cfg) = stdin {
581                cmd.stdin(cfg);
582            }
583            if let Some(cfg) = stdout {
584                cmd.stdout(cfg);
585            }
586            if let Some(cfg) = stderr {
587                cmd.stderr(cfg);
588            }
589            #[cfg(unix)]
590            if new_process_group {
591                use std::os::unix::process::CommandExt;
592                cmd.process_group(0);
593            }
594            #[cfg(not(unix))]
595            let _ = new_process_group;
596            cmd.spawn().map_err(ToolError::from)
597        })
598        .join()
599        .map_err(|_| ToolError::denied("confined-spawn thread panicked before exec"))?;
600
601        Ok(ConfinedChild {
602            child: spawned?,
603            sandbox_kind: reported_kind,
604        })
605    }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609enum SpawnAuthority {
610    ModelSelected,
611    TrustedWorker,
612}
613
614/// A closed set of internal worker entrypoints. The caller cannot supply
615/// arbitrary arguments: each kind maps to a fixed private protocol.
616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617pub enum TrustedWorkerKind {
618    /// The carried Brush shell worker.
619    Brush,
620}
621
622impl TrustedWorkerKind {
623    #[cfg(any(target_os = "linux", target_os = "macos"))]
624    fn args(self) -> [&'static str; 2] {
625        match self {
626            Self::Brush => ["--agent-bridle-worker", "brush"],
627        }
628    }
629}
630
631/// Builder for a fixed Agent Bridle worker born under the ordinary confinement
632/// funnel. Unlike [`ConfinedCommand`], the executable is mechanism
633/// configuration chosen by the trusted embedder and the entrypoint arguments
634/// are fixed by [`TrustedWorkerKind`]; model-authored arguments never reach the
635/// spawn boundary.
636#[derive(Debug, Clone)]
637pub struct SandboxedWorker {
638    #[cfg(any(target_os = "linux", target_os = "macos"))]
639    kind: TrustedWorkerKind,
640    sandbox_policy: Arc<SandboxPolicy>,
641}
642
643impl SandboxedWorker {
644    /// Configure the carried Brush worker at this process's fixed executable.
645    ///
646    /// The executable is intentionally not caller-selectable: trusted-worker
647    /// admission bypasses the model-facing exec check, so accepting an arbitrary
648    /// path here would turn the worker API into a generic confused deputy.
649    #[must_use]
650    pub fn brush() -> Self {
651        Self {
652            #[cfg(any(target_os = "linux", target_os = "macos"))]
653            kind: TrustedWorkerKind::Brush,
654            sandbox_policy: Arc::new(SandboxPolicy::default()),
655        }
656    }
657
658    /// Set the sandbox mechanism policy used by the shared spawn funnel.
659    #[must_use]
660    pub fn sandbox_policy(mut self, policy: Arc<SandboxPolicy>) -> Self {
661        self.sandbox_policy = policy;
662        self
663    }
664
665    /// Spawn the fixed worker with empty ambient environment, piped output, and
666    /// a private authenticated-control transport in place of ordinary stdin.
667    ///
668    /// `nonce` binds the worker request carried over stdin to this launch. The
669    /// worker is a fresh process-group leader on Unix so its supervisor can
670    /// terminate the complete process tree on timeout. Other targets retain
671    /// their native child-process behavior. The process-wide unbridled state is
672    /// derived inside core; a caller cannot opt a single worker out of
673    /// confinement.
674    pub fn spawn(
675        self,
676        cx: &ToolContext,
677        nonce: &str,
678        cwd: &Path,
679    ) -> ToolResult<SandboxedWorkerChild> {
680        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
681        {
682            let _ = (self, cx, nonce, cwd);
683            Err(ToolError::denied(
684                "refusing the Brush worker: this platform has no authenticated \
685                 private-control transport",
686            ))
687        }
688        #[cfg(any(target_os = "linux", target_os = "macos"))]
689        {
690            self.spawn_supported(cx, nonce, cwd)
691        }
692    }
693
694    #[cfg(any(target_os = "linux", target_os = "macos"))]
695    fn spawn_supported(
696        self,
697        cx: &ToolContext,
698        nonce: &str,
699        cwd: &Path,
700    ) -> ToolResult<SandboxedWorkerChild> {
701        cx.check_path_read(cwd)?;
702        let request_caveats = cx.caveats().clone();
703        let request_strength_floor = cx.strength_floor();
704        let executable = std::env::current_exe()
705            .and_then(std::fs::canonicalize)
706            .map_err(|error| ToolError::denied(format!("worker executable is invalid: {error}")))?;
707        let executable = executable.to_string_lossy().into_owned();
708        let [flag, kind] = self.kind.args();
709        #[cfg(any(target_os = "linux", target_os = "macos"))]
710        let (control, child_control) = std::os::unix::net::UnixStream::pair().map_err(|error| {
711            ToolError::denied(format!("create worker control channel: {error}"))
712        })?;
713
714        let command = ConfinedCommand::new(executable)
715            .args([flag, kind])
716            .env("AGENT_BRIDLE_WORKER_NONCE", nonce)
717            .current_dir(cwd)
718            .stdin(Stdio::from(std::os::fd::OwnedFd::from(child_control)))
719            .stdout(Stdio::piped())
720            .stderr(Stdio::piped())
721            .new_process_group()
722            .sandbox_policy(self.sandbox_policy);
723
724        let confined = if crate::is_unbridled() {
725            command.spawn_authorized(cx, Caveats::top(), SpawnAuthority::TrustedWorker)
726        } else {
727            let effective = cx.caveats().clone();
728            let available = best_available_sandbox(&command.sandbox_policy).kind();
729            let reported = effective_sandbox_kind(available, &effective);
730            #[cfg(not(target_os = "linux"))]
731            let _ = reported;
732            #[cfg(target_os = "linux")]
733            if reported == SandboxKind::Landlock
734                && crate::sandbox::restricts_fs(&effective)
735                && !linux_user_namespaces_hardened()
736            {
737                return Err(ToolError::denied(
738                    "refusing the Brush worker: Landlock filesystem confinement \
739                     is not a complete boundary while unprivileged user namespaces \
740                     remain available; disable them or add the namespace syscall backstop",
741                ));
742            }
743            command.spawn_authorized(cx, effective, SpawnAuthority::TrustedWorker)
744        }?;
745        Ok(SandboxedWorkerChild {
746            child: confined.child,
747            sandbox_kind: confined.sandbox_kind,
748            control: Some(TrustedWorkerControl {
749                stream: control,
750                nonce: nonce.to_string(),
751                caveats: request_caveats,
752                strength_floor: request_strength_floor,
753            }),
754        })
755    }
756}
757
758/// Build the mechanism-only caveats for a trusted worker transition.
759///
760/// Landlock, Seatbelt, and the identity-closing stronger tiers need the fixed
761/// worker executable in their kernel execute allow-list so the boundary can
762/// launch it. AppContainer is different: its launcher creates the worker as the
763/// initial confined process, and `exec: Only([])` must remain empty so
764/// `--no-child-process` is attached to that worker. Adding the worker path there
765/// would silently turn deny-all into a non-empty allow-list, disable the kernel
766/// child-process mitigation, and leave an `exec → Kernel` report overclaiming.
767///
768/// This changes mechanism configuration only; it never alters the effective
769/// authority carried by `ToolContext` or the enforcement report.
770fn trusted_worker_mechanism_caveats(
771    kind: SandboxKind,
772    effective: &Caveats,
773    program: &str,
774) -> ToolResult<Caveats> {
775    match kind {
776        SandboxKind::Landlock
777        | SandboxKind::Seatbelt
778        | SandboxKind::MinimalRootfs
779        | SandboxKind::MicroVm => caveats_with_trusted_program(effective, program),
780        SandboxKind::AppContainer | SandboxKind::None => Ok(effective.clone()),
781    }
782}
783
784/// Add the exact trusted worker executable to an execute allow-list used by a
785/// mechanism that must authorize the initial worker launch.
786fn caveats_with_trusted_program(effective: &Caveats, program: &str) -> ToolResult<Caveats> {
787    let canonical = Path::new(program)
788        .canonicalize()
789        .map_err(|error| ToolError::denied(format!("cannot resolve trusted worker: {error}")))?
790        .to_string_lossy()
791        .into_owned();
792    let mut mechanism = effective.clone();
793    if let crate::Scope::Only(programs) = &mut mechanism.exec {
794        programs.insert(canonical);
795    }
796    Ok(mechanism)
797}
798
799#[cfg(target_os = "linux")]
800fn linux_user_namespaces_hardened() -> bool {
801    fn sysctl_is(path: &str, expected: &str) -> bool {
802        std::fs::read_to_string(path).is_ok_and(|value| value.trim() == expected)
803    }
804
805    sysctl_is("/proc/sys/kernel/unprivileged_userns_clone", "0")
806        || sysctl_is("/proc/sys/user/max_user_namespaces", "0")
807        || sysctl_is(
808            "/proc/sys/kernel/apparmor_restrict_unprivileged_userns",
809            "1",
810        )
811}
812
813/// Spawn `program args` confined by `cx`, with the inherited stdio of the parent.
814///
815/// The convenience form of [`ConfinedCommand`]: `env_allow` is the child's
816/// **entire** environment (nothing else is inherited). For piped stdio (an MCP
817/// server), use [`ConfinedCommand`] directly.
818pub fn spawn_confined_subprocess(
819    program: &str,
820    args: &[String],
821    cx: &ToolContext,
822    env_allow: &[(String, String)],
823    cwd: Option<&Path>,
824) -> ToolResult<ConfinedChild> {
825    let mut cmd = ConfinedCommand::new(program).args(args);
826    for (k, v) in env_allow {
827        cmd = cmd.env(k, v);
828    }
829    if let Some(dir) = cwd {
830        cmd = cmd.current_dir(dir);
831    }
832    cmd.spawn(cx)
833}
834
835// ── Async-host spawn (tokio pipe handles) ────────────────────────────────────
836//
837// `spawn` above returns a `std::process::Child` — the caller owns the pipe
838// plumbing. An async host (an MCP-server **stdio** transport speaking JSON-RPC
839// over the child's stdin/stdout) needs those pipes as tokio-native, reactor-
840// registered handles, and it needs the child reaped when the transport drops.
841// `spawn_tokio` is that async-facing sibling: the confinement is **identical**
842// (it calls `spawn`, so the admission-check / OS-sandbox / env-scrub are the
843// same audited path — the boundary is unchanged), only the returned handle
844// types differ. Unix-only and gated on `spawn-tokio`, so core stays tokio-free
845// by default (the confinement primitives themselves have no async dependency).
846#[cfg(all(unix, feature = "spawn-tokio"))]
847pub use tokio_spawn::ConfinedTokioChild;
848
849#[cfg(all(unix, feature = "spawn-tokio"))]
850mod tokio_spawn {
851    use super::{ConfinedChild, ConfinedCommand, SandboxKind, ToolContext, ToolResult};
852    use crate::net_proxy::ProxyHandle;
853    use crate::{egress_proxy_plan, ToolError};
854    use std::os::fd::OwnedFd;
855    use std::process::Child;
856    use tokio::net::unix::pipe;
857
858    /// A confined child whose stdio is exposed as **tokio-native** pipe handles,
859    /// for an async host (e.g. an MCP-server stdio transport). The async-facing
860    /// sibling of [`ConfinedChild`](super::ConfinedChild): the confinement is
861    /// identical (produced by [`ConfinedCommand::spawn`]), only the pipe types
862    /// differ.
863    ///
864    /// **Kill-on-drop.** Dropping this SIGKILLs the child and reaps it on a
865    /// detached thread — restoring the guarantee a host loses by moving off
866    /// `tokio::process::Command::kill_on_drop(true)` onto the std child
867    /// underneath (tokio's runtime reaper only tracks *its own* children, so the
868    /// std child would otherwise linger as a zombie). Take the pipe ends with
869    /// the `take_*` accessors; the child stays owned here so this value's
870    /// lifetime governs the process.
871    #[derive(Debug)]
872    pub struct ConfinedTokioChild {
873        /// The OS-level sandbox actually applied to the child — the honest record
874        /// (mirrors [`ConfinedChild::sandbox_kind`](super::ConfinedChild)).
875        pub sandbox_kind: SandboxKind,
876        stdin: Option<pipe::Sender>,
877        stdout: Option<pipe::Receiver>,
878        stderr: Option<pipe::Receiver>,
879        /// `Some` until dropped; owned so kill-on-drop governs the process.
880        child: Option<Child>,
881        /// The live egress proxy fencing this child's net (#257) — `Some` iff the
882        /// grant was a general remote-host allow-list AND the loopback kernel
883        /// fence engaged. Owned here so the proxy's lifetime brackets the
884        /// child's: it is torn down after the child is killed on drop.
885        proxy: Option<ProxyHandle>,
886    }
887
888    impl ConfinedTokioChild {
889        /// Take the child's stdin pipe (writer). `None` if stdin was not
890        /// [`piped`](std::process::Stdio::piped) or was already taken.
891        pub fn take_stdin(&mut self) -> Option<pipe::Sender> {
892            self.stdin.take()
893        }
894
895        /// Take the child's stdout pipe (reader). `None` if stdout was not piped
896        /// or was already taken.
897        pub fn take_stdout(&mut self) -> Option<pipe::Receiver> {
898            self.stdout.take()
899        }
900
901        /// Take the child's stderr pipe (reader). `None` if stderr was not piped
902        /// or was already taken.
903        pub fn take_stderr(&mut self) -> Option<pipe::Receiver> {
904            self.stderr.take()
905        }
906
907        /// Whether this child's egress is fenced through the loopback proxy
908        /// (#257): kernel-fenced to loopback, per-host allow-list enforced by
909        /// the proxy it is pointed at via `*_PROXY` env.
910        pub fn egress_proxied(&self) -> bool {
911            self.proxy.is_some()
912        }
913
914        /// The off-allow-list hosts the child tried to reach through the proxy
915        /// (#196) — each was refused with 403. Empty when no proxy is in force
916        /// or nothing was refused. The exfil-attempt signal a host surfaces as
917        /// structured `net` denials.
918        pub fn refused_hosts(&self) -> Vec<String> {
919            self.proxy
920                .as_ref()
921                .map(ProxyHandle::refused_hosts)
922                .unwrap_or_default()
923        }
924    }
925
926    impl Drop for ConfinedTokioChild {
927        fn drop(&mut self) {
928            // Reinstate kill-on-drop. `spawn_tokio` hands back a std child, which
929            // tokio's runtime reaper does NOT track — so kill it and `wait` on a
930            // detached thread to avoid a zombie without blocking this (possibly
931            // async) drop.
932            if let Some(mut child) = self.child.take() {
933                let _ = child.kill();
934                std::thread::spawn(move || {
935                    let _ = child.wait();
936                });
937            }
938        }
939    }
940
941    impl ConfinedCommand {
942        /// Approve exact names for RFC1918/ULA resolution by this command's
943        /// `spawn_tokio` egress proxy. The owning harness supplies these names
944        /// after an explicit operator decision; server metadata is not authority.
945        /// The context's ordinary net allow-list must independently permit them.
946        ///
947        /// Empty by default. This neither starts a proxy where no loopback fence
948        /// exists nor changes synchronous `spawn` or any filesystem/exec caveat.
949        /// No wildcard or global private-space approval is accepted.
950        pub fn with_private_hosts(
951            mut self,
952            hosts: impl IntoIterator<Item = String>,
953        ) -> std::io::Result<Self> {
954            self.private_hosts = crate::net_proxy::canonical_private_hosts(hosts)?;
955            Ok(self)
956        }
957
958        /// Admission-check, confine, and spawn the child — like
959        /// [`spawn`](ConfinedCommand::spawn), but the stdio pipes are returned as
960        /// **tokio-native** handles wrapped in a kill-on-drop
961        /// [`ConfinedTokioChild`], for an async host (an MCP-server stdio
962        /// transport).
963        ///
964        /// The confinement is exactly `spawn`'s (this delegates to it): the
965        /// `exec` admission-check, the fail-closed refusal when a restricted fs
966        /// axis cannot be kernel-enforced, the OS sandbox, and the env scrub all
967        /// happen there. This method only converts the piped std handles into
968        /// tokio pipe ends.
969        ///
970        /// Must be called from within a tokio runtime — the pipe handles register
971        /// with the reactor. Unix-only; gated on the `spawn-tokio` feature.
972        pub fn spawn_tokio(mut self, cx: &ToolContext) -> ToolResult<ConfinedTokioChild> {
973            // #257 (Part A — Leg 4): under a general remote-host `net` grant,
974            // fence the child's egress. `egress_proxy_plan` is the ONE shared
975            // decision (also the shell engine's): engage only when the loopback
976            // kernel fence is actually emittable on this host — a proxy a rogue
977            // child can walk around is not confinement, so on fence-less hosts
978            // (e.g. Landlock, which cannot address-fence) the wiring stays
979            // INERT and the spawn proceeds exactly as before (net advisory,
980            // ADR 0015 posture).
981            let mut proxy = None;
982            let mut effective = cx.caveats().clone();
983            if let Some((hosts, fenced)) = egress_proxy_plan(&effective, &self.sandbox_policy) {
984                // Fail-closed: the grant calls for a fence + proxy; a proxy
985                // that cannot bind must refuse the spawn, never run unfenced.
986                let handle = crate::net_proxy::start_with_private_hosts(
987                    hosts,
988                    self.private_hosts.iter().cloned(),
989                    std::sync::Arc::new(crate::net_proxy::StdResolver),
990                    std::sync::Arc::new(crate::net_proxy::NullSink),
991                )
992                .map_err(|e| {
993                    ToolError::Exec(std::io::Error::other(format!(
994                        "refusing to spawn {:?}: the egress proxy could not bind \
995                         loopback ({e})",
996                        self.program
997                    )))
998                })?;
999                // Point the child at the proxy through the explicit env
1000                // grants (the only channel across the boundary).
1001                for (k, v) in handle.proxy_env() {
1002                    self = self.env(k, v);
1003                }
1004                proxy = Some(handle);
1005                effective = fenced;
1006            }
1007
1008            let ConfinedChild {
1009                mut child,
1010                sandbox_kind,
1011            } = self.spawn_with_effective(cx, effective)?;
1012
1013            // Convert each *piped* std handle into a tokio pipe end.
1014            // `pipe::{Sender,Receiver}::from_owned_fd` set O_NONBLOCK and register
1015            // the fd with the reactor. The `OwnedFd` conversion moves ownership
1016            // out of the std `Child`, so each fd is closed exactly once (the tokio
1017            // end owns it; `Child` no longer does after `take`). A handle that was
1018            // not piped stays `None`. `?` maps the io error via `ToolError::from`.
1019            let stdin = child
1020                .stdin
1021                .take()
1022                .map(|h| pipe::Sender::from_owned_fd(OwnedFd::from(h)))
1023                .transpose()?;
1024            let stdout = child
1025                .stdout
1026                .take()
1027                .map(|h| pipe::Receiver::from_owned_fd(OwnedFd::from(h)))
1028                .transpose()?;
1029            let stderr = child
1030                .stderr
1031                .take()
1032                .map(|h| pipe::Receiver::from_owned_fd(OwnedFd::from(h)))
1033                .transpose()?;
1034
1035            Ok(ConfinedTokioChild {
1036                sandbox_kind,
1037                stdin,
1038                stdout,
1039                stderr,
1040                child: Some(child),
1041                proxy,
1042            })
1043        }
1044    }
1045}
1046
1047/// Prepend a backend command prefix (Seatbelt's `sandbox-exec -p <profile>`) to
1048/// a `(program, args)`, yielding the argv to actually spawn. An empty prefix is
1049/// the identity — thread-confining (Landlock) and Noop backends spawn the
1050/// program directly. Under Seatbelt the program should be an absolute path
1051/// (the environment is scrubbed, so `sandbox-exec` cannot resolve a bare name
1052/// via `PATH`).
1053fn wrap_argv(prefix: &[String], program: &str, args: &[OsString]) -> (OsString, Vec<OsString>) {
1054    if prefix.is_empty() {
1055        return (OsString::from(program), args.to_vec());
1056    }
1057    let mut argv: Vec<OsString> = prefix[1..].iter().map(OsString::from).collect();
1058    argv.push(OsString::from(program));
1059    argv.extend(args.iter().cloned());
1060    (OsString::from(&prefix[0]), argv)
1061}
1062
1063/// Would confining this child be a *lie*? Decided against the **real** backend
1064/// `kind` (the probe the spawn actually confines through — not a stale gate
1065/// stamp; ADR 0012 D4) and the principal's `floor`.
1066///
1067/// Two parts:
1068/// 1. **The filesystem floor (always).** `fs_read` and `fs_write` *are*
1069///    kernel-enforceable (Landlock/Seatbelt/AppContainer); a restricted fs axis
1070///    the active backend cannot kernel-confine is a grant we cannot honor, so we
1071///    refuse regardless of strength. This keeps the ADR 0003 stub floor for
1072///    `fs_write` **and** extends it to `fs_read` — closing the spawn-boundary
1073///    fail-open ADR 0012 D4 found (a restricted `fs_read` was run unconfined
1074///    under `None` because the old check looked at `fs_write` only).
1075/// 2. **The strength floor (`exec`/`net`).** Coverage varies by backend and
1076///    scope, so those axes refuse when the principal's `floor` demands more than
1077///    the real report delivers (`fence_strength(report) < floor`). With the
1078///    default floor ([`AxisEnforcement::Advisory`]), an honestly reported weaker
1079///    axis may run; a strong principal (`floor = Kernel`) fails closed whenever
1080///    the active backend cannot kernel-confine it (ADR 0012 D3/D10).
1081#[must_use]
1082pub fn confinement_unenforceable(
1083    kind: SandboxKind,
1084    caveats: &Caveats,
1085    floor: AxisEnforcement,
1086) -> bool {
1087    if crate::sandbox::has_unix_socket_grants(caveats) && kind != SandboxKind::Seatbelt {
1088        return true;
1089    }
1090    let report = enforcement_report(caveats, kind);
1091    let below_kernel = |e: Option<AxisEnforcement>| e.is_some_and(|e| e != AxisEnforcement::Kernel);
1092    // (1) Filesystem axes: kernel-enforceable, so a restricted-but-not-kernel fs
1093    // axis is always unenforceable.
1094    if below_kernel(report.fs_write) || below_kernel(report.fs_read) {
1095        return true;
1096    }
1097    // (2) exec/net: refuse only when the strength floor is not met by reality.
1098    fence_strength(&report).is_some_and(|s| s < floor)
1099}
1100
1101// Async-path proof for `spawn_tokio`: the child's stdio survives the std→tokio
1102// pipe conversion (a JSON-RPC line round-trips), and kill-on-drop actually kills
1103// the child. Real-subprocess tests, matching this module's convention (the
1104// landlock/seatbelt child proofs above also spawn real programs).
1105#[cfg(all(unix, feature = "spawn-tokio", test))]
1106mod tokio_spawn_tests {
1107    use super::*;
1108    use crate::{Gate, Tool};
1109    use std::time::Duration;
1110    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1111
1112    fn ctx(granted: Caveats) -> ToolContext {
1113        struct AnyTool;
1114        #[async_trait::async_trait]
1115        impl Tool for AnyTool {
1116            fn name(&self) -> &str {
1117                "any"
1118            }
1119            fn schema(&self) -> serde_json::Value {
1120                serde_json::json!({})
1121            }
1122            async fn invoke(
1123                &self,
1124                _a: serde_json::Value,
1125                _c: &ToolContext,
1126            ) -> ToolResult<serde_json::Value> {
1127                Ok(serde_json::Value::Null)
1128            }
1129        }
1130        Gate::new(0)
1131            .authorize(&AnyTool, &granted)
1132            .expect("authorize")
1133    }
1134
1135    fn find_cat() -> Option<&'static str> {
1136        ["/usr/bin/cat", "/bin/cat"]
1137            .into_iter()
1138            .find(|p| Path::new(p).exists())
1139    }
1140
1141    #[test]
1142    fn exact_private_hosts_builder_defaults_closed_and_validates_names() {
1143        let command = ConfinedCommand::new("cat");
1144        assert!(command.private_hosts.is_empty());
1145        let command = command
1146            .with_private_hosts(["SERVICE.TEST.".to_string()])
1147            .unwrap();
1148        assert_eq!(command.private_hosts, ["service.test".to_string()].into());
1149        assert!(ConfinedCommand::new("cat")
1150            .with_private_hosts(["*".to_string()])
1151            .is_err());
1152    }
1153
1154    /// The MCP-transport use case: a newline-delimited JSON-RPC line written to
1155    /// the child's tokio stdin comes back on its tokio stdout (`cat` echoes),
1156    /// proving the std→tokio pipe conversion preserves a working duplex stream.
1157    #[tokio::test]
1158    async fn json_line_round_trips_over_tokio_pipes() {
1159        let Some(cat) = find_cat() else {
1160            eprintln!("skipping: no cat(1) found");
1161            return;
1162        };
1163        let cx = ctx(Caveats {
1164            exec: Scope::only(["cat".to_string()]),
1165            ..Caveats::top()
1166        });
1167        let mut child = ConfinedCommand::new(cat)
1168            .stdin(Stdio::piped())
1169            .stdout(Stdio::piped())
1170            .spawn_tokio(&cx)
1171            .expect("spawn_tokio cat");
1172
1173        let mut stdin = child.take_stdin().expect("stdin piped");
1174        let stdout = child.take_stdout().expect("stdout piped");
1175        assert!(child.take_stdin().is_none(), "stdin taken once");
1176
1177        let msg = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
1178        stdin.write_all(msg.as_bytes()).await.expect("write");
1179        stdin.write_all(b"\n").await.expect("write nl");
1180        stdin.flush().await.expect("flush");
1181
1182        let mut lines = BufReader::new(stdout).lines();
1183        let got = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
1184            .await
1185            .expect("recv did not time out")
1186            .expect("recv ok");
1187        assert_eq!(got.as_deref(), Some(msg));
1188    }
1189
1190    /// Kill-on-drop: dropping the [`ConfinedTokioChild`] SIGKILLs the child, which
1191    /// closes its stdout write end — so the retained reader reaches EOF. `stdin`
1192    /// is held so `cat` cannot exit on its own from a stdin EOF; the only thing
1193    /// that ends it is the drop.
1194    #[tokio::test]
1195    async fn dropping_the_guard_kills_the_child() {
1196        let Some(cat) = find_cat() else {
1197            eprintln!("skipping: no cat(1) found");
1198            return;
1199        };
1200        let cx = ctx(Caveats {
1201            exec: Scope::only(["cat".to_string()]),
1202            ..Caveats::top()
1203        });
1204        let mut child = ConfinedCommand::new(cat)
1205            .stdin(Stdio::piped())
1206            .stdout(Stdio::piped())
1207            .spawn_tokio(&cx)
1208            .expect("spawn_tokio cat");
1209
1210        let stdout = child.take_stdout().expect("stdout piped");
1211        // Hold stdin so `cat` does not exit from a stdin EOF — isolate the kill.
1212        let _stdin = child.take_stdin().expect("stdin piped");
1213        drop(child);
1214
1215        let mut lines = BufReader::new(stdout).lines();
1216        let eof = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
1217            .await
1218            .expect("EOF did not time out")
1219            .expect("read ok");
1220        assert_eq!(
1221            eof, None,
1222            "kill-on-drop must terminate the child and close its stdout (EOF)"
1223        );
1224    }
1225
1226    // ── #257: spawn_tokio's egress-proxy wiring ─────────────────────────────
1227
1228    /// Where the loopback fence is NOT emittable (any host whose backend does
1229    /// not engage for the fenced caveats — e.g. Linux/Landlock, which cannot
1230    /// address-fence), a remote-host `net` grant spawns with NO proxy: inert,
1231    /// advisory-net, exactly the pre-#257 behavior (the ADR 0015 posture —
1232    /// never a proxy the child can walk around).
1233    #[tokio::test]
1234    async fn remote_net_grant_without_fence_backend_spawns_inert() {
1235        let caveats = Caveats {
1236            exec: Scope::only(["true".to_string()]),
1237            net: Scope::only(["api.example.com".to_string()]),
1238            ..Caveats::top()
1239        };
1240        // Only meaningful where the fence would NOT engage; on a Seatbelt host
1241        // this test's premise doesn't hold, so skip there.
1242        let plan_engages = crate::egress_proxy_plan(
1243            &caveats,
1244            &std::sync::Arc::new(crate::SandboxPolicy::default()),
1245        )
1246        .is_some();
1247        if plan_engages {
1248            eprintln!("skipping: this host CAN emit the loopback fence (engage path)");
1249            return;
1250        }
1251        let cx = ctx(caveats);
1252        let child = ConfinedCommand::new("true")
1253            .spawn_tokio(&cx)
1254            .expect("inert path spawns as before");
1255        assert!(!child.egress_proxied(), "no fence backend → no proxy");
1256        assert!(child.refused_hosts().is_empty());
1257        // Reap deterministically (kill-on-drop covers it regardless).
1258        drop(child);
1259    }
1260
1261    /// The engage path — INTEGRATION tier (real Seatbelt + real subprocess +
1262    /// the loopback proxy), so `#[ignore]`d out of the per-PR unit run; the
1263    /// deterministic engage proof lives in `net_proxy::tests` (the proxy 403s +
1264    /// records an off-list host with no subprocess) and the inert case above.
1265    /// Run on macOS with `--ignored`.
1266    ///
1267    /// Under a remote-host grant on a fence-capable host, a spawned `curl`
1268    /// (exec-scoped to itself — no shell re-exec) inherits the granted
1269    /// `*_PROXY` env and its CONNECT to an off-allow-list host is refused by
1270    /// the proxy (recorded in `refused_hosts()`) BEFORE any real dial — so this
1271    /// needs no network. Proves the full spawn_tokio ∘ fence ∘ proxy compose.
1272    #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1273    #[tokio::test]
1274    #[ignore = "integration: real Seatbelt fence + curl subprocess + loopback proxy"]
1275    async fn remote_net_grant_with_fence_spawns_proxied_and_refuses_off_list() {
1276        if !crate::seatbelt_is_supported() {
1277            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1278            return;
1279        }
1280        let curl = "/usr/bin/curl"; // always present on macOS; no shell re-exec
1281        let caveats = Caveats {
1282            exec: Scope::only([curl.to_string()]),
1283            net: Scope::only(["api.example.com".to_string()]),
1284            ..Caveats::top()
1285        };
1286        let cx = ctx(caveats);
1287        // curl honors the lowercase `https_proxy` the proxy env grant sets; the
1288        // off-list CONNECT is refused at the allow-list (403) before any dial.
1289        let child = ConfinedCommand::new(curl)
1290            // A private-space approval cannot grant an off-list hostname.
1291            .with_private_hosts(["evil.example.net".to_string()])
1292            .expect("exact private host")
1293            .arg("-s")
1294            .arg("-m")
1295            .arg("5")
1296            .arg("https://evil.example.net/")
1297            .stdout(Stdio::null())
1298            .stderr(Stdio::null())
1299            .spawn_tokio(&cx)
1300            .expect("proxied spawn");
1301        assert!(child.egress_proxied(), "fence host → proxy must engage");
1302
1303        // Reap via the kill-on-drop guard after curl exits; the proxy records
1304        // the refusal synchronously as it serves the CONNECT.
1305        let _ = tokio::time::timeout(Duration::from_secs(10), async {
1306            tokio::time::sleep(Duration::from_secs(1)).await;
1307        })
1308        .await;
1309        assert!(
1310            child
1311                .refused_hosts()
1312                .contains(&"evil.example.net".to_string()),
1313            "the off-allow-list host must be refused and recorded: {:?}",
1314            child.refused_hosts()
1315        );
1316    }
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321    use super::*;
1322    use crate::{Gate, Tool};
1323
1324    /// Mint a `ToolContext` the only legitimate way — through the gate.
1325    fn ctx(granted: Caveats) -> ToolContext {
1326        struct AnyTool;
1327        #[async_trait::async_trait]
1328        impl Tool for AnyTool {
1329            fn name(&self) -> &str {
1330                "any"
1331            }
1332            fn schema(&self) -> serde_json::Value {
1333                serde_json::json!({})
1334            }
1335            async fn invoke(
1336                &self,
1337                _args: serde_json::Value,
1338                _cx: &ToolContext,
1339            ) -> ToolResult<serde_json::Value> {
1340                Ok(serde_json::Value::Null)
1341            }
1342        }
1343        Gate::new(0)
1344            .authorize(&AnyTool, &granted)
1345            .expect("authorize")
1346    }
1347
1348    /// Core freezes authority at worker spawn and exposes only a one-shot
1349    /// payload sender. Payload fields that merely *look* authority-bearing do
1350    /// not replace the captured caveats, and a second send is structurally
1351    /// refused.
1352    #[cfg(any(target_os = "linux", target_os = "macos"))]
1353    #[test]
1354    fn trusted_worker_control_freezes_authority_and_is_take_once() {
1355        let true_program = ["/usr/bin/true", "/bin/true"]
1356            .into_iter()
1357            .find(|path| Path::new(path).exists())
1358            .expect("true executable");
1359        let process = Command::new(true_program)
1360            .spawn()
1361            .expect("spawn PID holder");
1362        let child_pid = process.id();
1363        let (client, mut server) =
1364            std::os::unix::net::UnixStream::pair().expect("private socketpair");
1365        let (seen_tx, seen_rx) = std::sync::mpsc::channel();
1366
1367        let peer = std::thread::spawn(move || {
1368            let mut bootstrap = [0_u8; TRUSTED_WORKER_BOOTSTRAP.len()];
1369            server.read_exact(&mut bootstrap).expect("read bootstrap");
1370            assert_eq!(bootstrap, TRUSTED_WORKER_BOOTSTRAP);
1371            let challenge = [0x5a; 32];
1372            server
1373                .write_all(&encode_trusted_worker_hello(child_pid, challenge))
1374                .and_then(|()| server.flush())
1375                .expect("write hello");
1376
1377            let mut header = [0_u8; TRUSTED_WORKER_FRAME_HEADER_LEN];
1378            server.read_exact(&mut header).expect("read header");
1379            let (body_len, echoed, digest) =
1380                decode_trusted_worker_frame_header(&header).expect("decode header");
1381            assert_eq!(echoed, challenge);
1382            let mut body = vec![0_u8; body_len];
1383            server.read_exact(&mut body).expect("read body");
1384            assert_eq!(digest, trusted_worker_frame_digest(&challenge, &body));
1385            let request: TrustedWorkerRequest<serde_json::Value> =
1386                decode_trusted_worker_request(&body).expect("decode request");
1387            seen_tx.send(request.into_parts()).expect("report request");
1388            server
1389                .write_all(&TRUSTED_WORKER_ACK)
1390                .and_then(|()| server.flush())
1391                .expect("write ACK");
1392        });
1393
1394        let frozen = Caveats {
1395            exec: Scope::only(["echo".to_string()]),
1396            ..Caveats::top()
1397        };
1398        let control = TrustedWorkerControl {
1399            stream: client,
1400            nonce: "core-owned-nonce".to_string(),
1401            caveats: frozen.clone(),
1402            strength_floor: AxisEnforcement::Advisory,
1403        };
1404        let mut worker = SandboxedWorkerChild {
1405            child: process,
1406            sandbox_kind: SandboxKind::None,
1407            control: Some(control),
1408        };
1409        let forged_payload = serde_json::json!({
1410            "cmd": "echo ok",
1411            "caveats": Caveats::top(),
1412            "strength_floor": AxisEnforcement::Kernel,
1413        });
1414        worker
1415            .send_payload(&forged_payload, Duration::from_secs(5))
1416            .expect("first payload");
1417
1418        let (nonce, authority, floor, payload) = seen_rx.recv().expect("receive decoded request");
1419        assert_eq!(nonce, "core-owned-nonce");
1420        assert_eq!(authority, frozen, "payload must not replace frozen caveats");
1421        assert_eq!(floor, AxisEnforcement::Advisory);
1422        assert_eq!(payload, forged_payload);
1423        assert!(
1424            matches!(
1425                worker.send_payload(&serde_json::json!({}), Duration::from_secs(1)),
1426                Err(ToolError::Denied { .. })
1427            ),
1428            "the private control endpoint must be take-once"
1429        );
1430
1431        peer.join().expect("join fake worker");
1432        let _ = worker.child.wait();
1433    }
1434
1435    #[test]
1436    fn exec_outside_scope_is_denied_before_any_spawn() {
1437        let cx = ctx(Caveats {
1438            exec: Scope::only(["echo".to_string()]),
1439            ..Caveats::top()
1440        });
1441        let res = ConfinedCommand::new("rm").arg("-rf").spawn(&cx);
1442        assert!(matches!(res, Err(ToolError::Denied { .. })));
1443    }
1444
1445    #[test]
1446    fn unenforceable_predicate_fs_axes_always_strength_floor_for_exec() {
1447        use AxisEnforcement::{Advisory, Kernel};
1448        let fs_write = Caveats {
1449            fs_write: Scope::only(["/tmp/x".to_string()]),
1450            ..Caveats::top()
1451        };
1452        let fs_read = Caveats {
1453            fs_read: Scope::only(["/tmp/x".to_string()]),
1454            ..Caveats::top()
1455        };
1456        let exec = Caveats {
1457            exec: Scope::only(["echo".to_string()]),
1458            ..Caveats::top()
1459        };
1460
1461        // (1) FS floor — always, regardless of strength: a restricted fs axis with
1462        // no OS sandbox is unenforceable, for BOTH fs_write and fs_read (the
1463        // latter is the ADR 0012 D4 spawn-boundary fail-open this closes).
1464        assert!(confinement_unenforceable(
1465            SandboxKind::None,
1466            &fs_write,
1467            Advisory
1468        ));
1469        assert!(confinement_unenforceable(
1470            SandboxKind::None,
1471            &fs_read,
1472            Advisory
1473        ));
1474        // The kernel can enforce the fs axes => fine.
1475        assert!(!confinement_unenforceable(
1476            SandboxKind::Landlock,
1477            &fs_write,
1478            Advisory
1479        ));
1480        assert!(!confinement_unenforceable(
1481            SandboxKind::Landlock,
1482            &fs_read,
1483            Advisory
1484        ));
1485
1486        // (2) exec is not kernel-enforceable: the default (Advisory) floor permits
1487        // it; a strong (Kernel) floor fails closed (the opt-in un-stub posture).
1488        assert!(!confinement_unenforceable(
1489            SandboxKind::None,
1490            &exec,
1491            Advisory
1492        ));
1493        assert!(confinement_unenforceable(SandboxKind::None, &exec, Kernel));
1494
1495        // Unrestricted grant => nothing to enforce, even under a Kernel floor.
1496        assert!(!confinement_unenforceable(
1497            SandboxKind::None,
1498            &Caveats::top(),
1499            Kernel
1500        ));
1501    }
1502
1503    /// AppContainer's wired ACL narrowing means fs-only caveats engage the
1504    /// launcher; `--fs-read`/`--fs-write` grant its SID the requested workspace
1505    /// paths over the container's default deny of user directories.
1506    #[test]
1507    fn fs_restricted_under_appcontainer_engages_the_launcher() {
1508        let fs = Caveats {
1509            fs_write: Scope::only(["/tmp/x".to_string()]),
1510            ..Caveats::top()
1511        };
1512        let governing = effective_sandbox_kind(SandboxKind::AppContainer, &fs);
1513        assert_eq!(
1514            governing,
1515            SandboxKind::AppContainer,
1516            "fs-only must engage AppContainer (ACL narrowing wired, #51)"
1517        );
1518        // fs_write is Kernel: DACL grants + AppContainer default deny-user-dirs (#51).
1519        let report = enforcement_report(&fs, governing);
1520        assert_eq!(report.fs_write, Some(AxisEnforcement::Kernel));
1521        // With AppContainer engaged and fs Kernel, confinement is enforceable.
1522        assert!(
1523            !confinement_unenforceable(governing, &fs, AxisEnforcement::Advisory),
1524            "fs-restricted AppContainer is enforceable (launcher wired)"
1525        );
1526    }
1527
1528    /// exec_fully_denied engages the AppContainer backend: governing == AppContainer,
1529    /// and the enforcement report marks exec → Kernel (#123).
1530    #[test]
1531    fn exec_deny_all_under_appcontainer_is_kernel() {
1532        let exec_denied = Caveats {
1533            exec: Scope::only([] as [String; 0]),
1534            ..Caveats::top()
1535        };
1536        let governing = effective_sandbox_kind(SandboxKind::AppContainer, &exec_denied);
1537        assert_eq!(
1538            governing,
1539            SandboxKind::AppContainer,
1540            "exec deny-all must engage AppContainer"
1541        );
1542        // With an AppContainer backend and exec fully denied, the axis is kernel-enforced.
1543        assert!(
1544            !confinement_unenforceable(governing, &exec_denied, AxisEnforcement::Advisory),
1545            "exec deny-all under AppContainer is enforceable (kernel-level block)"
1546        );
1547        let report = enforcement_report(&exec_denied, governing);
1548        assert_eq!(
1549            report.exec,
1550            Some(AxisEnforcement::Kernel),
1551            "exec deny-all must be Kernel under AppContainer"
1552        );
1553    }
1554
1555    /// Trusted-worker launch configuration must not erase AppContainer's
1556    /// deny-all signal. The AppContainer launcher starts the worker itself, then
1557    /// `--no-child-process` confines what that worker may spawn. This is pure and
1558    /// host-independent so Linux/macOS CI protects the Windows policy routing.
1559    #[test]
1560    fn trusted_worker_preserves_appcontainer_exec_deny_all() {
1561        let exec_denied = Caveats {
1562            exec: Scope::only([] as [String; 0]),
1563            ..Caveats::top()
1564        };
1565
1566        let mechanism = trusted_worker_mechanism_caveats(
1567            SandboxKind::AppContainer,
1568            &exec_denied,
1569            "this-path-is-not-used-by-appcontainer",
1570        )
1571        .expect("AppContainer mechanism caveats");
1572
1573        assert!(
1574            crate::sandbox::exec_fully_denied(&mechanism),
1575            "the launcher must still select --no-child-process"
1576        );
1577        assert_eq!(
1578            effective_sandbox_kind(SandboxKind::AppContainer, &mechanism),
1579            SandboxKind::AppContainer,
1580            "deny-all must still engage the AppContainer boundary"
1581        );
1582        assert_eq!(
1583            enforcement_report(&mechanism, SandboxKind::AppContainer).exec,
1584            Some(AxisEnforcement::Kernel),
1585            "the preserved mechanism matches the reported kernel guarantee"
1586        );
1587    }
1588
1589    /// Backends whose wrapper/domain must execute the trusted worker still get
1590    /// its exact path as mechanism-only authority.
1591    #[test]
1592    fn trusted_worker_keeps_exec_allowance_for_allowlist_backends() {
1593        let exec_denied = Caveats {
1594            exec: Scope::only([] as [String; 0]),
1595            ..Caveats::top()
1596        };
1597        let current = std::env::current_exe()
1598            .expect("current executable")
1599            .canonicalize()
1600            .expect("canonical current executable")
1601            .to_string_lossy()
1602            .into_owned();
1603
1604        for kind in [SandboxKind::Landlock, SandboxKind::Seatbelt] {
1605            let mechanism = trusted_worker_mechanism_caveats(kind, &exec_denied, &current)
1606                .expect("allowlist mechanism caveats");
1607            assert!(
1608                matches!(&mechanism.exec, Scope::Only(programs) if programs.contains(&current)),
1609                "{kind:?} must authorize the fixed worker executable"
1610            );
1611        }
1612    }
1613
1614    /// Builds with **no** available OS sandbox: a restrictive `fs_write` must be
1615    /// refused rather than spawned unconfined. Gated off where a backend can
1616    /// actually enforce (Linux+Landlock, macOS+Seatbelt, Windows+AppContainer) —
1617    /// there the spawn is confined (or fails-closed on missing launcher), not
1618    /// silently unconfined, so this particular assertion does not apply.
1619    #[cfg(not(any(
1620        all(target_os = "linux", feature = "linux-landlock"),
1621        all(target_os = "macos", feature = "macos-seatbelt"),
1622        all(target_os = "windows", feature = "windows-appcontainer")
1623    )))]
1624    #[test]
1625    fn restrictive_write_refused_when_no_sandbox_available() {
1626        let cx = ctx(Caveats {
1627            exec: Scope::All,
1628            fs_write: Scope::only(["/tmp/allowed".to_string()]),
1629            ..Caveats::top()
1630        });
1631        let res = ConfinedCommand::new("true").spawn(&cx);
1632        assert!(
1633            matches!(res, Err(ToolError::Denied { .. })),
1634            "must fail closed when confinement is requested but unenforceable"
1635        );
1636    }
1637
1638    /// The environment is scrubbed: only granted vars reach the child, nothing
1639    /// ambient (e.g. the parent's `HOME`) leaks. Uses a piped stdout to read the
1640    /// child's view of its own environment.
1641    #[cfg(unix)]
1642    #[test]
1643    fn environment_is_scrubbed_to_the_granted_allow_list() {
1644        let env_bin = ["/usr/bin/env", "/bin/env"]
1645            .into_iter()
1646            .find(|p| Path::new(p).exists());
1647        let Some(env_bin) = env_bin else {
1648            eprintln!("skipping env-scrub test: no env(1) found");
1649            return;
1650        };
1651        // fs_write unrestricted (env(1) writes only to its stdout pipe, not the
1652        // filesystem), exec pinned to env.
1653        let cx = ctx(Caveats {
1654            exec: Scope::only(["env".to_string()]),
1655            ..Caveats::top()
1656        });
1657        let spawned = ConfinedCommand::new(env_bin)
1658            .env("ALLOWED", "yes")
1659            .stdout(Stdio::piped())
1660            .spawn(&cx)
1661            .expect("spawn env");
1662        let out = spawned.child.wait_with_output().expect("wait");
1663        let text = String::from_utf8_lossy(&out.stdout);
1664        assert!(text.contains("ALLOWED=yes"), "granted var must be present");
1665        assert!(
1666            !text.contains("HOME="),
1667            "ambient parent env must NOT leak into the child: {text:?}"
1668        );
1669    }
1670}
1671
1672// Kernel-enforcement proof: the *spawned child* (not just the parent thread)
1673// inherits the Landlock `fs_write` domain. Only meaningful on Linux with the
1674// feature and a capable kernel.
1675#[cfg(all(target_os = "linux", feature = "linux-landlock", test))]
1676mod landlock_child_tests {
1677    use super::*;
1678    use crate::{landlock_is_supported, Gate, Tool};
1679    use std::fs;
1680    use std::path::PathBuf;
1681    use std::sync::atomic::{AtomicU64, Ordering};
1682
1683    fn ctx(granted: Caveats) -> ToolContext {
1684        struct AnyTool;
1685        #[async_trait::async_trait]
1686        impl Tool for AnyTool {
1687            fn name(&self) -> &str {
1688                "any"
1689            }
1690            fn schema(&self) -> serde_json::Value {
1691                serde_json::json!({})
1692            }
1693            async fn invoke(
1694                &self,
1695                _a: serde_json::Value,
1696                _c: &ToolContext,
1697            ) -> ToolResult<serde_json::Value> {
1698                Ok(serde_json::Value::Null)
1699            }
1700        }
1701        Gate::new(0)
1702            .authorize(&AnyTool, &granted)
1703            .expect("authorize")
1704    }
1705
1706    fn unique_dir(tag: &str) -> PathBuf {
1707        static N: AtomicU64 = AtomicU64::new(0);
1708        let mut d = std::env::temp_dir();
1709        d.push(format!(
1710            "agent-bridle-spawn-{}-{}-{}",
1711            tag,
1712            std::process::id(),
1713            N.fetch_add(1, Ordering::Relaxed)
1714        ));
1715        fs::create_dir_all(&d).unwrap();
1716        d
1717    }
1718
1719    #[test]
1720    fn child_inherits_fs_write_domain_out_of_scope_denied_in_scope_allowed() {
1721        if !landlock_is_supported() {
1722            eprintln!("skipping: kernel lacks Landlock");
1723            return;
1724        }
1725        let touch = ["/usr/bin/touch", "/bin/touch"]
1726            .into_iter()
1727            .find(|p| std::path::Path::new(p).exists());
1728        let Some(touch) = touch else {
1729            eprintln!("skipping: no touch(1) found");
1730            return;
1731        };
1732
1733        let allowed = unique_dir("allowed");
1734        let forbidden = unique_dir("forbidden");
1735        let cx = ctx(Caveats {
1736            exec: Scope::only(["touch".to_string()]),
1737            fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
1738            ..Caveats::top()
1739        });
1740
1741        // Out of scope: the child's own write is kernel-denied → non-zero exit.
1742        let mut out = ConfinedCommand::new(touch)
1743            .arg(forbidden.join("escape.txt"))
1744            .spawn(&cx)
1745            .expect("spawn");
1746        assert_eq!(out.sandbox_kind, SandboxKind::Landlock);
1747        let status = out.child.wait().expect("wait");
1748        assert!(
1749            !status.success(),
1750            "child write outside fs_write must be kernel-denied"
1751        );
1752        assert!(!forbidden.join("escape.txt").exists());
1753
1754        // In scope: the child write succeeds.
1755        let mut ok = ConfinedCommand::new(touch)
1756            .arg(allowed.join("ok.txt"))
1757            .spawn(&cx)
1758            .expect("spawn");
1759        assert!(ok.child.wait().expect("wait").success());
1760        assert!(allowed.join("ok.txt").exists());
1761
1762        let _ = fs::remove_dir_all(&allowed);
1763        let _ = fs::remove_dir_all(&forbidden);
1764    }
1765
1766    /// #144 (I5-B): `ConfinedCommand::sandbox_policy` is honored — a child spawned
1767    /// with a widened `base_read_paths` can read a file outside `fs_read` scope
1768    /// that the default policy denies. Proves the builder threads the policy into
1769    /// `best_available_sandbox` (mechanism rides the builder, not `ToolContext`).
1770    #[test]
1771    fn confined_command_honors_sandbox_policy_base_read() {
1772        if !landlock_is_supported() {
1773            eprintln!("skipping: kernel lacks Landlock");
1774            return;
1775        }
1776        let cat = ["/usr/bin/cat", "/bin/cat"]
1777            .into_iter()
1778            .find(|p| std::path::Path::new(p).exists());
1779        let Some(cat) = cat else {
1780            eprintln!("skipping: no cat(1) found");
1781            return;
1782        };
1783
1784        let allowed = unique_dir("cfg-allowed");
1785        let extra = unique_dir("cfg-extra");
1786        fs::write(extra.join("data.txt"), b"configured").unwrap();
1787        let cx = ctx(Caveats {
1788            exec: Scope::only(["cat".to_string()]),
1789            fs_read: Scope::only([allowed.to_string_lossy().into_owned()]),
1790            ..Caveats::top()
1791        });
1792
1793        // Control: default policy → the child cannot read the out-of-scope file.
1794        let mut denied = ConfinedCommand::new(cat)
1795            .arg(extra.join("data.txt"))
1796            .stdout(Stdio::null())
1797            .stderr(Stdio::null())
1798            .spawn(&cx)
1799            .expect("spawn");
1800        assert!(
1801            !denied.child.wait().expect("wait").success(),
1802            "default base read must deny the child reading the out-of-scope file"
1803        );
1804
1805        // Widened policy: add `extra` to base_read_paths → the child reads it.
1806        let mut base = SandboxPolicy::default().base_read_paths;
1807        base.extra.push(extra.to_string_lossy().into_owned());
1808        let policy = Arc::new(SandboxPolicy {
1809            base_read_paths: base,
1810            ..SandboxPolicy::default()
1811        });
1812        let mut ok = ConfinedCommand::new(cat)
1813            .arg(extra.join("data.txt"))
1814            .sandbox_policy(policy)
1815            .stdout(Stdio::null())
1816            .stderr(Stdio::null())
1817            .spawn(&cx)
1818            .expect("spawn");
1819        assert!(
1820            ok.child.wait().expect("wait").success(),
1821            "a config-widened base_read_paths must let the child read the extra file"
1822        );
1823
1824        let _ = fs::remove_dir_all(&allowed);
1825        let _ = fs::remove_dir_all(&extra);
1826    }
1827}
1828
1829// Kernel-enforcement proof for macOS: the *spawned child* (not just the parent)
1830// is confined by the Seatbelt `sandbox-exec` wrapper that `ConfinedCommand`
1831// applies — the spawn.rs analog of the Landlock child proof above.
1832#[cfg(all(target_os = "macos", feature = "macos-seatbelt", test))]
1833mod seatbelt_child_tests {
1834    use super::*;
1835    use crate::{seatbelt_is_supported, Gate, Tool};
1836    use std::fs;
1837    use std::path::PathBuf;
1838    use std::sync::atomic::{AtomicU64, Ordering};
1839
1840    fn ctx(granted: Caveats) -> ToolContext {
1841        struct AnyTool;
1842        #[async_trait::async_trait]
1843        impl Tool for AnyTool {
1844            fn name(&self) -> &str {
1845                "any"
1846            }
1847            fn schema(&self) -> serde_json::Value {
1848                serde_json::json!({})
1849            }
1850            async fn invoke(
1851                &self,
1852                _a: serde_json::Value,
1853                _c: &ToolContext,
1854            ) -> ToolResult<serde_json::Value> {
1855                Ok(serde_json::Value::Null)
1856            }
1857        }
1858        Gate::new(0)
1859            .authorize(&AnyTool, &granted)
1860            .expect("authorize")
1861    }
1862
1863    fn unique_dir(tag: &str) -> PathBuf {
1864        static N: AtomicU64 = AtomicU64::new(0);
1865        let mut d = std::env::temp_dir();
1866        d.push(format!(
1867            "agent-bridle-spawn-sb-{}-{}-{}",
1868            tag,
1869            std::process::id(),
1870            N.fetch_add(1, Ordering::Relaxed)
1871        ));
1872        fs::create_dir_all(&d).unwrap();
1873        d
1874    }
1875
1876    #[test]
1877    fn child_inherits_fs_write_domain_out_of_scope_denied_in_scope_allowed() {
1878        if !seatbelt_is_supported() {
1879            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1880            return;
1881        }
1882        let allowed = unique_dir("allowed");
1883        let forbidden = unique_dir("forbidden");
1884        let cx = ctx(Caveats {
1885            // Absolute program path: the environment is scrubbed, so sandbox-exec
1886            // cannot resolve a bare name via PATH (see `wrap_argv`).
1887            exec: Scope::only(["/usr/bin/touch".to_string()]),
1888            fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
1889            ..Caveats::top()
1890        });
1891
1892        // Out of scope: the child's own write is kernel-denied → non-zero exit.
1893        let mut out = ConfinedCommand::new("/usr/bin/touch")
1894            .arg(forbidden.join("escape.txt"))
1895            .spawn(&cx)
1896            .expect("spawn");
1897        assert_eq!(out.sandbox_kind, SandboxKind::Seatbelt);
1898        let status = out.child.wait().expect("wait");
1899        assert!(
1900            !status.success(),
1901            "child write outside fs_write must be kernel-denied"
1902        );
1903        assert!(!forbidden.join("escape.txt").exists());
1904
1905        // In scope: the child write succeeds.
1906        let mut ok = ConfinedCommand::new("/usr/bin/touch")
1907            .arg(allowed.join("ok.txt"))
1908            .spawn(&cx)
1909            .expect("spawn");
1910        assert!(ok.child.wait().expect("wait").success());
1911        assert!(allowed.join("ok.txt").exists());
1912
1913        let _ = fs::remove_dir_all(&allowed);
1914        let _ = fs::remove_dir_all(&forbidden);
1915    }
1916
1917    /// Honesty (I9): a fully permissive grant confines *nothing*, so the Seatbelt
1918    /// wrapper applies nothing and the child must be reported `None`, never the raw
1919    /// backend kind. This is the regression for the original overclaim where
1920    /// `sandbox_kind` was the backend kind regardless of whether anything was
1921    /// confined.
1922    #[test]
1923    fn top_grant_confines_nothing_reports_none() {
1924        if !seatbelt_is_supported() {
1925            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1926            return;
1927        }
1928        let cx = ctx(Caveats::top());
1929        let child = ConfinedCommand::new("/usr/bin/true")
1930            .spawn(&cx)
1931            .expect("spawn");
1932        assert_eq!(
1933            child.sandbox_kind,
1934            SandboxKind::None,
1935            "nothing restricted => nothing confined => None, not the raw backend kind"
1936        );
1937    }
1938
1939    /// A restricted `exec` axis engages Seatbelt **even when both fs axes are
1940    /// `All`**: `process-exec*` kernel-confines the exec axis (ADR 0014), so
1941    /// reporting `Seatbelt` is honest, not an overclaim — the inverse of the
1942    /// `top_grant…` guard above. Before ADR 0014 this same grant reported `None`
1943    /// (the exec axis was left ambient).
1944    #[test]
1945    fn restricted_exec_engages_seatbelt() {
1946        if !seatbelt_is_supported() {
1947            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1948            return;
1949        }
1950        // exec restricted, both fs axes `All` — a grant a host might give an MCP
1951        // server: confine *what may run*, leave the filesystem ambient.
1952        let cx = ctx(Caveats {
1953            exec: Scope::only(["/usr/bin/true".to_string()]),
1954            ..Caveats::top()
1955        });
1956        let child = ConfinedCommand::new("/usr/bin/true")
1957            .spawn(&cx)
1958            .expect("spawn");
1959        assert_eq!(
1960            child.sandbox_kind,
1961            SandboxKind::Seatbelt,
1962            "a restricted exec axis is kernel-confined by process-exec* (ADR 0014)"
1963        );
1964    }
1965}