Skip to main content

agent_bridle_core/
sandbox.rs

1//! OS-level sandbox plumbing.
2//!
3//! The L3 boundary is the only layer that can confine a *permitted external
4//! program's own syscalls* once it has spawned — what neither the static
5//! decomposition (L1) nor the in-process interceptor (L2) can see. It is
6//! OS-specific, so each operating system gets its own backend behind one
7//! [`Sandbox`] trait, selected in code by [`best_available_sandbox`] (one
8//! `cfg(target_os, feature)` arm per backend, with a runtime capability probe),
9//! never overclaiming: a build either compiles a real backend for its host or
10//! falls back to the advisory [`NoopSandbox`] reporting [`SandboxKind::None`]
11//! (DESIGN §6, ADR 0001 L3, **ADR 0006** per-OS backends, **ADR 0009** the
12//! cross-platform strategy).
13//!
14//! - **Linux** — [`LandlockSandbox`] (`linux-landlock`): a real Landlock ruleset
15//!   confining the `fs_write` axis, and `fs_read` when restricted. `restrict_self`
16//!   confines the calling thread (inherited across `fork`/`execve`). Direct
17//!   execute rules narrow `execve` but do not close the loader trampoline, so
18//!   `exec` remains honestly `Interceptor`; ABI-v4 kernels can kernel-deny all
19//!   TCP egress for an empty `net` scope.
20//! - **macOS** — [`SeatbeltSandbox`] (`macos-seatbelt`): an SBPL profile derived
21//!   from the effective [`Caveats`], applied by wrapping the spawned program in
22//!   `sandbox-exec(1)` (no FFI — core forbids `unsafe`). Confines both filesystem
23//!   axes, restricted `exec`, and empty or loopback-only `net` scopes. General
24//!   remote-host allowlists use the separately fenced proxy path and remain
25//!   conservatively reported at their userspace strength.
26//! - **Windows** — [`SandboxKind::AppContainer`] (`windows-appcontainer`): a
27//!   process-creation wrapper applies filesystem DACLs, deny-all or loopback-only
28//!   network policy, and the kernel child-process block for `exec: Only([])`.
29//!   Non-empty exec allowlists cannot be expressed without WDAC and stay
30//!   `Interceptor`.
31//!
32//! A backend confines either by restricting the calling thread in [`Sandbox::apply`]
33//! (Landlock) **or** by wrapping the spawned command via
34//! [`Sandbox::command_prefix`] (Seatbelt/AppContainer); a spawn site honors both,
35//! so the mechanism is uniform at the call site.
36
37use crate::{Caveats, SandboxPolicy, ToolResult};
38use std::sync::Arc;
39
40/// Which OS-level sandbox actually backs an authorization.
41///
42/// Recorded in every [`crate::ToolContext`] and surfaced in every result
43/// envelope so callers can tell whether the leash is kernel-enforced or merely
44/// advisory.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum SandboxKind {
48    /// A real Landlock ruleset is active (Linux). Kernel-enforced.
49    Landlock,
50    /// A real Seatbelt (`sandbox-exec` SBPL) profile is active (macOS).
51    /// Kernel-enforced against the spawned program's interior.
52    Seatbelt,
53    /// A real AppContainer token is active (Windows). Kernel-enforced.
54    AppContainer,
55    /// A Linux **minimal-rootfs mount-namespace jail** is active (ADR 0013 D3/D4,
56    /// agent-bridle#109/#108). The process runs in a `pivot_root` jail that
57    /// physically contains only the granted program files, so `exec` is
58    /// kernel-confined by **identity** — no un-granted binary *exists* to run or to
59    /// `ld.so`-trampoline into (ADR 0011 D7's precondition is now physically true,
60    /// not asserted) — and the filesystem axes are kernel-confined by the
61    /// read-only/read-write bind-mounts. Network is not namespaced at this tier, so
62    /// `net` stays advisory (never overclaimed). Reserved for the minimal-rootfs
63    /// mode: a Landlock-only boundary run stays [`SandboxKind::Landlock`] (its exec
64    /// axis is held — ADR 0011).
65    MinimalRootfs,
66    /// A Linux **Tier-2 micro-VM** is active (ADR 0013 D3, ADR 0009 D2,
67    /// agent-bridle#111): the same minimal rootfs booted as a qemu guest under a
68    /// separate kernel. Identity is closed as in [`SandboxKind::MinimalRootfs`]
69    /// (only the granted program exists in the guest) and the filesystem is confined
70    /// by the guest boundary; with no guest network device, egress is impossible —
71    /// so `exec`, the fs axes, **and** `net` are all kernel-confined, and a
72    /// guest-kernel compromise is still contained. The strongest tier.
73    MicroVm,
74    /// No OS-level sandbox — the leash is in-process/advisory only. This is the
75    /// honest default on a host with no compiled-and-capable backend.
76    #[default]
77    None,
78}
79
80/// An OS-level confinement that can be applied from a set of [`Caveats`].
81///
82/// Implementations translate the lattice's `fs_read`/`fs_write`/`exec`/`net`
83/// axes into the kernel rules their native backend can honestly express.
84pub trait Sandbox: Send + Sync {
85    /// The kind of confinement this sandbox provides.
86    fn kind(&self) -> SandboxKind;
87
88    /// Apply the confinement for the given effective caveats. Called by a tool
89    /// *before* it does any privileged work, on the thread/process that will do
90    /// it. A `Noop` implementation succeeds without restricting anything.
91    ///
92    /// This is the confinement mechanism for *thread-confining* backends
93    /// (Landlock's `restrict_self`). *Wrapper-based* backends (macOS Seatbelt)
94    /// confine via [`Sandbox::command_prefix`] instead and make this a no-op.
95    fn apply(&self, effective: &Caveats) -> ToolResult<()>;
96
97    /// The argv prefix that wraps a child so a *wrapper-based* L3 backend
98    /// confines it (macOS `sandbox-exec`). The returned vector, prepended to a
99    /// `(program, args…)`, is the argv that must actually be spawned.
100    ///
101    /// Backends that confine the spawning thread in [`Sandbox::apply`]
102    /// (Landlock) or that do not confine ([`NoopSandbox`]) return an **empty**
103    /// prefix. A spawn site applies *both* `apply()` and this prefix, so either
104    /// mechanism is honored without the caller knowing which backend is active.
105    ///
106    /// **Fail-closed:** a backend that is selected but cannot build its wrapper
107    /// (e.g. the wrapper binary is missing) returns `Err` — never an empty
108    /// (silently unconfined) prefix. The default is the empty prefix.
109    fn command_prefix(&self, effective: &Caveats) -> ToolResult<Vec<String>> {
110        let _ = effective;
111        Ok(Vec::new())
112    }
113}
114
115/// The no-backend sandbox: applies nothing and reports [`SandboxKind::None`].
116///
117/// This is the honest fallback when no compiled native backend is capable or
118/// when the effective caveats engage no axis that the available backend governs.
119#[derive(Debug, Default, Clone, Copy)]
120pub struct NoopSandbox;
121
122impl Sandbox for NoopSandbox {
123    fn kind(&self) -> SandboxKind {
124        SandboxKind::None
125    }
126
127    fn apply(&self, _effective: &Caveats) -> ToolResult<()> {
128        // Intentionally a no-op: the advisory default. Real kernel enforcement
129        // lives in `LandlockSandbox` (Linux + `linux-landlock`).
130        Ok(())
131    }
132}
133
134/// `true` if either filesystem axis is actually restricted (`Only(_)`) — the
135/// condition under which the fs-confining backends (Landlock, Seatbelt) have
136/// something to enforce. When **no** fs axis is restricted, an fs-only backend
137/// governs nothing, so honest reporting downgrades the [`SandboxKind`] to
138/// [`SandboxKind::None`] rather than overclaiming a boundary that confines
139/// nothing (I9 / ADR 0006 D3). Used by every spawn site that reports a kind.
140#[must_use]
141pub(crate) fn restricts_fs(caveats: &Caveats) -> bool {
142    matches!(caveats.fs_write, crate::Scope::Only(_))
143        || matches!(caveats.fs_read, crate::Scope::Only(_))
144}
145
146/// `true` if the `exec` axis is actually restricted (`Only(_)`). Seatbelt acts on
147/// every such scope via `process-exec*`, including the spawned program's
148/// interior execs (ADR 0014), so `exec: Only(_)` engages it by itself.
149/// AppContainer separately handles the deny-all subset via
150/// [`exec_fully_denied`]. Landlock narrows direct `execve` when another governed
151/// axis engages it, but its loader-trampoline residual keeps the reported exec
152/// strength at `Interceptor`, so exec restriction alone does not engage it.
153#[must_use]
154pub(crate) fn restricts_exec(caveats: &Caveats) -> bool {
155    matches!(caveats.exec, crate::Scope::Only(_))
156}
157
158/// `true` if the `net` axis is restricted to the **empty** set — i.e. *all*
159/// network egress is denied. Seatbelt and AppContainer enforce this scope, and a
160/// Landlock ABI-v4 kernel can deny all TCP egress. A general non-empty hostname
161/// allowlist is not directly expressible by those native rules and follows the
162/// separately documented proxy/advisory path.
163#[must_use]
164pub(crate) fn net_fully_denied(caveats: &Caveats) -> bool {
165    matches!(&caveats.net, crate::Scope::Only(s) if s.is_empty())
166}
167
168/// An explicit path-named Unix endpoint is distinct from a DNS host. The exact
169/// token participates in the ordinary net-scope meet; filesystem grants never
170/// imply permission to connect. Only Seatbelt projects this endpoint authority.
171pub(crate) fn has_unix_socket_grants(caveats: &Caveats) -> bool {
172    matches!(&caveats.net, crate::Scope::Only(s) if s.iter().any(|s| s.starts_with("unix:")))
173}
174
175pub(crate) fn net_unix_only(caveats: &Caveats) -> bool {
176    matches!(&caveats.net, crate::Scope::Only(s)
177        if !s.is_empty() && s.iter().all(|s| s.starts_with("unix:")))
178}
179
180/// `true` when the `exec` axis is a deny-all empty allow-list (`Scope::Only([])`).
181///
182/// An empty allow-list means *no program may be spawned* — any `exec` call is
183/// refused. On Windows AppContainer this maps to the
184/// `PROCESS_CREATION_CHILD_PROCESS_RESTRICTED` kernel mitigation, so the
185/// sandboxed process cannot create child processes at the kernel level.
186#[must_use]
187pub(crate) fn exec_fully_denied(caveats: &Caveats) -> bool {
188    matches!(&caveats.exec, crate::Scope::Only(s) if s.is_empty())
189}
190
191/// `true` if this kernel supports Landlock TCP network rules (ABI V4, kernel ≥ 6.7).
192/// Always `false` on non-Linux or builds without `linux-landlock`.
193#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
194pub(crate) fn landlock_net_capable() -> bool {
195    landlock_impl::landlock_net_is_supported()
196}
197#[cfg(not(all(target_os = "linux", feature = "linux-landlock")))]
198pub(crate) fn landlock_net_capable() -> bool {
199    false
200}
201
202/// The host tokens that name the machine's own **loopback interface**. SBPL's
203/// `(remote ip "localhost:*")` filter matches exactly these destinations
204/// (`127.0.0.1` and `::1`) — empirically the *only* remote a non-empty SBPL net
205/// rule can name (an arbitrary IP is rejected: "host must be * or localhost").
206pub(crate) const LOOPBACK_HOSTS: &[&str] = &["localhost", "127.0.0.1", "::1"];
207
208/// `true` if the `net` axis is restricted to a **non-empty** allow-list whose
209/// every host is a [loopback identifier](LOOPBACK_HOSTS) — the one non-deny-all
210/// net policy SBPL *can* kernel-enforce (`(deny network*)` + `(allow network*
211/// (remote ip "localhost:*"))`), confining egress to the loopback interface so the
212/// process's **own off-box socket egress is kernel-denied** (ADR 0015; the
213/// system-resolver DNS residual is shared with the empty-net case). A general remote
214/// host cannot be named in SBPL (only `*`/`localhost` + ports), so a mixed or
215/// non-loopback allow-list is **not** loopback-only and stays advisory — never
216/// silently dropped. Mutually exclusive with [`net_fully_denied`] (empty set).
217///
218/// The kernel rule confines egress to the loopback *interface* — `localhost` =
219/// `127.0.0.1` **and** `::1`, the finest grain SBPL can name. For a **spawned
220/// child** (governed only by the kernel rule, not the in-process leash) that
221/// interface *is* the boundary, so a grant naming a single loopback address
222/// (e.g. `127.0.0.1`) still permits the other (`::1`) — a widening strictly
223/// *within* loopback, never off-box. Admission (`ToolContext::check_net`,
224/// exact-match) narrows to the granted host for the engine's *own* operations.
225/// Unlike the fs `(subpath root)` case — where the kernel subtree and the granted
226/// root denote the same set — the loopback interface can exceed a single-address
227/// grant; see ADR 0015 D2.
228/// Explicit `unix:` endpoints may coexist; they are separate exact outbound
229/// exceptions under Seatbelt, never additional IP authority. Other backends
230/// refuse scopes containing those endpoints at admission.
231#[must_use]
232pub(crate) fn net_loopback_only(caveats: &Caveats) -> bool {
233    matches!(&caveats.net, crate::Scope::Only(s)
234        if s.iter().any(|h| LOOPBACK_HOSTS.contains(&h.as_str()))
235            && s.iter().all(|h| h.starts_with("unix:") || LOOPBACK_HOSTS.contains(&h.as_str())))
236}
237
238/// The granted host set of a **general remote-host** `net` allow-list — the case
239/// SBPL cannot express and [`net_loopback_only`] therefore leaves advisory
240/// (ADR 0015 D3). `Some(hosts)` iff `net` is `Only(set)`, non-empty, with **at
241/// least one non-loopback host**; `None` for `All`, the empty set (deny-all), and
242/// a loopback-only allow-list — those three keep their existing owners
243/// ([`net_fully_denied`] / [`net_loopback_only`]).
244///
245/// This is the trigger for the macOS **egress-proxy** mechanism (#124, ADR 0016):
246/// a caller confines a spawned child's egress to the loopback interface
247/// ([`loopback_fenced_caveats`], reusing the ADR 0015 kernel fence) and runs a
248/// loopback forward proxy that enforces this host set. Pure; no IO. The returned
249/// set is the full **IP host** grant (loopback members included), matching
250/// `ToolContext::check_net`'s exact-name membership. Explicit `unix:` endpoints
251/// are not DNS hosts and remain exclusively in the child's kernel profile.
252#[must_use]
253pub fn net_egress_proxy_hosts(caveats: &Caveats) -> Option<Vec<String>> {
254    match &caveats.net {
255        crate::Scope::Only(s)
256            if s.iter()
257                .any(|h| !h.starts_with("unix:") && !LOOPBACK_HOSTS.contains(&h.as_str())) =>
258        {
259            Some(
260                s.iter()
261                    .filter(|h| !h.starts_with("unix:"))
262                    .cloned()
263                    .collect(),
264            )
265        }
266        _ => None,
267    }
268}
269
270/// The confinement caveats for a spawned child paired with a loopback **egress
271/// proxy** (#124, ADR 0016): identical to `caveats` except the `net` axis is
272/// replaced by the loopback set plus any explicit `unix:` endpoints, so its
273/// [`seatbelt_profile`] emits the ADR 0015
274/// kernel fence — `(deny network*)` + `(allow network* (remote ip
275/// "localhost:*"))` — while the `fs`/`exec` rules are preserved verbatim. The
276/// child can then reach *nothing* off-box directly; its only path off the
277/// loopback interface is the proxy it is pointed at via `*_PROXY` env. Pure; no
278/// IO. Only meaningful for a grant where [`net_egress_proxy_hosts`] is `Some`.
279#[must_use]
280pub fn loopback_fenced_caveats(caveats: &Caveats) -> Caveats {
281    let mut endpoints: std::collections::BTreeSet<String> =
282        LOOPBACK_HOSTS.iter().map(|h| (*h).to_string()).collect();
283    if let crate::Scope::Only(names) = &caveats.net {
284        endpoints.extend(names.iter().filter(|h| h.starts_with("unix:")).cloned());
285    }
286    Caveats {
287        net: crate::Scope::Only(endpoints),
288        ..caveats.clone()
289    }
290}
291
292/// The egress-proxy plan for `caveats` (#124/#257, ADR 0016), or `None` to fall
293/// through to the ordinary confinement paths. `Some((allow_hosts, fenced))`
294/// **iff** the grant is a general remote-host `net` allow-list
295/// ([`net_egress_proxy_hosts`]) *and* the available backend can kernel-fence the
296/// child's egress **to the loopback interface** ([`loopback_net_enforceable`]) —
297/// the precondition for the proxy to be real confinement instead of a
298/// walk-around-able advisory. A proxy a rogue child can dial around is not
299/// confinement, so backends that cannot address-fence stay inert (the ADR 0015
300/// honest posture); their `net` remains honestly advisory.
301///
302/// The ONE decision both consumers route through — the shell engine's
303/// proxied-pipeline path and `ConfinedCommand::spawn_tokio` (#257) — so the
304/// check and the spawn routing cannot disagree.
305#[must_use]
306pub fn egress_proxy_plan(
307    caveats: &Caveats,
308    policy: &Arc<SandboxPolicy>,
309) -> Option<(Vec<String>, Caveats)> {
310    egress_proxy_plan_for(best_available_sandbox(policy).kind(), caveats)
311}
312
313/// The egress-proxy plan given an **already-resolved** available backend
314/// `kind` — the pure, host-independent core of [`egress_proxy_plan`], split out
315/// so the enforceability decision can be unit-tested against each backend
316/// deterministically (the fail-open at #257/#275 hid behind a host-only path).
317pub(crate) fn egress_proxy_plan_for(
318    available: SandboxKind,
319    caveats: &Caveats,
320) -> Option<(Vec<String>, Caveats)> {
321    if has_unix_socket_grants(caveats) && available != SandboxKind::Seatbelt {
322        return None; // No other backend projects exact Unix endpoint authority.
323    }
324    let allow_hosts = net_egress_proxy_hosts(caveats)?;
325    // Engage the proxy ONLY where the child's egress can be kernel-fenced to
326    // loopback. Checking merely that the sandbox confines *something* (as the
327    // pre-fix gate did via `effective_sandbox_kind != None`) is a fail-open:
328    // Landlock engages on the *fs* axis (`restricts_fs`) while its `net` fence is
329    // port-based and cannot confine a loopback-only host set (`apply` sets
330    // `confine_net = net_fully_denied` only) — so under a general remote-host
331    // grant with restricted fs on Linux, the proxy would start, the child would
332    // be handed `*_PROXY`, yet the child could ignore it and dial any host
333    // directly (exfil unblocked AND unrecorded). That is the exact "proxy a rogue
334    // child can walk around" this must never engage. See [`loopback_net_enforceable`].
335    if !loopback_net_enforceable(available) {
336        return None; // net-loopback fence unenforceable → advisory, no proxy
337    }
338    Some((allow_hosts, loopback_fenced_caveats(caveats)))
339}
340
341/// Whether `available` can kernel-fence a spawned child's egress to the
342/// **loopback interface** — the precondition for the egress-proxy pattern
343/// (ADR 0016) to be real confinement rather than an advisory a child can dial
344/// around. True only for the address-fenceable backends:
345/// - [`SandboxKind::Seatbelt`] — SBPL `(allow network* (remote ip "localhost:*"))`.
346/// - [`SandboxKind::AppContainer`] — `NetworkIsolation` loopback exemption (#133).
347///
348/// False for the rest, each honestly advisory on `net` for a loopback-only set:
349/// - [`SandboxKind::Landlock`] — TCP rules are **port-based**, not address-based
350///   (ADR 0014/0015); it can deny *all* egress (`net: none`) but cannot admit
351///   only loopback. **The Linux enabler is the network-namespace egress fence**
352///   (netns + veth-to-parent proxy) tracked separately — until it lands, a
353///   remote-host `net` grant on Linux is advisory, not proxy-fenced.
354/// - [`SandboxKind::MinimalRootfs`] — net is not namespaced at this tier.
355/// - [`SandboxKind::MicroVm`] — no guest network device: egress is impossible, so
356///   the loopback proxy has no path anyway (net is confined by absence, not proxy).
357/// - [`SandboxKind::None`] — no backend.
358#[must_use]
359const fn loopback_net_enforceable(available: SandboxKind) -> bool {
360    matches!(available, SandboxKind::Seatbelt | SandboxKind::AppContainer)
361}
362
363/// The [`SandboxKind`] honestly in force for `caveats` given the strongest
364/// `available` backend: the backend's own kind when it will actually confine
365/// *something*, else [`SandboxKind::None`]. The single honesty rule shared by the
366/// subprocess primitive ([`crate::ConfinedCommand`]) and the shell engine, so
367/// neither overclaims.
368///
369/// Capabilities differ per backend, so the engaging condition does too: Landlock
370/// governs the filesystem axes; Seatbelt governs those, kernel-denies all egress
371/// when `net` is empty ([`net_fully_denied`]) or confines it to the loopback
372/// interface for a loopback-only allow-list ([`net_loopback_only`], ADR 0015),
373/// **and** confines the `exec` axis via `process-exec*` ([`restricts_exec`]) — a
374/// confinement Landlock cannot supply (ADR 0014). Landlock's exec axis stays held
375/// (agent-bridle#31/#57), so a Landlock host does not engage on `exec` alone.
376/// AppContainer (Windows, #51 / #123 / #133) engages when: `net` is fully denied
377/// (deny-all capability model), `net` is loopback-only (egress-proxy fence, ADR 0016),
378/// `exec` is fully denied (`PROCESS_CREATION_CHILD_PROCESS_RESTRICTED`, ADR 0013 D7),
379/// or `fs` is restricted (per-path DACL grants, ADR 0009).
380#[must_use]
381pub fn effective_sandbox_kind(available: SandboxKind, caveats: &Caveats) -> SandboxKind {
382    match available {
383        SandboxKind::Landlock
384            if restricts_fs(caveats) || (net_fully_denied(caveats) && landlock_net_capable()) =>
385        {
386            SandboxKind::Landlock
387        }
388        SandboxKind::Seatbelt
389            if restricts_fs(caveats)
390                || net_fully_denied(caveats)
391                || net_loopback_only(caveats)
392                || has_unix_socket_grants(caveats)
393                || restricts_exec(caveats) =>
394        {
395            SandboxKind::Seatbelt
396        }
397        SandboxKind::AppContainer
398            if net_fully_denied(caveats)
399                || net_loopback_only(caveats)
400                || exec_fully_denied(caveats)
401                || restricts_fs(caveats) =>
402        {
403            SandboxKind::AppContainer
404        }
405        _ => SandboxKind::None,
406    }
407}
408
409/// Return the strongest [`Sandbox`] available in this build on this host.
410///
411/// One `cfg(target_os, feature)` arm per backend (ADR 0006 D2): Landlock probes
412/// kernel support at runtime; Seatbelt probes for `sandbox-exec`; AppContainer
413/// uses its process-launch wrapper on Windows. Otherwise the advisory
414/// [`NoopSandbox`] is selected, so callers get a real native boundary where one
415/// is available and an honest [`SandboxKind::None`] where it is not. Enabling a
416/// backend feature off its target OS compiles and selects no target-specific
417/// implementation.
418pub fn best_available_sandbox(policy: &Arc<SandboxPolicy>) -> Box<dyn Sandbox> {
419    #[cfg(all(target_os = "windows", feature = "windows-appcontainer"))]
420    {
421        let _ = policy; // AppContainer configures per-process via the launcher.
422        Box::new(appcontainer_impl::AppContainerSandbox::new())
423    }
424
425    #[cfg(not(all(target_os = "windows", feature = "windows-appcontainer")))]
426    {
427        #[cfg(all(target_os = "linux", feature = "linux-landlock"))]
428        {
429            if landlock_impl::landlock_is_supported() {
430                return Box::new(landlock_impl::LandlockSandbox::with_policy(policy.clone()));
431            }
432        }
433        #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
434        {
435            if seatbelt_impl::seatbelt_is_supported() {
436                return Box::new(seatbelt_impl::SeatbeltSandbox::with_policy(policy.clone()));
437            }
438        }
439        let _ = policy; // NoopSandbox is unconfigurable (advisory).
440        Box::new(NoopSandbox)
441    }
442}
443
444#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
445pub use landlock_impl::{landlock_is_supported, landlock_net_is_supported, LandlockSandbox};
446
447#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
448pub use seatbelt_impl::{seatbelt_is_supported, SeatbeltSandbox};
449
450// Prefix construction is portable; unit tests exercise its admission decisions
451// on every host. Native Windows enforcement remains in the Windows proof lane.
452#[cfg(any(test, all(target_os = "windows", feature = "windows-appcontainer")))]
453pub(crate) mod appcontainer_impl {
454    use std::sync::atomic::{AtomicU64, Ordering};
455
456    use super::{
457        exec_fully_denied, net_fully_denied, net_loopback_only, restricts_fs, Sandbox, SandboxKind,
458    };
459    use crate::{Caveats, Scope, ToolError, ToolResult};
460
461    /// Monotonic counter for unique container names (PID + counter → no clock).
462    static SPAWN_N: AtomicU64 = AtomicU64::new(0);
463
464    /// A Windows AppContainer process sandbox.
465    ///
466    /// AppContainer is attached when creating a new process via
467    /// `PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES`; it cannot be installed on
468    /// the current thread and inherited across a later spawn the way Landlock
469    /// can. The spawn path must therefore use the `agent-bridle-aclaunch`
470    /// wrapper binary returned by [`command_prefix`] rather than the thread
471    /// `apply` path (ADR 0006 / agent-bridle#51).
472    ///
473    /// Calling [`Sandbox::apply`] directly fails closed: it is never correct to
474    /// call `apply` expecting AppContainer confinement on the current thread.
475    #[derive(Debug, Default, Clone, Copy)]
476    pub struct AppContainerSandbox;
477
478    impl AppContainerSandbox {
479        /// Construct the sandbox. (Stateless; confinement is per-process.)
480        pub fn new() -> Self {
481            Self
482        }
483    }
484
485    /// Return the path of `agent-bridle-aclaunch.exe`, searching first next to
486    /// the current executable and then via `PATH`.
487    fn find_launcher() -> Option<String> {
488        const LAUNCHER: &str = "agent-bridle-aclaunch.exe";
489
490        // Same directory as the current exe — the normal install layout.
491        if let Ok(mut p) = std::env::current_exe() {
492            p.set_file_name(LAUNCHER);
493            if p.exists() {
494                return Some(p.to_string_lossy().into_owned());
495            }
496        }
497        // Fall back to PATH.
498        std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
499            .map(|dir| dir.join(LAUNCHER))
500            .find(|p| p.exists())
501            .map(|p| p.to_string_lossy().into_owned())
502    }
503
504    impl Sandbox for AppContainerSandbox {
505        fn kind(&self) -> SandboxKind {
506            SandboxKind::AppContainer
507        }
508
509        /// No-op: AppContainer confinement is applied at process creation via the
510        /// `command_prefix` launcher wrapper (`agent-bridle-aclaunch`), not via
511        /// this thread. `apply` is reached only when `command_prefix` returned an
512        /// empty prefix (nothing to confine), so a no-op is correct here.
513        fn apply(&self, _effective: &Caveats) -> ToolResult<()> {
514            Ok(())
515        }
516
517        /// Build the `["agent-bridle-aclaunch.exe", ...]` prefix that wraps the
518        /// child inside a fresh AppContainer profile.
519        ///
520        /// Returns an empty prefix when nothing on a governed axis is restricted
521        /// (so the spawn runs unwrapped — the backend engages only when it
522        /// actually confines something). Fails closed if the launcher binary is
523        /// not found.
524        fn command_prefix(&self, effective: &Caveats) -> ToolResult<Vec<String>> {
525            if super::has_unix_socket_grants(effective) {
526                return Err(ToolError::denied(
527                    "windows-appcontainer: exact Unix endpoint grants require Seatbelt",
528                ));
529            }
530            // The launcher engages when:
531            //  - net is fully denied (deny-by-default network policy)
532            //  - net is loopback-only (egress proxy path, #133)
533            //  - exec is fully denied (kernel child-process-creation block)
534            //  - fs is restricted (ACL grants let the container reach its workspace)
535            if !net_fully_denied(effective)
536                && !net_loopback_only(effective)
537                && !exec_fully_denied(effective)
538                && !restricts_fs(effective)
539            {
540                return Ok(Vec::new());
541            }
542
543            // Fail-closed: without the launcher we cannot enforce.
544            let launcher = find_launcher().ok_or_else(|| {
545                ToolError::denied(
546                    "windows-appcontainer: agent-bridle-aclaunch.exe not found next to the \
547                     current executable or on PATH; cannot confine",
548                )
549            })?;
550
551            // Unique container name: PID + monotonic counter (no wall clock).
552            let n = SPAWN_N.fetch_add(1, Ordering::Relaxed);
553            let container_name = format!("ab{}{}", std::process::id(), n);
554
555            let mut prefix = vec![launcher, "--name".to_string(), container_name];
556
557            // Grant network capabilities only when net is fully unrestricted
558            // (Scope::All). Any non-All net scope denies egress by default via
559            // the AppContainer's deny-by-default network policy.
560            if matches!(effective.net, Scope::All) {
561                prefix.push("--net-allow".to_string());
562            }
563
564            // Loopback-only fence (#133, ADR 0016): AppContainers block loopback
565            // by default. For the egress-proxy pattern the child must reach the
566            // parent's loopback proxy, so grant the loopback exemption via the
567            // NetworkIsolationSetAppContainerConfig API.
568            if net_loopback_only(effective) {
569                prefix.push("--loopback-exemption".to_string());
570            }
571
572            // Kernel-block child process creation when exec is fully denied.
573            // The `--no-child-process` flag sets PROCESS_CREATION_CHILD_PROCESS_RESTRICTED
574            // on the spawned process — the kernel refuses any CreateProcess call
575            // it makes, closing the exec axis by OS enforcement (#123).
576            if exec_fully_denied(effective) {
577                prefix.push("--no-child-process".to_string());
578            }
579
580            // FS ACL narrowing (#51): grant the AppContainer SID access to the
581            // allowed paths so the container can read/write its workspace.
582            // AppContainers are denied user directories by default; without this
583            // grant the child cannot access its working directory.
584            if let Scope::Only(paths) = &effective.fs_write {
585                for p in paths {
586                    prefix.push("--fs-write".to_string());
587                    prefix.push(p.clone());
588                }
589            }
590            // Read-only paths that are not already covered by fs_write.
591            let write_set: std::collections::HashSet<&str> =
592                if let Scope::Only(paths) = &effective.fs_write {
593                    paths.iter().map(String::as_str).collect()
594                } else {
595                    std::collections::HashSet::new()
596                };
597            if let Scope::Only(paths) = &effective.fs_read {
598                for p in paths {
599                    if !write_set.contains(p.as_str()) {
600                        prefix.push("--fs-read".to_string());
601                        prefix.push(p.clone());
602                    }
603                }
604            }
605
606            Ok(prefix)
607        }
608    }
609}
610
611#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
612pub(crate) mod landlock_impl {
613    use super::{Sandbox, SandboxKind};
614    use crate::{Caveats, ChildNetworkPolicy, SandboxPolicy, Scope, ToolError, ToolResult};
615    use landlock::{
616        path_beneath_rules, Access, AccessFs, AccessNet, CompatLevel, Compatible, Ruleset,
617        RulesetAttr, RulesetCreatedAttr, RulesetStatus, ABI,
618    };
619    use std::sync::Arc;
620
621    /// Map a configured ABI floor to the landlock `ABI` enum. `apply` runs
622    /// `BestEffort`, so a floor above the running kernel still degrades
623    /// gracefully; unknown/too-high values clamp to the highest ABI this crate
624    /// (landlock 0.4.5) models — V7 — so raising a floor to reach a newer axis
625    /// (e.g. `IoctlDev` at V5) is honored, not silently dropped to V4. The
626    /// default floors (fs 3 / net 4) reproduce the previous `ABI::V3` / `ABI::V4`
627    /// constants.
628    ///
629    /// The *lower* bound is deliberately NOT enforced here: it is axis-specific
630    /// and applied at the call site via [`fs_abi_floor`] / [`net_abi_floor`],
631    /// because fs and net have different safe minimums below which the honesty
632    /// report would overclaim.
633    fn abi_from_u32(v: u32) -> ABI {
634        match v {
635            0 | 1 => ABI::V1,
636            2 => ABI::V2,
637            3 => ABI::V3,
638            4 => ABI::V4,
639            5 => ABI::V5,
640            6 => ABI::V6,
641            _ => ABI::V7,
642        }
643    }
644
645    /// The fs-axis ABI floor actually installed — never below V3 (the default).
646    ///
647    /// Security-critical clamp: a configured `landlock_abi_floor` below 3 would
648    /// drop `Refer` (V2) / `Truncate` (V3) from the governed write set, letting a
649    /// confined child `truncate`/`rename` files OUTSIDE its `fs_write` scope while
650    /// [`crate::enforcement_report`] still reports `fs_write = Kernel` — a silent
651    /// weakening *and* an overclaim. Lowering a floor has no legitimate use
652    /// (`BestEffort` already degrades on genuinely older kernels), so we clamp up
653    /// to the claimed baseline rather than honor a weakening. Raising above the
654    /// default stays allowed (explicit opt-in hardening).
655    fn fs_abi_floor(policy: &SandboxPolicy) -> ABI {
656        abi_from_u32(policy.landlock_abi_floor.max(3))
657    }
658
659    /// The net-axis ABI floor actually installed — never below V4 (the default).
660    ///
661    /// TCP net rights first exist at V4, so a configured `landlock_net_abi_floor`
662    /// below 4 makes `AccessNet::from_all` EMPTY; under `BestEffort`,
663    /// `handle_access` of an empty set governs nothing, silently dropping a
664    /// requested deny-all-egress even on a capable (≥ 6.7) kernel while the report
665    /// claims `net = Kernel`. Clamp up to V4 for the same reason as
666    /// [`fs_abi_floor`].
667    fn net_abi_floor(policy: &SandboxPolicy) -> ABI {
668        abi_from_u32(policy.landlock_net_abi_floor.max(4))
669    }
670
671    /// `true` if this kernel can enforce a Landlock ruleset.
672    ///
673    /// Non-destructive: it creates (but never `restrict_self`s) a throwaway
674    /// ruleset under `HardRequirement`, so an unsupported kernel surfaces as
675    /// `Err` rather than being silently swallowed by best-effort.
676    pub fn landlock_is_supported() -> bool {
677        Ruleset::default()
678            .set_compatibility(CompatLevel::HardRequirement)
679            .handle_access(AccessFs::from_all(ABI::V1))
680            .and_then(|r| r.create())
681            .is_ok()
682    }
683
684    /// `true` if this kernel supports Landlock TCP network rules (ABI V4,
685    /// kernel ≥ 6.7). Probed non-destructively — creates but never
686    /// `restrict_self`s a throwaway ruleset. This is the *capability* threshold
687    /// (TCP rules first appear at V4), distinct from the configurable request
688    /// floor in [`abi_from_u32`].
689    pub fn landlock_net_is_supported() -> bool {
690        Ruleset::default()
691            .set_compatibility(CompatLevel::HardRequirement)
692            .handle_access(AccessNet::from_all(ABI::V4))
693            .and_then(|r| r.create())
694            .is_ok()
695    }
696
697    // The Landlock read/exec allow-lists now live in `SandboxPolicy`
698    // (config.rs) and are read from `self.policy` in `apply`. Their security
699    // rationale is unchanged (ADR 0011 D3/D7):
700    //
701    // - `base_read_paths`: the loader/library trees + system DATA a permitted,
702    //   dynamically-linked program needs to start — but NOT the executable dirs
703    //   (`/usr/bin`, `/bin`, `/sbin`). Keeping bin dirs out of the read set
704    //   shrinks the loader-trampoline corpus: `/usr/bin/curl` is unreadable and
705    //   so cannot be `mmap`-exec'd via `ld.so`. This shrinks, but does not close,
706    //   the trampoline (`/usr/lib` still hides interpreters), so `exec` stays
707    //   `interceptor`, never `kernel`. `/etc` is never granted wholesale.
708    // - `bin_read_paths`: executable dirs, read-allowed ONLY when `exec` is
709    //   ambient (`All`); when `exec` is confined the granted binaries are added
710    //   by resolved path instead, narrowing the corpus to exactly them.
711    // - `loader_paths`: the dynamic linker(s) only — specific FILES, never
712    //   directories (a `path_beneath` dir grant would expose every ELF beneath
713    //   `/usr/lib` via the merged-usr symlink, defeating the exec axis).
714    //
715    // The `PathList` shrink-guard (config) means an operator can *widen* these
716    // (disclosed) but can only *remove* an entry with an explicit `replace=true`.
717
718    /// A real, kernel-enforced Landlock sandbox (Linux).
719    ///
720    /// **The `fs_write`, `fs_read`, and `exec` axes.** Writes are always governed
721    /// (from `fs_write`); reads are governed only when `fs_read` is *restricted*
722    /// (`Only(_)`), in which case the granted read roots plus the configured
723    /// `base_read_paths` are read-allowed and everything else is denied — so a
724    /// permitted external program cannot read user data outside `fs_read` (closing
725    /// `grep -f /etc/shadow`-style reads) yet can still load its libraries.
726    ///
727    /// `Execute` is governed only when `exec` is restricted: the *resolved*
728    /// granted program files plus the configured `loader_paths` (the dynamic linker only — never
729    /// library directories, which `path_beneath` would make recursively executable
730    /// and expose `/usr/lib`'s interpreters) are execute-allowed and all else
731    /// denied. This kernel-denies a **direct** `execve` of a different, un-granted
732    /// tool (`find -exec curl`, a written/symlinked payload, a shebang to an
733    /// un-granted interpreter) — the ADR 0011 boundary increment.
734    ///
735    /// It does **not** close the loader/interpreter *trampoline*: with reads
736    /// allow-listed, `ld.so` can `mmap`-exec any readable ELF, and a granted
737    /// interpreter runs arbitrary in-process code — neither is an `execve` the
738    /// `Execute` rule sees (ADR 0011 D2; Landlock has no `mmap` hook). So this is
739    /// the filesystem **boundary** + direct-execve denial, **not** program
740    /// identity — the per-axis report therefore keeps `exec → interceptor`, never
741    /// `kernel` (ADR 0011 D7); a strong principal still fails closed on a
742    /// restricted `exec` (ADR 0012 D4, already wired). The trampoline-tight close
743    /// (narrowed read base + W^X + seccomp `execve`/namespace deny, or a
744    /// micro-VM rootfs) is the Tier-2 follow-up (#57 / ADR 0009). When an axis is
745    /// `All` it stays ambient. On ABI-v4 kernels an empty `net` scope additionally
746    /// installs a deny-all TCP ruleset; hostname allowlists remain inexpressible.
747    ///
748    /// `restrict_self` is per-thread and irreversible, and is inherited across
749    /// `fork`/`execve`. Callers must therefore call [`Sandbox::apply`] on the
750    /// very thread that will spawn the confined work, immediately before the
751    /// spawn.
752    #[derive(Debug, Default, Clone)]
753    pub struct LandlockSandbox {
754        /// The read/exec allow-lists + ABI floors this backend enforces (I5-B).
755        policy: Arc<SandboxPolicy>,
756    }
757
758    impl LandlockSandbox {
759        /// Construct with the built-in defaults (today's allow-lists).
760        pub fn new() -> Self {
761            Self::default()
762        }
763
764        /// Construct configured with an operator-supplied [`SandboxPolicy`].
765        pub fn with_policy(policy: Arc<SandboxPolicy>) -> Self {
766            Self { policy }
767        }
768    }
769
770    impl Sandbox for LandlockSandbox {
771        fn kind(&self) -> SandboxKind {
772            SandboxKind::Landlock
773        }
774
775        fn apply(&self, effective: &Caveats) -> ToolResult<()> {
776            let write = AccessFs::from_write(fs_abi_floor(&self.policy));
777            // Pure read rights — `from_read` also bundles `Execute`, which we
778            // govern separately (only when `exec` is restricted), never via the
779            // read axis.
780            let read = AccessFs::ReadFile | AccessFs::ReadDir;
781
782            // Govern writes always; govern reads / execute only when their axis is
783            // actually restricted (`Only`). `All` means no confinement was asked
784            // for, so that axis stays ambient and needs no base allow-list.
785            let confine_read = matches!(effective.fs_read, Scope::Only(_));
786            let confine_exec = matches!(effective.exec, Scope::Only(_));
787            // `net: Scope::Only([])` (empty) = deny ALL TCP bind + connect.
788            // Non-empty host allow-lists are not expressible in Landlock (port-
789            // based, not hostname-based) and stay advisory — only the empty-set
790            // case maps cleanly to a deny-all TCP rule.
791            let confine_net = super::net_fully_denied(effective);
792            let mut handled = write;
793            if confine_read {
794                handled |= read;
795            }
796            if confine_exec {
797                handled |= AccessFs::Execute;
798            }
799
800            let mut write_roots = scope_roots(&effective.fs_write);
801            // #1220: the device sinks are always write-openable — a confined
802            // git opening `/dev/null` O_RDWR must not be what the jail breaks.
803            // (O_RDWR also needs the read right: ambient when `fs_read` is
804            // `All`; granted via `base_read_paths` — which lists the same
805            // devices — when confined.)
806            write_roots.extend(self.policy.device_sink_paths.resolve());
807            write_roots.retain(|p| std::path::Path::new(p).exists());
808            // Build the ruleset: fs axes first (V3 floor), then optionally the
809            // net axis (V4+). BestEffort means handle_access silently skips
810            // access types the kernel doesn't know — so on pre-6.7 kernels the
811            // TCP handle is a no-op and only fs rules apply.
812            let ruleset = Ruleset::default()
813                .set_compatibility(CompatLevel::BestEffort)
814                .handle_access(handled)
815                .map_err(landlock_denied)?;
816            // When net is fully denied: declare AccessNet without adding any
817            // NetPort rules → deny-by-default for all TCP bind + connect.
818            let ruleset = if confine_net {
819                ruleset
820                    .handle_access(AccessNet::from_all(net_abi_floor(&self.policy)))
821                    .map_err(landlock_denied)?
822            } else {
823                ruleset
824            };
825            let ruleset = ruleset
826                .create()
827                .map_err(landlock_denied)?
828                .add_rules(path_beneath_rules(&write_roots, write))
829                .map_err(landlock_denied)?;
830
831            let ruleset = if confine_read {
832                // Granted read roots + the loader/library/data base list, so a
833                // permitted binary loads while out-of-scope reads stay denied.
834                let mut read_roots = scope_roots(&effective.fs_read);
835                read_roots.extend(self.policy.base_read_paths.resolve());
836                // The program's own binary must be readable to load. When `exec`
837                // is confined, read-allow ONLY the resolved granted programs — so
838                // the bin dirs stay OUT of the trampoline corpus (`/usr/bin/curl`
839                // unreadable ⇒ not `ld.so`-trampolinable; ADR 0011 D3). When `exec`
840                // is ambient the program is unknown, so the bin dirs are
841                // read-allowed wholesale.
842                if confine_exec {
843                    read_roots.extend(resolve_exec_paths(&effective.exec));
844                } else {
845                    read_roots.extend(self.policy.bin_read_paths.resolve());
846                }
847                read_roots.retain(|p| std::path::Path::new(p).exists());
848                ruleset
849                    .add_rules(path_beneath_rules(&read_roots, read))
850                    .map_err(landlock_denied)?
851            } else {
852                ruleset
853            };
854
855            let ruleset = if confine_exec {
856                // Execute-allow ONLY the resolved granted program files plus the
857                // dynamic linker(s) — never library directories (recursive +
858                // expose `/usr/lib`'s interpreters). A permitted binary still runs
859                // (its own execve + the loader + .so reads), but cannot DIRECTLY
860                // execve a different, un-granted program.
861                let mut exec_roots = resolve_exec_paths(&effective.exec);
862                exec_roots.extend(self.policy.loader_paths.resolve());
863                exec_roots.retain(|p| std::path::Path::new(p).exists());
864                ruleset
865                    .add_rules(path_beneath_rules(&exec_roots, AccessFs::Execute))
866                    .map_err(landlock_denied)?
867            } else {
868                ruleset
869            };
870
871            let status = ruleset.restrict_self().map_err(landlock_denied)?;
872
873            // Fail closed: if the kernel did not actually enforce the ruleset,
874            // do not let the caller believe it is confined.
875            if status.ruleset == RulesetStatus::NotEnforced {
876                return Err(ToolError::denied(
877                    "landlock ruleset was not enforced by this kernel",
878                ));
879            }
880
881            // ChildNetworkPolicy::DenyDirect — the seccomp socket()-family egress
882            // deny, on THIS confining thread (same thread as `restrict_self`,
883            // inherited across the imminent `fork`/`execve`). Only when net is
884            // already fully denied (a granted net scope leaves it inert), and
885            // fail-closed: a failed install refuses the spawn rather than let the
886            // caller believe UDP/DNS/raw egress is denied when it is not.
887            if self.policy.child_network == ChildNetworkPolicy::DenyDirect && confine_net {
888                install_seccomp_egress_deny()?;
889            }
890            Ok(())
891        }
892    }
893
894    /// Install the seccomp `socket()`-family egress deny on the CURRENT thread —
895    /// the [`ChildNetworkPolicy::DenyDirect`] leg (`crate::ChildNetworkPolicy`).
896    ///
897    /// Denies `socket()` for the off-box address families (`AF_INET` /
898    /// `AF_INET6` / `AF_PACKET`) with `EACCES`; `AF_UNIX` and every other syscall
899    /// stay allowed. This closes the UDP/DNS/raw/packet egress leg that Landlock's
900    /// TCP-only net rule cannot filter — a child under `net: none` can otherwise
901    /// still create those sockets. `apply_filter` sets `PR_SET_NO_NEW_PRIVS`, so
902    /// it needs no privilege, is irreversible, and is inherited by every
903    /// `fork`/`execve` descendant. `apply_filter` is a safe fn, so core keeps
904    /// `unsafe_code = forbid`. Must run on the confining thread, after
905    /// `restrict_self`, immediately before the spawn.
906    fn install_seccomp_egress_deny() -> ToolResult<()> {
907        use seccompiler::{
908            apply_filter, BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp,
909            SeccompCondition, SeccompFilter, SeccompRule, TargetArch,
910        };
911        use std::collections::BTreeMap;
912
913        let denied =
914            |e: String| ToolError::denied(format!("seccomp egress deny not installed: {e}"));
915
916        // One rule per off-box family, matched on socket()'s `domain` arg (arg 0).
917        let families: [u64; 3] = [
918            libc::AF_INET as u64,
919            libc::AF_INET6 as u64,
920            libc::AF_PACKET as u64,
921        ];
922        let rules: Vec<SeccompRule> = families
923            .into_iter()
924            .map(|fam| {
925                let cond = SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Eq, fam)
926                    .map_err(|e| denied(e.to_string()))?;
927                SeccompRule::new(vec![cond]).map_err(|e| denied(e.to_string()))
928            })
929            .collect::<ToolResult<_>>()?;
930
931        let mut per_syscall: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
932        per_syscall.insert(libc::SYS_socket, rules);
933
934        let filter = SeccompFilter::new(
935            per_syscall,
936            // Default for every other syscall — and for `socket()` with a
937            // non-matched family (e.g. AF_UNIX): allow.
938            SeccompAction::Allow,
939            // A matched off-box `socket()`: fail with EACCES (a clean, catchable
940            // "permission denied" the child sees as an unreachable network).
941            SeccompAction::Errno(libc::EACCES as u32),
942            TargetArch::try_from(std::env::consts::ARCH).map_err(|e| denied(e.to_string()))?,
943        )
944        .map_err(|e| denied(e.to_string()))?;
945
946        let prog: BpfProgram = BpfProgram::try_from(filter).map_err(|e| denied(e.to_string()))?;
947        apply_filter(&prog).map_err(|e| denied(e.to_string()))
948    }
949
950    /// Resolve the granted `exec` scope to absolute, existing program **files**
951    /// for the `Execute` allow-list: a path-bearing entry is taken as-is (if it
952    /// exists); a bare name is resolved against the exec search dirs. Canonicalized
953    /// so the rule anchors the real inode. `All` => empty (exec stays ambient).
954    fn resolve_exec_paths(scope: &Scope<String>) -> Vec<String> {
955        let set = match scope {
956            Scope::All => return Vec::new(),
957            Scope::Only(set) => set,
958        };
959        let dirs = exec_search_dirs();
960        let mut out = Vec::new();
961        for entry in set {
962            let candidate = if entry.contains('/') {
963                let p = std::path::PathBuf::from(entry);
964                p.exists().then_some(p)
965            } else {
966                dirs.iter()
967                    .map(|d| std::path::Path::new(d).join(entry))
968                    .find(|c| c.is_file())
969            };
970            if let Some(p) = candidate {
971                if let Ok(canon) = p.canonicalize() {
972                    out.push(canon.to_string_lossy().into_owned());
973                }
974            }
975        }
976        out
977    }
978
979    /// The directories a bare program name is resolved against: `$PATH` if set,
980    /// else a conventional fallback. Used only to anchor the `Execute` allow-list
981    /// (the spawn itself still resolves the program normally).
982    fn exec_search_dirs() -> Vec<String> {
983        if let Ok(path) = std::env::var("PATH") {
984            let dirs: Vec<String> = path
985                .split(':')
986                .filter(|s| !s.is_empty())
987                .map(String::from)
988                .collect();
989            if !dirs.is_empty() {
990                return dirs;
991            }
992        }
993        [
994            "/usr/local/bin",
995            "/usr/bin",
996            "/bin",
997            "/usr/local/sbin",
998            "/usr/sbin",
999            "/sbin",
1000        ]
1001        .iter()
1002        .map(|s| (*s).to_string())
1003        .collect()
1004    }
1005
1006    /// The existing path roots a [`Scope`] grants: `All` => the whole tree
1007    /// (`/`); `Only(set)` => exactly those paths that exist (a non-existent path
1008    /// cannot anchor a Landlock rule and is skipped — safe, since its parent is
1009    /// ungranted, so access beneath it stays denied).
1010    fn scope_roots(scope: &Scope<String>) -> Vec<String> {
1011        match scope {
1012            Scope::All => vec!["/".to_string()],
1013            Scope::Only(set) => set
1014                .iter()
1015                .filter(|p| std::path::Path::new(p).exists())
1016                .cloned()
1017                .collect(),
1018        }
1019    }
1020
1021    fn landlock_denied(e: impl std::fmt::Display) -> ToolError {
1022        ToolError::denied(format!("landlock: {e}"))
1023    }
1024}
1025
1026#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1027mod seatbelt_impl {
1028    use super::{Sandbox, SandboxKind};
1029    use crate::{Caveats, SandboxPolicy, Scope, ToolError, ToolResult};
1030    use std::path::Path;
1031    use std::sync::Arc;
1032
1033    /// The macOS sandbox wrapper. We invoke it by **absolute path** (never via
1034    /// `PATH`) so the boundary cannot be shadowed by a `sandbox-exec` planted
1035    /// earlier in a caller's `PATH`. `sandbox-exec(1)` is deprecated-but-present
1036    /// on stock macOS; using it keeps the boundary FFI-free, which core requires
1037    /// (`unsafe_code = "forbid"`).
1038    const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
1039
1040    // Read-side base allow-list (subpaths): the system/loader paths a
1041    // dynamically-linked Mach-O binary must read to *start and run* — the dynamic
1042    // linker and dyld shared cache (under `/System`, incl. the Cryptex volume),
1043    // system dylibs/frameworks, the binaries themselves, the name-service and
1044    // locale config (`/private/etc`, the real target of `/etc`), the dyld closure
1045    // db, and the `/dev` essentials. Added whenever `fs_read` is confined,
1046    // alongside the literal root entry, so a *permitted* program still loads while
1047    // user data outside scope stays unreadable. Non-existent entries are dropped
1048    // during canonicalization, so extra entries are harmless across macOS layouts
1049    // (verified on Apple Silicon: `grep`/`cat`/`cp` load read-confined). The list
1050    // now lives in `SandboxPolicy::base_read_paths` (config.rs), whose default is
1051    // macOS-specific on this platform (I5-B, #144).
1052
1053    /// `true` if this host can enforce a Seatbelt profile — i.e. the
1054    /// `sandbox-exec` wrapper is present. The wrapper itself is the boundary, so
1055    /// its presence is the capability (the analog of `landlock_is_supported`).
1056    #[must_use]
1057    pub fn seatbelt_is_supported() -> bool {
1058        Path::new(SANDBOX_EXEC).exists()
1059    }
1060
1061    /// A real, kernel-enforced Seatbelt sandbox (macOS).
1062    ///
1063    /// **The `fs_write` and `fs_read` axes** — the same *axes* the Linux Landlock
1064    /// backend governs (not necessarily the same path-level strictness; see
1065    /// below). Confinement is applied by wrapping the spawned program in
1066    /// `sandbox-exec -p <profile>`, where the SBPL profile is generated from the
1067    /// effective [`Caveats`] (see [`seatbelt_profile`]): writes are denied
1068    /// outside the granted `fs_write` roots, and — when `fs_read` is restricted —
1069    /// reads are denied outside the granted roots plus the loader/system base
1070    /// list. It also kernel-denies **all** network egress when `net` is empty
1071    /// (`(deny network*)`) — a confinement Landlock cannot provide, closing the
1072    /// `find -exec curl` egress path at L3. A non-empty `net` host allowlist is
1073    /// not expressible in SBPL (it filters by socket, not hostname) and stays
1074    /// advisory.
1075    ///
1076    /// **The `exec` axis** — when restricted, the profile emits
1077    /// `(deny process-exec*)` and re-allows exactly the granted programs (resolved
1078    /// to absolute paths). Because `process-exec*` is a kernel-checked operation
1079    /// applied to the confined process *and everything it spawns*, this confines
1080    /// the program's **interior** execs — the L3 gap a path allow-list alone
1081    /// cannot reach. Unlike Landlock, no seccomp backstop is needed: the loader
1082    /// trampoline (`dyld TARGET`) is itself a governed `process-exec`, and the
1083    /// `mmap(PROT_EXEC)` read-as-code path is closed by Apple-Silicon hardware
1084    /// W^X + code signing — so "the readable set equals the runnable set" (the
1085    /// fact that forces the Linux seccomp filter) does **not** hold here. The axis
1086    /// is therefore honestly reported `Kernel` (ADR 0014; agent-bridle#31/#57).
1087    ///
1088    /// Read confinement here is **content-level**: file *metadata* (stat,
1089    /// existence, directory traversal) stays ambient so binaries can load through
1090    /// symlink ancestors, and the system read base (the configured `base_read_paths`, incl.
1091    /// `/private/etc`) is broadly readable — looser than Landlock's file-level
1092    /// `/etc` allow-list, but the protected resource (out-of-scope file
1093    /// *contents*, the exfil threat) is denied identically. macOS keeps user
1094    /// secrets in the Keychain and `$HOME`, not `/etc`.
1095    ///
1096    /// Unlike Landlock's per-thread `restrict_self`, Seatbelt confinement is
1097    /// carried by the wrapper process and inherited by the child, so
1098    /// [`Sandbox::apply`] is a no-op and the boundary lives entirely in
1099    /// [`Sandbox::command_prefix`].
1100    #[derive(Debug, Default, Clone)]
1101    pub struct SeatbeltSandbox {
1102        /// The read base this backend's SBPL profile allows (I5-B).
1103        policy: Arc<SandboxPolicy>,
1104    }
1105
1106    impl SeatbeltSandbox {
1107        /// Construct with the built-in defaults (today's read base).
1108        #[must_use]
1109        pub fn new() -> Self {
1110            Self::default()
1111        }
1112
1113        /// Construct configured with an operator-supplied [`SandboxPolicy`].
1114        #[must_use]
1115        pub fn with_policy(policy: Arc<SandboxPolicy>) -> Self {
1116            Self { policy }
1117        }
1118    }
1119
1120    impl Sandbox for SeatbeltSandbox {
1121        fn kind(&self) -> SandboxKind {
1122            SandboxKind::Seatbelt
1123        }
1124
1125        fn apply(&self, _effective: &Caveats) -> ToolResult<()> {
1126            // Deliberate no-op: Seatbelt confines via the `sandbox-exec` wrapper
1127            // (see `command_prefix`), not by restricting the calling thread. The
1128            // boundary is the wrapped spawn.
1129            Ok(())
1130        }
1131
1132        fn command_prefix(&self, effective: &Caveats) -> ToolResult<Vec<String>> {
1133            let unix_sockets = unix_socket_paths(effective)?;
1134            if !unix_sockets.is_empty()
1135                && !super::net_unix_only(effective)
1136                && !super::net_loopback_only(effective)
1137            {
1138                return Err(ToolError::denied(
1139                    "Unix socket grants with remote hosts require the managed egress proxy",
1140                ));
1141            }
1142            // Nothing on a governed axis (fs, all-egress-denied or loopback-only
1143            // net, or a restricted exec allow-list) => nothing to confine; run
1144            // unwrapped (coarse honesty falls to `None` upstream, and the per-axis
1145            // report omits unrestricted axes).
1146            if !super::restricts_fs(effective)
1147                && !super::net_fully_denied(effective)
1148                && !super::net_loopback_only(effective)
1149                && unix_sockets.is_empty()
1150                && !super::restricts_exec(effective)
1151            {
1152                return Ok(Vec::new());
1153            }
1154            // Fail-closed: if the wrapper is gone we cannot enforce, so refuse
1155            // rather than hand back an empty (silently unconfined) prefix.
1156            if !seatbelt_is_supported() {
1157                return Err(ToolError::denied(
1158                    "macOS seatbelt: /usr/bin/sandbox-exec is unavailable; cannot confine",
1159                ));
1160            }
1161            Ok(vec![
1162                SANDBOX_EXEC.to_string(),
1163                "-p".to_string(),
1164                seatbelt_profile_with(
1165                    effective,
1166                    &self.policy.base_read_paths.resolve(),
1167                    &self.policy.device_sink_paths.resolve(),
1168                    &unix_sockets,
1169                ),
1170            ])
1171        }
1172    }
1173
1174    /// Generate the SBPL profile for `effective`. **Pure** (modulo path
1175    /// canonicalization against the real filesystem); no spawning.
1176    ///
1177    /// Model (the macOS analog of Landlock handling only the write/read access
1178    /// rights and leaving the rest ambient): start from `(allow default)` so
1179    /// unhandled operations — `exec`, `network`, mach lookups a normal process
1180    /// needs — stay ambient, then `(deny file-write*)` / `(deny file-read*)` for
1181    /// a restricted axis and re-allow exactly the granted roots (canonicalized,
1182    /// so `/tmp` → `/private/tmp` matches). An empty `fs_write` scope emits the
1183    /// deny with no re-allow — every write denied. SBPL evaluates last-match-wins,
1184    /// so the trailing allow-roots override the deny.
1185    // Convenience over the built-in read base — **tests only** (production uses
1186    // `command_prefix` → `seatbelt_profile_with` with the configured
1187    // `SandboxPolicy::base_read_paths`, I5-B #144).
1188    #[cfg(test)]
1189    #[must_use]
1190    pub fn seatbelt_profile(effective: &Caveats) -> String {
1191        let policy = SandboxPolicy::default();
1192        seatbelt_profile_with(
1193            effective,
1194            &policy.base_read_paths.resolve(),
1195            &policy.device_sink_paths.resolve(),
1196            &unix_socket_paths(effective).expect("valid Unix socket grants in profile fixture"),
1197        )
1198    }
1199
1200    /// SBPL profile builder, parameterized on the read base (`base_read`) and
1201    /// the always-writable device sinks (`sinks`, #1220).
1202    #[must_use]
1203    fn seatbelt_profile_with(
1204        effective: &Caveats,
1205        base_read: &[String],
1206        sinks: &[String],
1207        unix_sockets: &[String],
1208    ) -> String {
1209        let mut p = String::from("(version 1)\n(allow default)\n");
1210
1211        // fs_write: deny writes, then re-allow the granted roots.
1212        if let Scope::Only(_) = &effective.fs_write {
1213            p.push_str("(deny file-write*)\n");
1214            let roots = confined_roots(&effective.fs_write);
1215            if !roots.is_empty() {
1216                p.push_str("(allow file-write*");
1217                for r in &roots {
1218                    p.push_str(&format!(" (subpath {})", sbpl_string(r)));
1219                }
1220                p.push_str(")\n");
1221            }
1222            // #1220: device sinks stay write-openable under confinement —
1223            // `literal` (not `subpath`): each is a single character device.
1224            if !sinks.is_empty() {
1225                p.push_str("(allow file-write*");
1226                for s in sinks {
1227                    p.push_str(&format!(" (literal {})", sbpl_string(s)));
1228                }
1229                p.push_str(")\n");
1230            }
1231        }
1232
1233        // fs_read: deny reads, then re-allow. `(allow file-read-metadata)`
1234        // permits path *traversal* and `stat` everywhere — without it, reaching
1235        // an in-scope file through a symlink ancestor (`/tmp`, `/var`, `/etc` →
1236        // `/private/…`) is denied at the symlink lookup. Metadata reveals only
1237        // existence/size, never **content**; the data axis stays confined to the
1238        // loader/system base, the root directory *entry* (dyld reads `/` itself),
1239        // and the granted roots — so a permitted program loads and reads in-scope
1240        // files while out-of-scope file *contents* (the exfil threat) stay denied.
1241        if let Scope::Only(_) = &effective.fs_read {
1242            p.push_str("(deny file-read*)\n");
1243            p.push_str("(allow file-read-metadata)\n");
1244            p.push_str("(allow file-read* (literal \"/\")");
1245            for base in base_read {
1246                if let Some(c) = canonical_path(base) {
1247                    p.push_str(&format!(" (subpath {})", sbpl_string(&c)));
1248                }
1249            }
1250            for r in confined_roots(&effective.fs_read) {
1251                p.push_str(&format!(" (subpath {})", sbpl_string(&r)));
1252            }
1253            p.push_str(")\n");
1254        }
1255
1256        // net: SBPL can name only `*`/`localhost` + ports as a remote (an
1257        // arbitrary IP is rejected: "host must be * or localhost"; ADR 0015), so a
1258        // general host allowlist is inexpressible and left ambient (reported
1259        // advisory, never silently dropped). The two policies it *can* enforce:
1260        //   • empty scope  → `(deny network*)`: every socket kernel-denied — a
1261        //     confinement no Landlock increment can supply.
1262        //   • loopback-only allowlist → deny all, then re-allow the loopback
1263        //     interface (`localhost` = 127.0.0.1 + ::1). The process's own off-box
1264        //     socket egress stays kernel-denied; the exact loopback host is narrowed
1265        //     by admission. Last-match-wins, so the allow overrides.
1266        if super::net_fully_denied(effective) || super::net_unix_only(effective) {
1267            p.push_str("(deny network*)\n");
1268        } else if super::net_loopback_only(effective) {
1269            p.push_str("(deny network*)\n");
1270            p.push_str("(allow network* (remote ip \"localhost:*\"))\n");
1271        }
1272        for path in unix_sockets {
1273            p.push_str(&format!(
1274                "(allow network-outbound (literal {}))\n",
1275                sbpl_string(path)
1276            ));
1277        }
1278
1279        // exec: deny *all* further execs, then re-allow exactly the granted
1280        // programs (resolved to absolute, canonical paths). `process-exec*` is
1281        // kernel-checked on the confined process AND everything it spawns, so this
1282        // is the `exec` axis at interior grain — no seccomp backstop needed (the
1283        // dyld trampoline is itself a governed `process-exec`, and `mmap(PROT_EXEC)`
1284        // read-as-code is closed by hardware W^X + code signing; ADR 0014). An
1285        // empty/unresolvable grant emits the deny with no re-allow — every exec
1286        // (including the wrapped program's own launch) denied: fail-closed, never
1287        // ambient. SBPL is last-match-wins, so the trailing allow overrides.
1288        if let Scope::Only(_) = &effective.exec {
1289            p.push_str("(deny process-exec*)\n");
1290            let targets = resolve_exec_targets(&effective.exec);
1291            if !targets.is_empty() {
1292                p.push_str("(allow process-exec*");
1293                for t in &targets {
1294                    p.push_str(&format!(" (literal {})", sbpl_string(t)));
1295                }
1296                p.push_str(")\n");
1297            }
1298        }
1299
1300        p
1301    }
1302
1303    /// No lexical aliases, symlinks, missing endpoints, or patterns. Matching is
1304    /// to this exact existing pathname, not its parent or a socket subtree.
1305    fn unix_socket_paths(effective: &Caveats) -> ToolResult<Vec<String>> {
1306        use std::os::unix::fs::FileTypeExt;
1307        let Scope::Only(names) = &effective.net else {
1308            return Ok(Vec::new());
1309        };
1310        names.iter().filter_map(|name| name.strip_prefix("unix:")).map(|name| {
1311            let path = Path::new(name);
1312            let valid = path.is_absolute()
1313                && !name.chars().any(|c| c.is_control() || matches!(c, '*' | '?' | '[' | ']'))
1314                && std::fs::canonicalize(path).ok().and_then(|p| p.to_str().map(str::to_owned)).as_deref() == Some(name)
1315                && std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_socket());
1316            if valid { Ok(name.to_owned()) }
1317            else { Err(ToolError::denied("Unix endpoint grant must name an existing canonical absolute socket without symlinks or patterns")) }
1318        }).collect()
1319    }
1320
1321    /// The canonicalized, existing roots a restricted [`Scope`] grants. A path
1322    /// that cannot be resolved to any existing ancestor is dropped (it cannot
1323    /// anchor a rule — safe, since its parent is ungranted, so access beneath it
1324    /// stays denied). `All` yields nothing (callers only pass a restricted axis).
1325    fn confined_roots(scope: &Scope<String>) -> Vec<String> {
1326        let Scope::Only(set) = scope else {
1327            return Vec::new();
1328        };
1329        let mut roots: Vec<String> = set.iter().filter_map(|p| canonical_path(p)).collect();
1330        roots.sort();
1331        roots.dedup();
1332        roots
1333    }
1334
1335    /// System binary directories searched to resolve a **bare-name** `exec` grant
1336    /// (e.g. `["git"]`) to absolute path(s) for the `process-exec*` allow-list.
1337    /// SIP-protected, read-only system locations — a trustworthy pin. Bare names
1338    /// resolve through this *fixed* list, never the ambient `$PATH` (ADR 0014 /
1339    /// ADR 0011 D5), so a binary planted earlier on a caller's `$PATH` cannot
1340    /// widen the kernel allow-list. An absolute-path grant is honored verbatim
1341    /// (then canonicalized); a basename collision outside these dirs is not.
1342    const TRUSTED_EXEC_DIRS: &[&str] = &["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
1343
1344    /// Resolve a restricted `exec` [`Scope`] to the absolute, canonical program
1345    /// paths that anchor the SBPL `(allow process-exec* (literal …))` rules. The
1346    /// kernel matches `process-exec` against the *resolved* path of the exec
1347    /// target, so each grant must become a realpath: an absolute grant is
1348    /// canonicalized; a bare name is resolved against [`TRUSTED_EXEC_DIRS`] (each
1349    /// existing hit included, mirroring admission's basename semantics in
1350    /// [`crate::context`] but pinned to trusted dirs). A relative-path or
1351    /// unresolvable grant is dropped — it cannot anchor a rule, so the program
1352    /// stays denied (fail-closed). `All` yields nothing (callers pass a restricted
1353    /// axis). Results are sorted+deduped so the emitted profile is deterministic.
1354    fn resolve_exec_targets(scope: &Scope<String>) -> Vec<String> {
1355        let Scope::Only(set) = scope else {
1356            return Vec::new();
1357        };
1358        let canon_file = |path: &Path, out: &mut Vec<String>| {
1359            if let Ok(c) = std::fs::canonicalize(path) {
1360                if c.is_file() {
1361                    out.push(c.to_string_lossy().into_owned());
1362                }
1363            }
1364        };
1365        let mut out: Vec<String> = Vec::new();
1366        for token in set {
1367            if token.starts_with('/') {
1368                // Absolute grant: honored verbatim (canonicalized, must exist).
1369                canon_file(Path::new(token), &mut out);
1370            } else if !token.contains('/') {
1371                // Bare name: resolve against the fixed trusted system dirs only.
1372                for dir in TRUSTED_EXEC_DIRS {
1373                    canon_file(&Path::new(dir).join(token), &mut out);
1374                }
1375            }
1376            // else: a relative path grant cannot anchor a kernel rule safely — drop.
1377        }
1378        out.sort();
1379        out.dedup();
1380        out
1381    }
1382
1383    /// Resolve `p` to an absolute, symlink-free path suitable for `(subpath …)`
1384    /// matching, which the kernel performs against the *resolved* path (so a
1385    /// granted `/tmp/x` must become `/private/tmp/x` or it never matches). If the
1386    /// leaf does not yet exist, canonicalize the longest existing ancestor and
1387    /// re-append the remainder. `None` if not even an ancestor resolves.
1388    fn canonical_path(p: &str) -> Option<String> {
1389        let path = Path::new(p);
1390        if let Ok(c) = std::fs::canonicalize(path) {
1391            return Some(c.to_string_lossy().into_owned());
1392        }
1393        let mut tail: Vec<std::ffi::OsString> = Vec::new();
1394        let mut cur = path;
1395        while let Some(parent) = cur.parent() {
1396            if let Some(name) = cur.file_name() {
1397                tail.push(name.to_owned());
1398            }
1399            if let Ok(c) = std::fs::canonicalize(parent) {
1400                let mut resolved = c;
1401                for seg in tail.iter().rev() {
1402                    resolved.push(seg);
1403                }
1404                return Some(resolved.to_string_lossy().into_owned());
1405            }
1406            cur = parent;
1407        }
1408        None
1409    }
1410
1411    /// Quote `s` as an SBPL string literal, escaping `\` and `"` so a crafted
1412    /// path can never break out of the quotes and inject profile syntax.
1413    fn sbpl_string(s: &str) -> String {
1414        let mut out = String::with_capacity(s.len() + 2);
1415        out.push('"');
1416        for ch in s.chars() {
1417            if ch == '\\' || ch == '"' {
1418                out.push('\\');
1419            }
1420            out.push(ch);
1421        }
1422        out.push('"');
1423        out
1424    }
1425
1426    #[cfg(test)]
1427    mod unit {
1428        use super::*;
1429        use crate::Scope;
1430
1431        /// #1220: a write-confined profile must re-allow the device sinks as
1432        /// literals — git's O_RDWR open of /dev/null dies otherwise.
1433        #[test]
1434        fn write_confined_profile_allows_the_device_sinks() {
1435            let confined = Caveats {
1436                fs_write: Scope::only(["/tmp/x".to_string()]),
1437                ..Caveats::top()
1438            };
1439            let profile = seatbelt_profile(&confined);
1440            assert!(profile.contains("(deny file-write*)"), "{profile}");
1441            assert!(
1442                profile.contains("(literal \"/dev/null\")"),
1443                "the null sink must stay write-openable: {profile}"
1444            );
1445        }
1446
1447        #[test]
1448        fn unrestricted_caveats_make_no_wrapper() {
1449            assert!(SeatbeltSandbox::new()
1450                .command_prefix(&Caveats::top())
1451                .unwrap()
1452                .is_empty());
1453        }
1454
1455        /// #144 (I5-B) regression guard: the Seatbelt backend must read its base
1456        /// allow-list from `self.policy` on the PRODUCTION path (`command_prefix`
1457        /// → `seatbelt_profile_with`), not a hardcoded const. A widened
1458        /// `base_read_paths` must appear in the generated SBPL profile; the
1459        /// default policy must not admit it. Mirrors the Landlock proof
1460        /// `landlock_config_widens_base_read`, so a revert of the const path is
1461        /// caught on macOS too (previously only Landlock had this coverage).
1462        #[test]
1463        fn command_prefix_widens_the_read_base_from_policy() {
1464            if !seatbelt_is_supported() {
1465                eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1466                return;
1467            }
1468            let extra = std::env::temp_dir().join("abridle-seatbelt-cfg-widen");
1469            std::fs::create_dir_all(&extra).unwrap();
1470            let extra_str = extra.to_string_lossy().into_owned();
1471            // The profile carries the canonicalized path (e.g. /tmp → /private/tmp).
1472            let want = canonical_path(&extra_str).expect("temp dir canonicalizes");
1473
1474            // fs_read must be restricted for the read base to be emitted at all.
1475            let cav = Caveats {
1476                fs_read: Scope::only(["/usr".to_string()]),
1477                ..Caveats::top()
1478            };
1479
1480            // Control: the default read base does NOT admit the extra dir.
1481            let default_prefix = SeatbeltSandbox::new().command_prefix(&cav).unwrap();
1482            assert!(
1483                !default_prefix.iter().any(|a| a.contains(&want)),
1484                "default read base must not include the extra dir: {default_prefix:?}"
1485            );
1486
1487            // Widened policy: add `extra` to base_read_paths → it appears.
1488            let mut base = SandboxPolicy::default().base_read_paths;
1489            base.extra.push(extra_str);
1490            let policy = Arc::new(SandboxPolicy {
1491                base_read_paths: base,
1492                ..SandboxPolicy::default()
1493            });
1494            let widened_prefix = SeatbeltSandbox::with_policy(policy)
1495                .command_prefix(&cav)
1496                .unwrap();
1497            assert!(
1498                widened_prefix.iter().any(|a| a.contains(&want)),
1499                "config-widened base_read_paths must reach the SBPL profile: {widened_prefix:?}"
1500            );
1501
1502            let _ = std::fs::remove_dir_all(&extra);
1503        }
1504
1505        #[test]
1506        fn empty_net_denies_all_egress_and_engages_the_wrapper() {
1507            // net:none with fs unrestricted still confines (network), so the
1508            // wrapper must engage and the profile must deny all egress.
1509            let cav = Caveats {
1510                net: Scope::none(),
1511                ..Caveats::top()
1512            };
1513            let prof = seatbelt_profile(&cav);
1514            assert!(prof.contains("(deny network*)"), "{prof}");
1515            assert!(
1516                !SeatbeltSandbox::new()
1517                    .command_prefix(&cav)
1518                    .unwrap()
1519                    .is_empty(),
1520                "net:none must engage the sandbox-exec wrapper"
1521            );
1522        }
1523
1524        #[test]
1525        fn nonempty_net_allowlist_is_not_denied() {
1526            // A general (non-loopback) host allowlist is not expressible in SBPL —
1527            // it can name only `*`/`localhost` + ports as a remote — so no network
1528            // rule is emitted; left ambient (advisory), never silently dropped.
1529            let cav = Caveats {
1530                net: Scope::only(["example.com".to_string()]),
1531                ..Caveats::top()
1532            };
1533            let prof = seatbelt_profile(&cav);
1534            assert!(
1535                !prof.contains("network"),
1536                "non-loopback net must stay ambient: {prof}"
1537            );
1538        }
1539
1540        #[test]
1541        fn loopback_only_net_confines_to_loopback_and_engages() {
1542            // A loopback-only allowlist IS expressible: deny all egress, then
1543            // re-allow the loopback interface (ADR 0015). Off-box egress stays
1544            // kernel-denied; the wrapper engages even with fs/exec unrestricted.
1545            for host in ["localhost", "127.0.0.1", "::1"] {
1546                let cav = Caveats {
1547                    net: Scope::only([host.to_string()]),
1548                    ..Caveats::top()
1549                };
1550                let prof = seatbelt_profile(&cav);
1551                assert!(prof.contains("(deny network*)"), "{host}: {prof}");
1552                assert!(
1553                    prof.contains("(allow network* (remote ip \"localhost:*\"))"),
1554                    "{host}: loopback re-allow missing: {prof}"
1555                );
1556                assert!(
1557                    !SeatbeltSandbox::new()
1558                        .command_prefix(&cav)
1559                        .unwrap()
1560                        .is_empty(),
1561                    "{host}: a loopback-only net grant must engage the wrapper"
1562                );
1563            }
1564        }
1565
1566        #[test]
1567        fn mixed_loopback_and_remote_host_stays_ambient() {
1568            // A single non-loopback host taints the set: SBPL cannot express the
1569            // remote, so the whole allowlist stays ambient (advisory) rather than
1570            // emit a rule that would silently drop `example.com`.
1571            let cav = Caveats {
1572                net: Scope::only(["localhost".to_string(), "example.com".to_string()]),
1573                ..Caveats::top()
1574            };
1575            let prof = seatbelt_profile(&cav);
1576            assert!(
1577                !prof.contains("network"),
1578                "a mixed loopback+remote allowlist must stay ambient: {prof}"
1579            );
1580        }
1581
1582        #[test]
1583        fn loopback_fenced_caveats_emit_the_egress_proxy_fence() {
1584            // The egress-proxy mechanism (#124, ADR 0016) fences a remote-host
1585            // grant to loopback via `loopback_fenced_caveats`: the resulting
1586            // profile must carry the ADR 0015 loopback fence AND preserve fs/exec.
1587            let granted = Caveats {
1588                net: Scope::only(["example.com".to_string()]),
1589                fs_write: Scope::only(["/tmp".to_string()]),
1590                ..Caveats::top()
1591            };
1592            // The remote grant alone emits NO net rule (advisory) …
1593            assert!(!seatbelt_profile(&granted).contains("network"));
1594            // … but its loopback-fenced form emits the kernel egress fence.
1595            let prof = seatbelt_profile(&super::super::loopback_fenced_caveats(&granted));
1596            assert!(prof.contains("(deny network*)"), "{prof}");
1597            assert!(
1598                prof.contains("(allow network* (remote ip \"localhost:*\"))"),
1599                "fence must re-allow loopback: {prof}"
1600            );
1601            assert!(
1602                prof.contains("(deny file-write*)"),
1603                "fs_write rule must survive the fence: {prof}"
1604            );
1605        }
1606
1607        #[test]
1608        fn restricted_write_yields_sandbox_exec_wrapper() {
1609            let cav = Caveats {
1610                fs_write: Scope::only(["/tmp".to_string()]),
1611                ..Caveats::top()
1612            };
1613            let prefix = SeatbeltSandbox::new().command_prefix(&cav).unwrap();
1614            assert_eq!(prefix[0], SANDBOX_EXEC);
1615            assert_eq!(prefix[1], "-p");
1616            assert!(prefix[2].contains("(deny file-write*)"));
1617        }
1618
1619        #[test]
1620        fn profile_denies_then_reallows_write_roots() {
1621            let cav = Caveats {
1622                fs_write: Scope::only(["/tmp".to_string()]),
1623                ..Caveats::top()
1624            };
1625            let prof = seatbelt_profile(&cav);
1626            assert!(prof.contains("(allow default)"));
1627            assert!(prof.contains("(deny file-write*)"));
1628            // `/tmp` must be canonicalized to its real target for subpath match.
1629            assert!(prof.contains("(subpath \"/private/tmp\")"), "{prof}");
1630            // No read axis restricted => no read deny.
1631            assert!(!prof.contains("(deny file-read*)"));
1632        }
1633
1634        #[test]
1635        fn empty_write_scope_denies_all_writes_no_allow() {
1636            let cav = Caveats {
1637                fs_write: Scope::none(),
1638                ..Caveats::top()
1639            };
1640            let prof = seatbelt_profile(&cav);
1641            assert!(prof.contains("(deny file-write*)"));
1642            assert!(
1643                !prof.contains("(subpath"),
1644                "an empty scope must grant no write roots: {prof}"
1645            );
1646            // #1220: the device sinks stay write-openable even with an empty
1647            // write scope — that re-allow is a `literal`, not a `subpath` root.
1648            assert!(
1649                prof.contains("(literal \"/dev/null\")"),
1650                "device sinks must still be re-allowed: {prof}"
1651            );
1652        }
1653
1654        #[test]
1655        fn restricted_read_includes_loader_base_and_root_entry() {
1656            let cav = Caveats {
1657                fs_read: Scope::only(["/tmp".to_string()]),
1658                ..Caveats::top()
1659            };
1660            let prof = seatbelt_profile(&cav);
1661            assert!(prof.contains("(deny file-read*)"));
1662            assert!(prof.contains("(literal \"/\")"), "{prof}");
1663            assert!(prof.contains("(subpath \"/usr\")"), "{prof}");
1664            assert!(prof.contains("(subpath \"/System\")"), "{prof}");
1665        }
1666
1667        #[test]
1668        fn sbpl_string_escapes_quotes_and_backslashes() {
1669            assert_eq!(sbpl_string("/a/b"), "\"/a/b\"");
1670            assert_eq!(sbpl_string("/a\"b"), "\"/a\\\"b\"");
1671            assert_eq!(sbpl_string("/a\\b"), "\"/a\\\\b\"");
1672        }
1673
1674        /// Count double-quotes that are *not* backslash-escaped — the structural
1675        /// quotes SBPL actually sees. Each `(subpath "…")` term I emit
1676        /// contributes exactly two; any extra would mean a path broke out of its
1677        /// literal.
1678        fn unescaped_quotes(s: &str) -> usize {
1679            let b = s.as_bytes();
1680            (0..b.len())
1681                .filter(|&i| b[i] == b'"' && (i == 0 || b[i - 1] != b'\\'))
1682                .count()
1683        }
1684
1685        #[test]
1686        fn crafted_path_cannot_inject_profile_syntax() {
1687            // A path crafted to close the string and add its own allow rule must
1688            // stay inside one escaped literal — its quotes get backslash-escaped,
1689            // so SBPL sees exactly the two structural quotes of the single term.
1690            let cav = Caveats {
1691                fs_write: Scope::only(["/tmp/x\") (allow file-write* (subpath \"/".to_string()]),
1692                ..Caveats::top()
1693            };
1694            let prof = seatbelt_profile(&cav);
1695            // Every other structural term is a plain, non-crafted literal (the
1696            // #1220 device sinks); the crafted root contributes exactly one
1697            // structural (subpath "…") term — 2 unescaped quotes — on top of
1698            // those.
1699            let sinks = SandboxPolicy::default().device_sink_paths.resolve();
1700            assert_eq!(
1701                unescaped_quotes(&prof),
1702                2 + 2 * sinks.len(),
1703                "exactly one structural (subpath \"…\") term — no breakout: {prof}"
1704            );
1705            assert!(
1706                prof.contains("\\\""),
1707                "the crafted quotes must be backslash-escaped: {prof}"
1708            );
1709        }
1710
1711        #[test]
1712        fn restricted_exec_emits_deny_and_allowlist() {
1713            let cav = Caveats {
1714                exec: Scope::only(["/bin/echo".to_string()]),
1715                ..Caveats::top()
1716            };
1717            let prof = seatbelt_profile(&cav);
1718            assert!(prof.contains("(deny process-exec*)"), "{prof}");
1719            assert!(
1720                prof.contains("(allow process-exec* (literal \"/bin/echo\")"),
1721                "{prof}"
1722            );
1723        }
1724
1725        #[test]
1726        fn bare_name_exec_resolves_through_trusted_dirs() {
1727            // A bare name is pinned to the fixed trusted system dirs, never $PATH.
1728            let cav = Caveats {
1729                exec: Scope::only(["true".to_string()]),
1730                ..Caveats::top()
1731            };
1732            let prof = seatbelt_profile(&cav);
1733            // `/usr/bin/true` exists on every macOS host and canonicalizes to
1734            // itself, so the literal must name the absolute resolved path.
1735            assert!(
1736                prof.contains("(literal \"/usr/bin/true\")"),
1737                "bare name must resolve to its trusted-dir absolute path: {prof}"
1738            );
1739        }
1740
1741        #[test]
1742        fn restricted_exec_engages_the_wrapper() {
1743            // exec-only (no fs/net restriction) must still engage sandbox-exec.
1744            let cav = Caveats {
1745                exec: Scope::only(["/bin/echo".to_string()]),
1746                ..Caveats::top()
1747            };
1748            let prefix = SeatbeltSandbox::new().command_prefix(&cav).unwrap();
1749            assert_eq!(prefix.first().map(String::as_str), Some(SANDBOX_EXEC));
1750        }
1751
1752        #[test]
1753        fn empty_exec_scope_denies_all_exec_with_no_allow() {
1754            // exec:none — the program may exec nothing. The deny is emitted with no
1755            // re-allow, so even the wrapped program's launch is denied: fail-closed,
1756            // never silently ambient.
1757            let cav = Caveats {
1758                exec: Scope::none(),
1759                ..Caveats::top()
1760            };
1761            let prof = seatbelt_profile(&cav);
1762            assert!(prof.contains("(deny process-exec*)"), "{prof}");
1763            assert!(
1764                !prof.contains("(allow process-exec*"),
1765                "an empty exec scope must grant no exec targets: {prof}"
1766            );
1767        }
1768
1769        #[test]
1770        fn relative_and_unresolvable_exec_grants_are_dropped() {
1771            // A relative-path grant cannot anchor a kernel rule; a bare name with no
1772            // trusted-dir hit resolves to nothing. Either way: deny with no allow.
1773            let cav = Caveats {
1774                exec: Scope::only(["./payload".to_string(), "no-such-binary-xyzzy".to_string()]),
1775                ..Caveats::top()
1776            };
1777            let prof = seatbelt_profile(&cav);
1778            assert!(prof.contains("(deny process-exec*)"), "{prof}");
1779            assert!(
1780                !prof.contains("(allow process-exec*"),
1781                "unresolvable/relative grants must not anchor an allow: {prof}"
1782            );
1783        }
1784
1785        #[test]
1786        fn unrestricted_exec_emits_no_exec_rules() {
1787            // exec:All (the default) is ambient on the exec axis — no rules.
1788            let prof = seatbelt_profile(&Caveats::top());
1789            assert!(!prof.contains("process-exec"), "{prof}");
1790        }
1791    }
1792}
1793
1794#[cfg(test)]
1795mod tests {
1796    use super::*;
1797    use crate::Scope;
1798
1799    fn assert_appcontainer_rejects_unix(names: &[&str]) {
1800        let caveats = Caveats {
1801            net: Scope::only(names.iter().map(|name| (*name).to_owned())),
1802            ..Caveats::top()
1803        };
1804        let result = appcontainer_impl::AppContainerSandbox::new().command_prefix(&caveats);
1805        assert!(
1806            matches!(&result, Err(crate::ToolError::Denied { reason }) if reason.contains("Unix")),
1807            "unsupported Unix authority must refuse before launcher lookup: {result:?}"
1808        );
1809    }
1810
1811    #[test]
1812    fn appcontainer_command_prefix_rejects_unix_only() {
1813        assert_appcontainer_rejects_unix(&["unix:/private/tmp/service.sock"]);
1814    }
1815
1816    #[test]
1817    fn appcontainer_command_prefix_rejects_unix_with_loopback() {
1818        assert_appcontainer_rejects_unix(&["unix:/private/tmp/service.sock", "localhost"]);
1819    }
1820
1821    #[test]
1822    fn appcontainer_command_prefix_keeps_existing_unix_free_behavior() {
1823        let sandbox = appcontainer_impl::AppContainerSandbox::new();
1824        assert!(sandbox.command_prefix(&Caveats::top()).unwrap().is_empty());
1825        let caveats = Caveats {
1826            net: Scope::only(["localhost".to_owned()]),
1827            ..Caveats::top()
1828        };
1829        match sandbox.command_prefix(&caveats) {
1830            Ok(prefix) => assert!(prefix.iter().any(|arg| arg == "--loopback-exemption")),
1831            Err(crate::ToolError::Denied { reason }) => assert!(
1832                reason.contains("agent-bridle-aclaunch.exe not found"),
1833                "plain loopback must reach normal launcher lookup: {reason}"
1834            ),
1835            other => panic!("unexpected loopback prefix result: {other:?}"),
1836        }
1837    }
1838
1839    #[test]
1840    fn noop_reports_none_and_never_fails() {
1841        let s = NoopSandbox;
1842        assert_eq!(s.kind(), SandboxKind::None);
1843        assert!(s.apply(&Caveats::top()).is_ok());
1844    }
1845
1846    #[test]
1847    fn net_egress_proxy_hosts_triggers_only_on_a_general_remote_allowlist() {
1848        let with_net = |net| {
1849            net_egress_proxy_hosts(&Caveats {
1850                net,
1851                ..Caveats::top()
1852            })
1853        };
1854        // No trigger: unrestricted, deny-all, or loopback-only — owned elsewhere.
1855        assert_eq!(with_net(Scope::All), None);
1856        assert_eq!(with_net(Scope::none()), None); // empty = deny-all (net_fully_denied)
1857        for lo in ["localhost", "127.0.0.1", "::1"] {
1858            assert_eq!(
1859                with_net(Scope::only([lo.to_string()])),
1860                None,
1861                "{lo} is loopback-only"
1862            );
1863        }
1864        // Trigger: a remote host, alone or mixed with loopback (full set returned).
1865        assert_eq!(
1866            with_net(Scope::only(["example.com".to_string()])),
1867            Some(vec!["example.com".to_string()])
1868        );
1869        let mixed = with_net(Scope::only([
1870            "example.com".to_string(),
1871            "localhost".to_string(),
1872        ]))
1873        .expect("mixed set triggers");
1874        assert_eq!(
1875            mixed.len(),
1876            2,
1877            "the FULL grant is returned, loopback included: {mixed:?}"
1878        );
1879        assert!(
1880            mixed.contains(&"example.com".to_string()) && mixed.contains(&"localhost".to_string())
1881        );
1882    }
1883
1884    #[test]
1885    fn loopback_fenced_caveats_swaps_net_to_loopback_preserving_other_axes() {
1886        let granted = Caveats {
1887            net: Scope::only(["example.com".to_string()]),
1888            fs_write: Scope::only(["/tmp/x".to_string()]),
1889            exec: Scope::only(["git".to_string()]),
1890            ..Caveats::top()
1891        };
1892        let fenced = loopback_fenced_caveats(&granted);
1893        // net is now loopback-only, so it engages the ADR 0015 kernel fence …
1894        assert!(
1895            net_loopback_only(&fenced),
1896            "fenced net must be loopback-only"
1897        );
1898        assert!(
1899            net_egress_proxy_hosts(&fenced).is_none(),
1900            "fenced caveats no longer trigger the proxy"
1901        );
1902        // … while fs/exec are preserved verbatim (the fence keeps their rules).
1903        assert_eq!(fenced.fs_write, granted.fs_write);
1904        assert_eq!(fenced.exec, granted.exec);
1905    }
1906
1907    /// Regression (#257/#275 fail-open): the egress proxy must engage ONLY where
1908    /// the backend can address-fence the child's egress to loopback. The prior
1909    /// gate (`effective_sandbox_kind != None`) let Landlock through whenever the
1910    /// *fs* axis engaged — but Landlock's `net` fence is port-based and cannot
1911    /// confine a loopback-only host set, so the child could dial around the proxy
1912    /// while the system reported it fenced. This asserts the net-axis-specific gate.
1913    #[test]
1914    fn egress_proxy_plan_engages_only_where_loopback_net_is_enforceable() {
1915        // The Leg-4 config that triggered the fail-open: a remote-host `net`
1916        // allow-list AND a restricted fs axis (so Landlock engages on fs).
1917        let leg4 = Caveats {
1918            net: Scope::only(["api.github.com".to_string()]),
1919            fs_write: Scope::only(["/work".to_string()]),
1920            ..Caveats::top()
1921        };
1922        // Address-fenceable backends engage the proxy (real confinement).
1923        assert!(
1924            egress_proxy_plan_for(SandboxKind::Seatbelt, &leg4).is_some(),
1925            "Seatbelt fences net to loopback (SBPL) → proxy is real confinement"
1926        );
1927        assert!(
1928            egress_proxy_plan_for(SandboxKind::AppContainer, &leg4).is_some(),
1929            "AppContainer loopback-exemption → proxy is real confinement"
1930        );
1931        // THE FIX: Landlock engages on fs but CANNOT address-fence net, so the
1932        // proxy must NOT engage — otherwise it is walk-around-able false
1933        // confinement. This assertion fails against the pre-fix gate.
1934        assert_eq!(
1935            egress_proxy_plan_for(SandboxKind::Landlock, &leg4),
1936            None,
1937            "Landlock is port-based; loopback-only net is unenforceable → advisory, no walk-around proxy"
1938        );
1939        // Tiers that don't namespace net at their level, and 'no backend', are
1940        // advisory too — never a walk-around proxy.
1941        for k in [
1942            SandboxKind::MinimalRootfs,
1943            SandboxKind::MicroVm,
1944            SandboxKind::None,
1945        ] {
1946            assert_eq!(
1947                egress_proxy_plan_for(k, &leg4),
1948                None,
1949                "{k:?} does not address-fence net → advisory"
1950            );
1951        }
1952        // A non-proxy grant (net: All) never engages, even on a fenceable backend.
1953        assert_eq!(
1954            egress_proxy_plan_for(
1955                SandboxKind::Seatbelt,
1956                &Caveats {
1957                    net: Scope::All,
1958                    ..Caveats::top()
1959                }
1960            ),
1961            None,
1962            "net: All needs no fence"
1963        );
1964    }
1965
1966    #[test]
1967    fn sandbox_kind_serde_is_snake_case() {
1968        assert_eq!(
1969            serde_json::to_string(&SandboxKind::None).unwrap(),
1970            "\"none\""
1971        );
1972        assert_eq!(
1973            serde_json::to_string(&SandboxKind::Landlock).unwrap(),
1974            "\"landlock\""
1975        );
1976        assert_eq!(
1977            serde_json::to_string(&SandboxKind::Seatbelt).unwrap(),
1978            "\"seatbelt\""
1979        );
1980        assert_eq!(
1981            serde_json::to_string(&SandboxKind::AppContainer).unwrap(),
1982            "\"app_container\""
1983        );
1984        assert_eq!(
1985            serde_json::to_string(&SandboxKind::MinimalRootfs).unwrap(),
1986            "\"minimal_rootfs\""
1987        );
1988        assert_eq!(
1989            serde_json::to_string(&SandboxKind::MicroVm).unwrap(),
1990            "\"micro_vm\""
1991        );
1992    }
1993
1994    #[test]
1995    fn effective_kind_downgrades_to_none_when_no_axis_is_restricted() {
1996        // The honesty rule (I9): a backend that confines nothing must not be
1997        // reported. With every axis `All`, even a real backend reports None.
1998        for available in [
1999            SandboxKind::Landlock,
2000            SandboxKind::Seatbelt,
2001            SandboxKind::AppContainer,
2002            SandboxKind::None,
2003        ] {
2004            assert_eq!(
2005                effective_sandbox_kind(available, &Caveats::top()),
2006                SandboxKind::None,
2007                "unrestricted fs must report None for {available:?}"
2008            );
2009        }
2010        // With a restricted fs axis, the backend's own kind is reported …
2011        let restricted = Caveats {
2012            fs_write: Scope::only(["/w".to_string()]),
2013            ..Caveats::top()
2014        };
2015        assert_eq!(
2016            effective_sandbox_kind(SandboxKind::Landlock, &restricted),
2017            SandboxKind::Landlock
2018        );
2019        assert_eq!(
2020            effective_sandbox_kind(SandboxKind::Seatbelt, &restricted),
2021            SandboxKind::Seatbelt
2022        );
2023        // … except a None host is always None (nothing to enforce with).
2024        assert_eq!(
2025            effective_sandbox_kind(SandboxKind::None, &restricted),
2026            SandboxKind::None
2027        );
2028        // A restricted *read* axis also engages (Landlock/Seatbelt govern reads).
2029        let read_only = Caveats {
2030            fs_read: Scope::only(["/r".to_string()]),
2031            ..Caveats::top()
2032        };
2033        assert_eq!(
2034            effective_sandbox_kind(SandboxKind::Seatbelt, &read_only),
2035            SandboxKind::Seatbelt
2036        );
2037        // An empty net scope (all egress denied), even with fs unrestricted,
2038        // engages Seatbelt. Landlock engages only on V4+ kernels (≥ 6.7) where
2039        // TCP deny-all is expressible; on older kernels it falls back to None.
2040        let net_denied = Caveats {
2041            net: Scope::none(),
2042            ..Caveats::top()
2043        };
2044        assert_eq!(
2045            effective_sandbox_kind(SandboxKind::Seatbelt, &net_denied),
2046            SandboxKind::Seatbelt,
2047            "Seatbelt kernel-denies egress, so net:none engages it"
2048        );
2049        let expected_landlock_net = if landlock_net_capable() {
2050            SandboxKind::Landlock
2051        } else {
2052            SandboxKind::None
2053        };
2054        assert_eq!(
2055            effective_sandbox_kind(SandboxKind::Landlock, &net_denied),
2056            expected_landlock_net,
2057            "Landlock engages for net:none only when V4 TCP-deny support is present"
2058        );
2059    }
2060
2061    /// AppContainer engages for a loopback-only net scope (#133, ADR 0016).
2062    /// This enables the egress-proxy pattern: `loopback_fenced_caveats` produces
2063    /// a net=loopback grant, and with AppContainer that fence is kernel-expressed
2064    /// (off-box egress is denied; loopback exemption lets the child reach the proxy).
2065    #[test]
2066    fn appcontainer_engages_for_loopback_only_net() {
2067        for host in ["localhost", "127.0.0.1", "::1"] {
2068            let loopback_only = Caveats {
2069                net: Scope::only([host.to_string()]),
2070                ..Caveats::top()
2071            };
2072            assert_eq!(
2073                effective_sandbox_kind(SandboxKind::AppContainer, &loopback_only),
2074                SandboxKind::AppContainer,
2075                "AppContainer must engage for loopback host {host}"
2076            );
2077        }
2078        // A general remote host is NOT loopback-only → falls through to None
2079        // (net advisory; handled by egress-proxy when the sandbox is AppContainer).
2080        let remote = Caveats {
2081            net: Scope::only(["example.com".to_string()]),
2082            ..Caveats::top()
2083        };
2084        assert_eq!(
2085            effective_sandbox_kind(SandboxKind::AppContainer, &remote),
2086            SandboxKind::None,
2087            "general remote host must not directly engage AppContainer"
2088        );
2089    }
2090
2091    /// `loopback_fenced_caveats` + AppContainer engages the backend, enabling
2092    /// `egress_proxy_plan` to route through the loopback proxy on Windows (#133).
2093    #[test]
2094    fn loopback_fenced_caveats_engages_appcontainer() {
2095        let remote = Caveats {
2096            net: Scope::only(["example.com".to_string()]),
2097            ..Caveats::top()
2098        };
2099        let fenced = loopback_fenced_caveats(&remote);
2100        assert!(
2101            net_loopback_only(&fenced),
2102            "loopback_fenced_caveats must produce a loopback-only net scope"
2103        );
2104        assert_eq!(
2105            effective_sandbox_kind(SandboxKind::AppContainer, &fenced),
2106            SandboxKind::AppContainer,
2107            "loopback-fenced caveats must engage AppContainer"
2108        );
2109    }
2110
2111    #[test]
2112    fn best_available_sandbox_is_a_sandbox() {
2113        // Always returns *some* sandbox; on a non-landlock build/kernel it is the
2114        // advisory Noop. Just exercise the trait object.
2115        // AppContainer's `apply` is a deliberate no-op (confinement is applied at
2116        // process creation via `command_prefix`, not to the current thread).
2117        let sb = best_available_sandbox(&Arc::new(SandboxPolicy::default()));
2118        assert!(sb.apply(&Caveats::top()).is_ok());
2119    }
2120
2121    #[cfg(all(target_os = "windows", feature = "windows-appcontainer"))]
2122    #[test]
2123    fn windows_appcontainer_feature_selects_appcontainer_backend() {
2124        assert_eq!(
2125            best_available_sandbox(&Arc::new(SandboxPolicy::default())).kind(),
2126            SandboxKind::AppContainer
2127        );
2128    }
2129}
2130
2131// Real kernel enforcement test. Only meaningful with the feature on Linux; it
2132// asserts the leash is the *kernel's*, not ours — the regression proof that
2133// `fs_write` confines a process even outside the in-process L2 interceptor.
2134#[cfg(all(target_os = "linux", feature = "linux-landlock", test))]
2135mod landlock_kernel_tests {
2136    use super::*;
2137    use crate::Scope;
2138    use std::fs;
2139    use std::path::PathBuf;
2140
2141    fn unique_dir(tag: &str) -> PathBuf {
2142        // No rand dep: derive a unique path from pid + a per-call atomic counter.
2143        use std::sync::atomic::{AtomicU64, Ordering};
2144        static N: AtomicU64 = AtomicU64::new(0);
2145        let mut d = std::env::temp_dir();
2146        d.push(format!(
2147            "agent-bridle-ll-{}-{}-{}",
2148            tag,
2149            std::process::id(),
2150            N.fetch_add(1, Ordering::Relaxed)
2151        ));
2152        fs::create_dir_all(&d).unwrap();
2153        d
2154    }
2155
2156    /// Whether a kernel-enforcement proof should run, skip, or hard-**FAIL** — a
2157    /// pure decision over (Landlock supported?, enforcement required?). Required
2158    /// but unsupported is a FAILURE: a security library must not ship a green
2159    /// build in which its kernel boundary was never exercised (#74).
2160    #[derive(Debug, PartialEq, Eq)]
2161    enum ProofGate {
2162        Run,
2163        Skip,
2164        Fail,
2165    }
2166
2167    fn proof_gate(supported: bool, required: bool) -> ProofGate {
2168        match (supported, required) {
2169            (true, _) => ProofGate::Run,
2170            (false, true) => ProofGate::Fail,
2171            (false, false) => ProofGate::Skip,
2172        }
2173    }
2174
2175    /// `true` if the caller should `return` (skip the proof). **Panics** when
2176    /// Landlock is *required* (`BRIDLE_REQUIRE_LANDLOCK` set, as CI does) but the
2177    /// kernel lacks it — so a flagged run cannot pass without actually exercising
2178    /// the boundary. A local run without the flag legitimately skips (#74).
2179    fn skip_proof_unless_landlock() -> bool {
2180        let required = std::env::var("BRIDLE_REQUIRE_LANDLOCK")
2181            .map(|v| !v.is_empty() && v != "0")
2182            .unwrap_or(false);
2183        match proof_gate(landlock_is_supported(), required) {
2184            ProofGate::Run => false,
2185            ProofGate::Skip => {
2186                eprintln!(
2187                    "skipping Landlock proof: kernel lacks Landlock \
2188                     (set BRIDLE_REQUIRE_LANDLOCK=1 to require it, as CI does)"
2189                );
2190                true
2191            }
2192            ProofGate::Fail => panic!(
2193                "BRIDLE_REQUIRE_LANDLOCK is set but this kernel lacks Landlock — the \
2194                 fs_write/fs_read kernel-enforcement proofs cannot be verified (#74)"
2195            ),
2196        }
2197    }
2198
2199    #[test]
2200    fn proof_gate_required_but_unsupported_is_a_failure() {
2201        assert_eq!(proof_gate(true, false), ProofGate::Run);
2202        assert_eq!(proof_gate(true, true), ProofGate::Run);
2203        assert_eq!(proof_gate(false, false), ProofGate::Skip);
2204        // The crux (#74): required + unsupported must FAIL, never silently skip,
2205        // so CI cannot pass without exercising the kernel boundary.
2206        assert_eq!(proof_gate(false, true), ProofGate::Fail);
2207    }
2208
2209    #[test]
2210    fn fs_write_is_kernel_enforced_outside_scope_denied_inside_allowed() {
2211        if skip_proof_unless_landlock() {
2212            return;
2213        }
2214
2215        let allowed = unique_dir("allowed");
2216        let forbidden = unique_dir("forbidden");
2217        let allowed_t = allowed.clone();
2218        let forbidden_t = forbidden.clone();
2219
2220        // `restrict_self` is per-thread and irreversible, so confine a throwaway
2221        // thread rather than poisoning the test runner's threads.
2222        let (inside_ok, outside) = std::thread::spawn(move || {
2223            let cav = Caveats {
2224                fs_write: Scope::only([allowed_t.to_string_lossy().into_owned()]),
2225                ..Caveats::top()
2226            };
2227            LandlockSandbox::new().apply(&cav).expect("apply landlock");
2228
2229            let inside = fs::write(allowed_t.join("ok.txt"), b"hi");
2230            let outside = fs::write(forbidden_t.join("escape.txt"), b"nope");
2231            (inside.is_ok(), outside)
2232        })
2233        .join()
2234        .unwrap();
2235
2236        assert!(inside_ok, "writing within fs_write scope must succeed");
2237        let err = outside.expect_err("writing outside fs_write scope must be denied by Landlock");
2238        assert_eq!(
2239            err.kind(),
2240            std::io::ErrorKind::PermissionDenied,
2241            "the denial must come from the kernel (EACCES)"
2242        );
2243
2244        let _ = fs::remove_dir_all(&allowed);
2245        let _ = fs::remove_dir_all(&forbidden);
2246    }
2247
2248    /// #144 (I5-B): the Landlock read base is config-driven. Widening
2249    /// `base_read_paths` lets a confined thread read a path that is otherwise
2250    /// outside `fs_read` scope — proving `apply` reads `self.policy`, not the old
2251    /// module const. The control (default policy) denies the same read.
2252    #[test]
2253    fn landlock_config_widens_base_read() {
2254        if skip_proof_unless_landlock() {
2255            return;
2256        }
2257        let allowed = unique_dir("cfg-allowed");
2258        let extra = unique_dir("cfg-extra");
2259        fs::write(extra.join("data.txt"), b"configured").unwrap();
2260
2261        let cav = Caveats {
2262            fs_read: Scope::only([allowed.to_string_lossy().into_owned()]),
2263            ..Caveats::top()
2264        };
2265
2266        // Control: with the DEFAULT policy the out-of-scope `extra` dir is denied.
2267        let (extra_c, cav_c) = (extra.clone(), cav.clone());
2268        let denied = std::thread::spawn(move || {
2269            LandlockSandbox::new().apply(&cav_c).expect("apply");
2270            fs::read(extra_c.join("data.txt"))
2271        })
2272        .join()
2273        .unwrap();
2274        assert!(
2275            denied.is_err(),
2276            "default base read must NOT include the out-of-scope extra dir"
2277        );
2278
2279        // Widened policy: add `extra` to base_read_paths → the same read succeeds.
2280        let mut base = SandboxPolicy::default().base_read_paths;
2281        base.extra.push(extra.to_string_lossy().into_owned());
2282        let policy = Arc::new(SandboxPolicy {
2283            base_read_paths: base,
2284            ..SandboxPolicy::default()
2285        });
2286        let extra_w = extra.clone();
2287        let allowed_read = std::thread::spawn(move || {
2288            LandlockSandbox::with_policy(policy)
2289                .apply(&cav)
2290                .expect("apply");
2291            fs::read(extra_w.join("data.txt"))
2292        })
2293        .join()
2294        .unwrap();
2295        assert!(
2296            allowed_read.is_ok(),
2297            "config-widened base_read_paths must allow the extra dir: {allowed_read:?}"
2298        );
2299
2300        let _ = fs::remove_dir_all(&allowed);
2301        let _ = fs::remove_dir_all(&extra);
2302    }
2303
2304    #[test]
2305    fn empty_fs_write_scope_denies_all_writes() {
2306        if skip_proof_unless_landlock() {
2307            return;
2308        }
2309        let dir = unique_dir("none");
2310        let dir_t = dir.clone();
2311        let outside = std::thread::spawn(move || {
2312            let cav = Caveats {
2313                fs_write: Scope::none(),
2314                ..Caveats::top()
2315            };
2316            LandlockSandbox::new().apply(&cav).expect("apply landlock");
2317            fs::write(dir_t.join("x.txt"), b"nope")
2318        })
2319        .join()
2320        .unwrap();
2321        assert_eq!(
2322            outside
2323                .expect_err("empty fs_write must deny all writes")
2324                .kind(),
2325            std::io::ErrorKind::PermissionDenied
2326        );
2327        let _ = fs::remove_dir_all(&dir);
2328    }
2329
2330    #[test]
2331    fn fs_read_is_kernel_enforced_outside_scope_denied_inside_allowed() {
2332        if skip_proof_unless_landlock() {
2333            return;
2334        }
2335        let allowed = unique_dir("read-allowed");
2336        let forbidden = unique_dir("read-forbidden");
2337        // Create both files BEFORE confining (afterwards the forbidden dir is
2338        // unreadable, but it must already hold a file to attempt the read).
2339        fs::write(allowed.join("ok.txt"), b"in-scope").unwrap();
2340        fs::write(forbidden.join("secret.txt"), b"out-of-scope").unwrap();
2341        let allowed_t = allowed.clone();
2342        let forbidden_t = forbidden.clone();
2343
2344        let (inside, outside) = std::thread::spawn(move || {
2345            let cav = Caveats {
2346                fs_read: Scope::only([allowed_t.to_string_lossy().into_owned()]),
2347                ..Caveats::top()
2348            };
2349            LandlockSandbox::new().apply(&cav).expect("apply landlock");
2350            let inside = fs::read(allowed_t.join("ok.txt"));
2351            let outside = fs::read(forbidden_t.join("secret.txt"));
2352            (inside, outside)
2353        })
2354        .join()
2355        .unwrap();
2356
2357        assert_eq!(inside.expect("in-scope read must succeed"), b"in-scope");
2358        assert_eq!(
2359            outside
2360                .expect_err("reading outside fs_read scope must be denied by Landlock")
2361                .kind(),
2362            std::io::ErrorKind::PermissionDenied,
2363            "the denial must come from the kernel (EACCES)"
2364        );
2365
2366        let _ = fs::remove_dir_all(&allowed);
2367        let _ = fs::remove_dir_all(&forbidden);
2368    }
2369
2370    #[test]
2371    fn read_confined_binary_still_loads_via_base_allowlist() {
2372        if skip_proof_unless_landlock() {
2373            return;
2374        }
2375        let allowed = unique_dir("rc-allowed");
2376        let forbidden = unique_dir("rc-forbidden");
2377        fs::write(allowed.join("ok.txt"), b"hello\n").unwrap();
2378        fs::write(forbidden.join("secret.txt"), b"nope\n").unwrap();
2379        let allowed_t = allowed.clone();
2380        let forbidden_t = forbidden.clone();
2381
2382        // Confine reads, then run a *real* dynamically-linked binary (`cat`):
2383        // it must still load (proving the base allow-list covers the loader and
2384        // libc) and read the in-scope file, but be denied the out-of-scope one.
2385        let (inside, outside) = std::thread::spawn(move || {
2386            let cav = Caveats {
2387                fs_read: Scope::only([allowed_t.to_string_lossy().into_owned()]),
2388                ..Caveats::top()
2389            };
2390            LandlockSandbox::new().apply(&cav).expect("apply landlock");
2391            let inside = std::process::Command::new("cat")
2392                .arg(allowed_t.join("ok.txt"))
2393                .output();
2394            let outside = std::process::Command::new("cat")
2395                .arg(forbidden_t.join("secret.txt"))
2396                .output();
2397            (inside, outside)
2398        })
2399        .join()
2400        .unwrap();
2401
2402        let inside = inside.expect("cat must still load+run under read confinement");
2403        assert!(
2404            inside.status.success(),
2405            "in-scope cat must succeed: {inside:?}"
2406        );
2407        assert_eq!(inside.stdout, b"hello\n");
2408
2409        let outside = outside.expect("cat launches (loader is allowed) even for a denied target");
2410        assert!(
2411            !outside.status.success(),
2412            "cat of an out-of-scope file must fail (read denied): {outside:?}"
2413        );
2414
2415        let _ = fs::remove_dir_all(&allowed);
2416        let _ = fs::remove_dir_all(&forbidden);
2417    }
2418
2419    #[test]
2420    fn fs_read_all_leaves_reads_ambient() {
2421        if skip_proof_unless_landlock() {
2422            return;
2423        }
2424        // With fs_read: All (only fs_write restricted), reads are NOT governed —
2425        // a path outside the write scope is still readable.
2426        let outside_dir = unique_dir("ambient-read");
2427        fs::write(outside_dir.join("readable.txt"), b"still readable").unwrap();
2428        let write_scope = unique_dir("ambient-write");
2429        let outside_t = outside_dir.clone();
2430        let write_t = write_scope.clone();
2431
2432        let read = std::thread::spawn(move || {
2433            let cav = Caveats {
2434                fs_write: Scope::only([write_t.to_string_lossy().into_owned()]),
2435                ..Caveats::top() // fs_read stays All
2436            };
2437            LandlockSandbox::new().apply(&cav).expect("apply landlock");
2438            fs::read(outside_t.join("readable.txt"))
2439        })
2440        .join()
2441        .unwrap();
2442
2443        assert_eq!(
2444            read.expect("fs_read: All must leave reads ambient"),
2445            b"still readable"
2446        );
2447        let _ = fs::remove_dir_all(&outside_dir);
2448        let _ = fs::remove_dir_all(&write_scope);
2449    }
2450
2451    /// #57 boundary: with `exec` confined to `cat`, the granted program (and its
2452    /// libraries) still runs, but a DIRECT `execve` of an un-granted tool (`head`)
2453    /// — the `find -exec curl` escape in miniature — is kernel-denied by the
2454    /// `Execute` allow-list. (This is the boundary/direct-execve close, NOT the
2455    /// trampoline; `exec` stays reported `interceptor`, ADR 0011 D7.)
2456    #[test]
2457    fn exec_direct_execve_of_ungranted_tool_is_kernel_denied() {
2458        if skip_proof_unless_landlock() {
2459            return;
2460        }
2461        let dir = unique_dir("exec");
2462        fs::write(dir.join("data.txt"), b"payload\n").unwrap();
2463        let dir_t = dir.clone();
2464
2465        let (granted, ungranted) = std::thread::spawn(move || {
2466            let cav = Caveats {
2467                exec: Scope::only(["cat".to_string()]),
2468                ..Caveats::top()
2469            };
2470            LandlockSandbox::new().apply(&cav).expect("apply landlock");
2471            let granted = std::process::Command::new("cat")
2472                .arg(dir_t.join("data.txt"))
2473                .output();
2474            let ungranted = std::process::Command::new("head")
2475                .arg(dir_t.join("data.txt"))
2476                .output();
2477            (granted, ungranted)
2478        })
2479        .join()
2480        .unwrap();
2481
2482        let granted = granted.expect("granted `cat` must still load and run");
2483        assert!(
2484            granted.status.success(),
2485            "granted cat must succeed: {granted:?}"
2486        );
2487        assert_eq!(granted.stdout, b"payload\n");
2488
2489        // execve of the un-granted binary is kernel-denied: std surfaces the
2490        // post-fork exec failure as a PermissionDenied spawn error.
2491        let err = ungranted.expect_err("un-granted `head` must be exec-denied by Landlock");
2492        assert_eq!(
2493            err.kind(),
2494            std::io::ErrorKind::PermissionDenied,
2495            "the denial must come from the kernel (EACCES on execve)"
2496        );
2497
2498        let _ = fs::remove_dir_all(&dir);
2499    }
2500
2501    /// #57 adversarial sweep: with `exec` confined to `cat` and writes confined to
2502    /// a scratch dir, EVERY classic "make the permitted program launch something
2503    /// else" DIRECT-execve escape must be kernel-denied — an un-granted tool, a
2504    /// payload the context could write+run, a shebang script (un-granted
2505    /// interpreter), a symlink to an un-granted tool, and the real
2506    /// shells/interpreters that live under `/usr/lib*` (which a recursive lib-dir
2507    /// Execute grant — the narrowing this avoids — would have exposed). The
2508    /// granted program still works (control). (Direct-execve boundary only; the
2509    /// ld.so/interpreter trampoline is out of scope — `exec` stays `interceptor`.)
2510    #[test]
2511    fn exec_escape_attempts_are_all_denied() {
2512        use std::os::unix::fs::{symlink, PermissionsExt};
2513
2514        if skip_proof_unless_landlock() {
2515            return;
2516        }
2517        let scratch = unique_dir("exec-escape"); // in fs_write scope
2518        fs::write(scratch.join("data.txt"), b"ok\n").unwrap();
2519
2520        // A real ELF the confined context could try to run from the scratch dir (a
2521        // "written payload"); copy an existing binary to avoid needing a compiler.
2522        let payload = scratch.join("payload");
2523        if let Ok(src) = std::fs::read("/bin/cat").or_else(|_| std::fs::read("/usr/bin/cat")) {
2524            fs::write(&payload, src).unwrap();
2525            fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)).unwrap();
2526        }
2527        // A shebang script + a symlink to an un-granted interpreter.
2528        let script = scratch.join("script.sh");
2529        fs::write(&script, b"#!/bin/sh\necho pwned\n").unwrap();
2530        fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
2531        let link = scratch.join("sh-link");
2532        let _ = symlink("/bin/sh", &link);
2533
2534        // Real shells/interpreters that live UNDER the library tree (/usr/lib*):
2535        // loader-only Execute must deny them. Tested only where present.
2536        let lib_execs: Vec<PathBuf> = [
2537            "/usr/lib/klibc/bin/sh",
2538            "/usr/lib/initramfs-tools/bin/busybox",
2539            "/usr/lib/git-core/git",
2540        ]
2541        .iter()
2542        .map(PathBuf::from)
2543        .filter(|p| p.exists())
2544        .collect();
2545
2546        let scratch_t = scratch.clone();
2547        let (attempts, control) = std::thread::spawn(move || {
2548            let cav = Caveats {
2549                exec: Scope::only(["cat".to_string()]),
2550                fs_write: Scope::only([scratch_t.to_string_lossy().into_owned()]),
2551                ..Caveats::top()
2552            };
2553            LandlockSandbox::new().apply(&cav).expect("apply landlock");
2554
2555            let mut attempts = vec![
2556                (
2557                    "ungranted-tool".to_string(),
2558                    std::process::Command::new("head")
2559                        .arg("/etc/hostname")
2560                        .output(),
2561                ),
2562                (
2563                    "written-payload".to_string(),
2564                    std::process::Command::new(scratch_t.join("payload")).output(),
2565                ),
2566                (
2567                    "shebang-script".to_string(),
2568                    std::process::Command::new(scratch_t.join("script.sh")).output(),
2569                ),
2570                (
2571                    "symlink-to-sh".to_string(),
2572                    std::process::Command::new(scratch_t.join("sh-link"))
2573                        .arg("-c")
2574                        .arg("echo pwned")
2575                        .output(),
2576                ),
2577            ];
2578            for p in &lib_execs {
2579                attempts.push((
2580                    format!("under-usr-lib:{}", p.display()),
2581                    std::process::Command::new(p).arg("--version").output(),
2582                ));
2583            }
2584            // Control: the granted program still runs.
2585            let control = std::process::Command::new("cat")
2586                .arg(scratch_t.join("data.txt"))
2587                .output();
2588            (attempts, control)
2589        })
2590        .join()
2591        .unwrap();
2592
2593        for (label, res) in attempts {
2594            match res {
2595                Err(e) => assert_eq!(
2596                    e.kind(),
2597                    std::io::ErrorKind::PermissionDenied,
2598                    "escape `{label}` failed for the wrong reason: {e:?}"
2599                ),
2600                Ok(out) => panic!(
2601                    "escape `{label}` was NOT denied — it ran (status {:?}, stdout {:?})",
2602                    out.status, out.stdout
2603                ),
2604            }
2605        }
2606        let control = control.expect("granted `cat` must still run");
2607        assert!(
2608            control.status.success() && control.stdout == b"ok\n",
2609            "control: {control:?}"
2610        );
2611
2612        let _ = fs::remove_dir_all(&scratch);
2613    }
2614
2615    /// #57 / ADR 0011 D3: when BOTH `exec` and `fs_read` are confined, the read
2616    /// base excludes the bin dirs — the granted program (and its libs) still
2617    /// loads, but an un-granted system binary is NOT readable, so it cannot be
2618    /// `ld.so`-trampolined (the trampoline corpus is shrunk to the granted set).
2619    #[test]
2620    fn read_base_excludes_bin_dirs_when_exec_confined() {
2621        if skip_proof_unless_landlock() {
2622            return;
2623        }
2624        let dir = unique_dir("read-narrow");
2625        fs::write(dir.join("data.txt"), b"payload\n").unwrap();
2626        let dir_t = dir.clone();
2627
2628        let (granted, head_bytes) = std::thread::spawn(move || {
2629            let cav = Caveats {
2630                exec: Scope::only(["cat".to_string()]),
2631                fs_read: Scope::only([dir_t.to_string_lossy().into_owned()]),
2632                ..Caveats::top()
2633            };
2634            LandlockSandbox::new().apply(&cav).expect("apply landlock");
2635            // Granted `cat` loads (its binary + libs are read-allowed) and reads
2636            // the in-scope file.
2637            let granted = std::process::Command::new("cat")
2638                .arg(dir_t.join("data.txt"))
2639                .output();
2640            // Reading an un-granted bin-dir binary's bytes (a would-be trampoline
2641            // payload) is denied — the bin dirs are not in the read set.
2642            let head_bytes = std::fs::read("/usr/bin/head").or_else(|_| std::fs::read("/bin/head"));
2643            (granted, head_bytes)
2644        })
2645        .join()
2646        .unwrap();
2647
2648        let granted = granted.expect("granted `cat` must load + run under narrowed reads");
2649        assert!(
2650            granted.status.success() && granted.stdout == b"payload\n",
2651            "granted cat under narrowed reads: {granted:?}"
2652        );
2653        assert!(
2654            head_bytes.is_err(),
2655            "an un-granted bin-dir binary must be unreadable (trampoline corpus shrunk): {head_bytes:?}"
2656        );
2657
2658        let _ = fs::remove_dir_all(&dir);
2659    }
2660
2661    // ── ChildNetworkPolicy::DenyDirect — the seccomp socket()-family egress
2662    //    floor. These use safe `std::net` / `std::os::unix::net` (core forbids
2663    //    `unsafe`): socket *creation* itself is what the seccomp filter EACCES-
2664    //    fails, so a failed `bind`/`connect` at the socket step is the proof.
2665    //    They run on throwaway threads (seccomp, like Landlock, is per-thread and
2666    //    irreversible). The floor is inherited across fork/exec by kernel
2667    //    guarantee — descendant inheritance for the identical filter is proved
2668    //    end-to-end on the newt side (net_guard_executor.rs).
2669
2670    /// DenyDirect under `net: none` denies AF_INET / AF_INET6 socket creation
2671    /// (TCP *and* UDP — the UDP/DNS leg Landlock's TCP-only rule misses) while
2672    /// AF_UNIX stays creatable (a path-named unix socket is fs-fenced, not a
2673    /// seccomp concern).
2674    #[test]
2675    fn deny_direct_seccomp_blocks_off_box_sockets_allows_af_unix() {
2676        if skip_proof_unless_landlock() {
2677            return;
2678        }
2679        let policy = std::sync::Arc::new(crate::SandboxPolicy {
2680            child_network: crate::ChildNetworkPolicy::DenyDirect,
2681            ..crate::SandboxPolicy::default()
2682        });
2683        let (udp4, udp6, tcp4, unix_ok) = std::thread::spawn(move || {
2684            let cav = Caveats {
2685                net: Scope::none(),
2686                ..Caveats::top()
2687            };
2688            LandlockSandbox::with_policy(policy)
2689                .apply(&cav)
2690                .expect("apply landlock + seccomp");
2691            let udp4 = std::net::UdpSocket::bind("127.0.0.1:0").is_err();
2692            let udp6 = std::net::UdpSocket::bind("[::1]:0").is_err();
2693            let tcp4 = std::net::TcpStream::connect("127.0.0.1:9").is_err();
2694            let unix_ok = std::os::unix::net::UnixDatagram::unbound().is_ok();
2695            (udp4, udp6, tcp4, unix_ok)
2696        })
2697        .join()
2698        .unwrap();
2699        assert!(udp4, "DenyDirect must deny AF_INET (UDP) socket creation");
2700        assert!(udp6, "DenyDirect must deny AF_INET6 (UDP) socket creation");
2701        assert!(tcp4, "DenyDirect must deny AF_INET (TCP) socket creation");
2702        assert!(
2703            unix_ok,
2704            "DenyDirect must still allow AF_UNIX socket creation"
2705        );
2706    }
2707
2708    /// The control + backward-compat guard: the DEFAULT `LandlockOnly` policy
2709    /// leaves AF_INET UDP socket creation OPEN under `net: none` — Landlock's
2710    /// TCP-only net rule doesn't cover it. This is exactly the leak DenyDirect
2711    /// closes, and proves the default behavior is unchanged.
2712    #[test]
2713    fn landlock_only_default_leaves_udp_socket_creation_open() {
2714        if skip_proof_unless_landlock() {
2715            return;
2716        }
2717        // Default policy == LandlockOnly.
2718        let policy = std::sync::Arc::new(crate::SandboxPolicy::default());
2719        let udp_created = std::thread::spawn(move || {
2720            let cav = Caveats {
2721                net: Scope::none(),
2722                ..Caveats::top()
2723            };
2724            LandlockSandbox::with_policy(policy)
2725                .apply(&cav)
2726                .expect("apply landlock");
2727            std::net::UdpSocket::bind("127.0.0.1:0").is_ok()
2728        })
2729        .join()
2730        .unwrap();
2731        assert!(
2732            udp_created,
2733            "LandlockOnly (default) must leave UDP socket creation open — the leak DenyDirect closes"
2734        );
2735    }
2736
2737    /// DenyDirect is inert when the caller GRANTED a net scope (they asked for
2738    /// egress): `net_fully_denied` is false, so no seccomp floor is installed and
2739    /// socket creation still works.
2740    #[test]
2741    fn deny_direct_is_inert_when_net_is_granted() {
2742        if skip_proof_unless_landlock() {
2743            return;
2744        }
2745        let policy = std::sync::Arc::new(crate::SandboxPolicy {
2746            child_network: crate::ChildNetworkPolicy::DenyDirect,
2747            ..crate::SandboxPolicy::default()
2748        });
2749        let udp_created = std::thread::spawn(move || {
2750            // net = All (ambient) → a granted net scope; DenyDirect must NOT fire.
2751            let cav = Caveats::top();
2752            LandlockSandbox::with_policy(policy)
2753                .apply(&cav)
2754                .expect("apply landlock");
2755            std::net::UdpSocket::bind("127.0.0.1:0").is_ok()
2756        })
2757        .join()
2758        .unwrap();
2759        assert!(
2760            udp_created,
2761            "DenyDirect must be inert when net is granted (caller asked for egress)"
2762        );
2763    }
2764}
2765
2766// Real kernel-enforcement proof for macOS Seatbelt. Only meaningful on macOS
2767// with the feature; it asserts the leash is the *kernel's* (sandbox-exec's),
2768// not ours — the spawned child's own out-of-scope writes/reads are denied even
2769// though L2 cannot see its syscalls. Mirrors the Landlock proofs above.
2770#[cfg(all(target_os = "macos", feature = "macos-seatbelt", test))]
2771mod seatbelt_kernel_tests {
2772    use super::*;
2773    use crate::Scope;
2774    use std::fs;
2775    use std::path::PathBuf;
2776
2777    /// Whether a proof should run, skip, or hard-**FAIL** — the same gate as the
2778    /// Landlock proofs (#74): *required but unsupported is a FAILURE*, so a
2779    /// macOS CI job that sets `BRIDLE_REQUIRE_SEATBELT` can never go green with
2780    /// the kernel boundary unexercised.
2781    #[derive(Debug, PartialEq, Eq)]
2782    enum ProofGate {
2783        Run,
2784        Skip,
2785        Fail,
2786    }
2787
2788    fn proof_gate(supported: bool, required: bool) -> ProofGate {
2789        match (supported, required) {
2790            (true, _) => ProofGate::Run,
2791            (false, true) => ProofGate::Fail,
2792            (false, false) => ProofGate::Skip,
2793        }
2794    }
2795
2796    /// `true` if the caller should skip the proof. **Panics** when Seatbelt is
2797    /// *required* (`BRIDLE_REQUIRE_SEATBELT` set, as a macOS CI job does) but the
2798    /// host lacks `sandbox-exec`. A local run without the flag legitimately skips.
2799    fn skip_proof_unless_seatbelt() -> bool {
2800        let required = std::env::var("BRIDLE_REQUIRE_SEATBELT")
2801            .map(|v| !v.is_empty() && v != "0")
2802            .unwrap_or(false);
2803        match proof_gate(seatbelt_is_supported(), required) {
2804            ProofGate::Run => false,
2805            ProofGate::Skip => {
2806                eprintln!(
2807                    "skipping Seatbelt proof: /usr/bin/sandbox-exec unavailable \
2808                     (set BRIDLE_REQUIRE_SEATBELT=1 to require it, as macOS CI does)"
2809                );
2810                true
2811            }
2812            ProofGate::Fail => panic!(
2813                "BRIDLE_REQUIRE_SEATBELT is set but /usr/bin/sandbox-exec is unavailable — \
2814                 the fs_write/fs_read kernel-enforcement proofs cannot be verified"
2815            ),
2816        }
2817    }
2818
2819    fn unique_dir(tag: &str) -> PathBuf {
2820        use std::sync::atomic::{AtomicU64, Ordering};
2821        static N: AtomicU64 = AtomicU64::new(0);
2822        let mut d = std::env::temp_dir();
2823        d.push(format!(
2824            "agent-bridle-sb-{}-{}-{}",
2825            tag,
2826            std::process::id(),
2827            N.fetch_add(1, Ordering::Relaxed)
2828        ));
2829        fs::create_dir_all(&d).unwrap();
2830        d
2831    }
2832
2833    /// Spawn `program args` through the real `sandbox-exec` wrapper that
2834    /// [`SeatbeltSandbox::command_prefix`] builds for `cav`, and return its exit
2835    /// status. This exercises the *production* profile path end to end.
2836    fn run_wrapped(cav: &Caveats, program: &str, args: &[&str]) -> std::process::ExitStatus {
2837        let prefix = SeatbeltSandbox::new()
2838            .command_prefix(cav)
2839            .expect("a restricted axis must yield a wrapper prefix");
2840        assert!(!prefix.is_empty(), "expected a sandbox-exec wrapper");
2841        std::process::Command::new(&prefix[0])
2842            .args(&prefix[1..])
2843            .arg(program)
2844            .args(args)
2845            .status()
2846            .expect("spawn sandbox-exec")
2847    }
2848
2849    #[test]
2850    fn proof_gate_required_but_unsupported_is_a_failure() {
2851        assert_eq!(proof_gate(true, false), ProofGate::Run);
2852        assert_eq!(proof_gate(true, true), ProofGate::Run);
2853        assert_eq!(proof_gate(false, false), ProofGate::Skip);
2854        assert_eq!(proof_gate(false, true), ProofGate::Fail);
2855    }
2856
2857    #[test]
2858    fn fs_write_is_kernel_enforced_outside_scope_denied_inside_allowed() {
2859        if skip_proof_unless_seatbelt() {
2860            return;
2861        }
2862        let allowed = unique_dir("w-allowed");
2863        let forbidden = unique_dir("w-forbidden");
2864        let cav = Caveats {
2865            fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
2866            ..Caveats::top()
2867        };
2868
2869        let inside = run_wrapped(
2870            &cav,
2871            "/usr/bin/touch",
2872            &[allowed.join("ok.txt").to_str().unwrap()],
2873        );
2874        assert!(
2875            inside.success(),
2876            "writing within fs_write scope must succeed"
2877        );
2878        assert!(
2879            allowed.join("ok.txt").exists(),
2880            "the in-scope file must exist"
2881        );
2882
2883        let outside = run_wrapped(
2884            &cav,
2885            "/usr/bin/touch",
2886            &[forbidden.join("escape.txt").to_str().unwrap()],
2887        );
2888        assert!(
2889            !outside.success(),
2890            "the kernel must deny a write outside fs_write scope"
2891        );
2892        assert!(
2893            !forbidden.join("escape.txt").exists(),
2894            "the out-of-scope file must NOT have been created"
2895        );
2896
2897        let _ = fs::remove_dir_all(&allowed);
2898        let _ = fs::remove_dir_all(&forbidden);
2899    }
2900
2901    #[test]
2902    fn empty_fs_write_scope_denies_all_writes() {
2903        if skip_proof_unless_seatbelt() {
2904            return;
2905        }
2906        let dir = unique_dir("w-none");
2907        let cav = Caveats {
2908            fs_write: Scope::none(),
2909            ..Caveats::top()
2910        };
2911        let target = dir.join("x.txt");
2912        let prefix = SeatbeltSandbox::new().command_prefix(&cav).expect("prefix");
2913        let out = std::process::Command::new(&prefix[0])
2914            .args(&prefix[1..])
2915            .arg("/usr/bin/touch")
2916            .arg(&target)
2917            .output()
2918            .expect("spawn sandbox-exec");
2919        assert!(!out.status.success(), "empty fs_write must deny all writes");
2920        // Positive control: the failure is the *kernel* denying the write (EPERM),
2921        // not a spurious touch error — so this assertion cannot pass vacuously.
2922        let stderr = String::from_utf8_lossy(&out.stderr);
2923        assert!(
2924            stderr.contains("Operation not permitted"),
2925            "denial must be a sandbox EPERM, got: {stderr:?}"
2926        );
2927        assert!(!target.exists());
2928        let _ = fs::remove_dir_all(&dir);
2929    }
2930
2931    #[test]
2932    fn fs_read_is_kernel_enforced_outside_scope_denied_inside_allowed() {
2933        if skip_proof_unless_seatbelt() {
2934            return;
2935        }
2936        let allowed = unique_dir("r-allowed");
2937        let forbidden = unique_dir("r-forbidden");
2938        fs::write(allowed.join("ok.txt"), b"in-scope").unwrap();
2939        fs::write(forbidden.join("secret.txt"), b"out-of-scope").unwrap();
2940        let cav = Caveats {
2941            fs_read: Scope::only([allowed.to_string_lossy().into_owned()]),
2942            ..Caveats::top()
2943        };
2944
2945        // A real dynamically-linked binary (`cat`) must still load (the base
2946        // allow-list covers dyld) and read the in-scope file …
2947        let inside = run_wrapped(
2948            &cav,
2949            "/bin/cat",
2950            &[allowed.join("ok.txt").to_str().unwrap()],
2951        );
2952        assert!(
2953            inside.success(),
2954            "in-scope cat must load and read under read-confinement"
2955        );
2956        // … but be denied the out-of-scope one.
2957        let outside = run_wrapped(
2958            &cav,
2959            "/bin/cat",
2960            &[forbidden.join("secret.txt").to_str().unwrap()],
2961        );
2962        assert!(
2963            !outside.success(),
2964            "reading outside fs_read scope must be kernel-denied"
2965        );
2966
2967        let _ = fs::remove_dir_all(&allowed);
2968        let _ = fs::remove_dir_all(&forbidden);
2969    }
2970
2971    #[test]
2972    fn net_fully_denied_kernel_blocks_egress() {
2973        if skip_proof_unless_seatbelt() {
2974            return;
2975        }
2976        let curl = "/usr/bin/curl";
2977        if !std::path::Path::new(curl).exists() {
2978            eprintln!("skipping: no curl(1) on this host");
2979            return;
2980        }
2981        let cav = Caveats {
2982            net: Scope::none(),
2983            ..Caveats::top()
2984        };
2985        // Positive control: a benign NON-network command under the SAME net:none
2986        // profile must succeed — proving the profile parsed and only egress is
2987        // denied. Without this, a malformed `(deny network*)` (sandbox-exec exit
2988        // 65, child never launches) would let the denial assertion pass vacuously.
2989        let benign = run_wrapped(&cav, "/bin/echo", &["ok"]);
2990        assert!(
2991            benign.success(),
2992            "net:none must still allow non-network commands (profile must parse)"
2993        );
2994        // Egress denied: curl to a literal IP (no DNS) exits **7** ("couldn't
2995        // connect") because the socket is kernel-denied immediately. Asserting
2996        // exactly 7 — not merely non-zero — rules out the vacuous passes: a
2997        // no-egress host times out (28), a broken profile never launches the child
2998        // (65). `--max-time` bounds it regardless.
2999        let confined = run_wrapped(&cav, curl, &["-sS", "--max-time", "5", "http://1.1.1.1/"]);
3000        assert_eq!(
3001            confined.code(),
3002            Some(7),
3003            "egress under net:none must be kernel-denied at the socket (curl exit 7)"
3004        );
3005    }
3006
3007    /// A one-shot loopback listener answering a single HTTP request, so an ALLOW
3008    /// assertion tests a *reachable* socket (curl 0) — not "connection refused"
3009    /// (also 7). Detached, so an unexpected deny can't hang the test on a
3010    /// never-accepted connection. Returns the bound `SocketAddr`, or `None` if the
3011    /// family is unavailable on this host (e.g. no `::1`), so a caller can skip.
3012    fn spawn_loopback_http(bind: &str) -> Option<std::net::SocketAddr> {
3013        let listener = std::net::TcpListener::bind(bind).ok()?;
3014        let addr = listener.local_addr().ok()?;
3015        std::thread::spawn(move || {
3016            if let Ok((mut sock, _)) = listener.accept() {
3017                use std::io::{Read, Write};
3018                let mut buf = [0u8; 1024];
3019                let _ = sock.read(&mut buf);
3020                let _ = sock.write_all(b"HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok");
3021            }
3022        });
3023        Some(addr)
3024    }
3025
3026    /// A loopback-only `net` grant kernel-confines egress to the loopback
3027    /// *interface* (ADR 0015): the process reaches loopback (v4 **and** v6, since
3028    /// SBPL's `localhost` denotes both) and is kernel-DENIED any off-box host. The
3029    /// grant here names a **single** v4 address (`127.0.0.1`) yet `::1` is still
3030    /// reachable — the documented interface-granular widening (D2): a spawned child
3031    /// is governed only by the kernel rule, not the exact-host admission leash.
3032    #[test]
3033    fn net_loopback_only_permits_loopback_interface_denies_offbox() {
3034        if skip_proof_unless_seatbelt() {
3035            return;
3036        }
3037        let curl = "/usr/bin/curl";
3038        if !std::path::Path::new(curl).exists() {
3039            eprintln!("skipping: no curl(1) on this host");
3040            return;
3041        }
3042        let v4 = spawn_loopback_http("127.0.0.1:0").expect("bind v4 loopback");
3043
3044        // A single v4 loopback address — the case that widens to the interface.
3045        let cav = Caveats {
3046            net: Scope::only(["127.0.0.1".to_string()]),
3047            ..Caveats::top()
3048        };
3049        // Positive control: a benign non-network command runs — the loopback
3050        // profile parsed (a malformed one exits 65 and never launches the child).
3051        assert!(
3052            run_wrapped(&cav, "/bin/echo", &["ok"]).success(),
3053            "loopback-only profile must still run non-network commands (must parse)"
3054        );
3055        // ALLOW (v4): egress to the loopback listener succeeds (curl exit 0). A
3056        // deny-all or malformed rule would fail this — so it cannot pass vacuously.
3057        let v4_url = format!("http://127.0.0.1:{}/", v4.port());
3058        assert!(
3059            run_wrapped(&cav, curl, &["-sS", "--max-time", "5", &v4_url]).success(),
3060            "net:Only([127.0.0.1]) must kernel-PERMIT v4 loopback egress"
3061        );
3062        // ALLOW (v6): `::1` is reachable too — locking the interface-granular
3063        // widening documented in ADR 0015 D2 (kernel `localhost` = 127.0.0.1 + ::1,
3064        // broader than the single-address grant). Skipped only if v6 loopback is
3065        // unavailable on the host (never on stock macOS).
3066        if let Some(v6) = spawn_loopback_http("[::1]:0") {
3067            let v6_url = format!("http://[::1]:{}/", v6.port());
3068            assert!(
3069                run_wrapped(&cav, curl, &["-sS", "--max-time", "5", &v6_url]).success(),
3070                "net:Only([127.0.0.1]) kernel-permits the whole loopback interface, incl. ::1 (ADR 0015 D2)"
3071            );
3072        }
3073        // DENY: off-box egress to a literal IP (no DNS) is kernel-denied at the
3074        // socket. Assert both curl exit 7 AND the EPERM signal ("Operation not
3075        // permitted") in stderr — so a no-internet runner (ENETUNREACH, also exit
3076        // 7) cannot make this pass vacuously; it must be a *permission* denial.
3077        let offbox = run_wrapped_output(
3078            &cav,
3079            curl,
3080            &["-sS", "-v", "--max-time", "5", "http://1.1.1.1/"],
3081        );
3082        assert_eq!(
3083            offbox.status.code(),
3084            Some(7),
3085            "net:Only([127.0.0.1]) must kernel-DENY off-box egress (curl exit 7)"
3086        );
3087        let stderr = String::from_utf8_lossy(&offbox.stderr);
3088        assert!(
3089            stderr.contains("Operation not permitted"),
3090            "off-box denial must be a kernel EPERM, not a routing failure: {stderr}"
3091        );
3092    }
3093
3094    /// Like [`run_wrapped`] but captures stdout/stderr, so a proof can assert on
3095    /// the *interior* exec behavior (a granted program's child exec statuses) the
3096    /// kernel produced — the L3-grain the `exec` axis claims.
3097    fn run_wrapped_output(cav: &Caveats, program: &str, args: &[&str]) -> std::process::Output {
3098        let prefix = SeatbeltSandbox::new()
3099            .command_prefix(cav)
3100            .expect("a restricted axis must yield a wrapper prefix");
3101        assert!(!prefix.is_empty(), "expected a sandbox-exec wrapper");
3102        std::process::Command::new(&prefix[0])
3103            .args(&prefix[1..])
3104            .arg(program)
3105            .args(args)
3106            .output()
3107            .expect("spawn sandbox-exec")
3108    }
3109
3110    /// The exec allow-list is kernel-enforced at the **interior**: a granted shell
3111    /// runs, may exec a *listed* binary, but is kernel-denied an *unlisted* one —
3112    /// the L3 gap a path allow-list alone cannot reach (ADR 0014). The discriminator
3113    /// is exact: the unlisted `/usr/bin/false` must fail at **exec** (status 127),
3114    /// not run-and-return-1 — so this cannot pass vacuously.
3115    #[test]
3116    fn exec_allowlist_permits_listed_denies_unlisted_child() {
3117        if skip_proof_unless_seatbelt() {
3118            return;
3119        }
3120        let cav = Caveats {
3121            exec: Scope::only(["/bin/zsh".to_string(), "/usr/bin/true".to_string()]),
3122            ..Caveats::top()
3123        };
3124        let out = run_wrapped_output(
3125            &cav,
3126            "/bin/zsh",
3127            &["-c", "/usr/bin/true; echo T=$?; /usr/bin/false; echo F=$?"],
3128        );
3129        let stdout = String::from_utf8_lossy(&out.stdout);
3130        assert!(
3131            stdout.contains("T=0"),
3132            "a listed binary must exec and run (T=0): {stdout:?}"
3133        );
3134        assert!(
3135            stdout.contains("F=127"),
3136            "an unlisted binary must be kernel-denied at EXEC (status 127), not run: {stdout:?}"
3137        );
3138    }
3139
3140    /// The `exec:none`-style floor: when the granted set is just the entry shell,
3141    /// the shell launches but may exec **nothing** further — every child exec is
3142    /// kernel-denied. This is the interior "no further exec" guarantee.
3143    #[test]
3144    fn granted_shell_cannot_exec_any_unlisted_child() {
3145        if skip_proof_unless_seatbelt() {
3146            return;
3147        }
3148        let cav = Caveats {
3149            exec: Scope::only(["/bin/zsh".to_string()]),
3150            ..Caveats::top()
3151        };
3152        let out = run_wrapped_output(&cav, "/bin/zsh", &["-c", "/usr/bin/true; echo S=$?"]);
3153        let stdout = String::from_utf8_lossy(&out.stdout);
3154        assert!(
3155            stdout.contains("S=127"),
3156            "a shell granted only itself must be denied every child exec (S=127): {stdout:?}"
3157        );
3158    }
3159
3160    /// The ADR 0011 loader trampoline — the bypass that has **no Landlock hook**
3161    /// and forces the Linux seccomp backstop — is *closed by the platform* on
3162    /// macOS. A granted interpreter (`perl`) cannot reach an unlisted binary by:
3163    /// (a) directly `exec`ing it, nor (b) trampolining through `dyld`. Both are
3164    /// governed `process-exec`s; `dyld` is not allow-listed, so both are denied.
3165    #[test]
3166    fn granted_interpreter_cannot_trampoline_to_unlisted_binary() {
3167        if skip_proof_unless_seatbelt() {
3168            return;
3169        }
3170        let cav = Caveats {
3171            exec: Scope::only(["/usr/bin/perl".to_string()]),
3172            ..Caveats::top()
3173        };
3174        // Each `exec` returns (and perl continues) only when the exec was DENIED.
3175        let script = "print \"PERL-RAN\\n\"; \
3176                      exec(\"/usr/bin/true\"); print \"DIRECT-DENIED\\n\"; \
3177                      exec(\"/usr/lib/dyld\", \"/usr/bin/true\"); print \"TRAMPOLINE-DENIED\\n\";";
3178        let out = run_wrapped_output(&cav, "/usr/bin/perl", &["-e", script]);
3179        let stdout = String::from_utf8_lossy(&out.stdout);
3180        assert!(
3181            stdout.contains("PERL-RAN"),
3182            "the granted interpreter must run: {stdout:?}"
3183        );
3184        assert!(
3185            stdout.contains("DIRECT-DENIED"),
3186            "direct exec of an unlisted binary must be denied: {stdout:?}"
3187        );
3188        assert!(
3189            stdout.contains("TRAMPOLINE-DENIED"),
3190            "the dyld loader trampoline must be denied (no standing loader entry): {stdout:?}"
3191        );
3192    }
3193
3194    /// Positive control / no deny-of-function: an allow-listed **dynamically
3195    /// linked** binary still loads its dylibs (via the kernel-trusted dyld path,
3196    /// which the exec allow-list does not gate) and runs normally under exec
3197    /// confinement — proving the axis confines *spawning*, not legitimate linking.
3198    #[test]
3199    fn exec_confinement_does_not_break_dynamic_linking() {
3200        if skip_proof_unless_seatbelt() {
3201            return;
3202        }
3203        let curl = "/usr/bin/curl";
3204        if !std::path::Path::new(curl).exists() {
3205            eprintln!("skipping: no curl(1) on this host");
3206            return;
3207        }
3208        let cav = Caveats {
3209            exec: Scope::only([curl.to_string()]),
3210            ..Caveats::top()
3211        };
3212        let status = run_wrapped(&cav, curl, &["--version"]);
3213        assert!(
3214            status.success(),
3215            "an allow-listed dynamic binary must load + run under exec confinement"
3216        );
3217    }
3218}