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}