Skip to main content

agent_bridle_core/
report.rs

1//! Axis-granular confinement honesty (ADR 0004 D1).
2//!
3//! A single [`SandboxKind`] cannot honestly describe a run where axis coverage
4//! differs. For example, Landlock kernel-confines the filesystem, reports
5//! `exec` only at interceptor strength because of the loader trampoline, and
6//! can kernel-deny TCP only for an empty `net` scope on ABI-v4 kernels.
7//! Reporting only `sandbox_kind: landlock` is true coarsely but insufficient at
8//! the grain a caller reasons about.
9//!
10//! [`enforcement_report`] classifies each **restricted** Caveat axis (`Only(_)`,
11//! not `All`) as one of [`AxisEnforcement`]. It is a pure function of the
12//! effective [`Caveats`] and the active [`SandboxKind`] — no IO. The coarse
13//! `sandbox_kind` stays the **minimum** claim; this report refines it and is
14//! never allowed to describe an `advisory` axis as confined.
15
16use serde::{Deserialize, Serialize};
17
18use crate::{Caveats, SandboxKind, Scope};
19
20/// How a single restricted Caveat axis is actually enforced for a run
21/// (ADR 0004 D1).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum AxisEnforcement {
25    /// An OS ruleset enforces this axis against the spawned program's
26    /// **interior** (e.g. Landlock on `fs_write`). The strongest claim.
27    ///
28    /// **`exec → kernel` is about *identity*, not *behavior* (ADR 0013 D6 /
29    /// agent-bridle#114).** It means "no **un-granted program** can run as a
30    /// process" — via Seatbelt `process-exec*` (ADR 0014), or a Linux minimal
31    /// rootfs that physically excludes un-granted binaries (ADR 0013). It does
32    /// **NOT** mean a *granted* program — especially a granted **interpreter**
33    /// (`sh`, `python`, `perl`) — is constrained in what it *does*: its interior
34    /// logic is still bounded only by the `fs_read`/`fs_write`/`net` axes (read
35    /// those for the data-side guarantee). Do not read `exec → kernel` as "this
36    /// program will only do what I expect."
37    Kernel,
38    /// The in-process L2 leash gates this axis at the spawn/open chokepoint —
39    /// it holds for the engine's own operations, **not** for a permitted
40    /// external child's interior (a `find -exec` child's reads escape it).
41    Interceptor,
42    /// Validated at admission, then **ambient** — nothing backstops the spawned
43    /// interior. Honest "we checked the request, we cannot confine the effect."
44    Advisory,
45}
46
47impl AxisEnforcement {
48    /// Ascending confinement strength: `Advisory (0) < Interceptor (1) <
49    /// Kernel (2)`.
50    ///
51    /// The variants are *declared* strongest-first (`Kernel` first) so the type
52    /// reads top-down — which means a naive `#[derive(PartialOrd, Ord)]` would
53    /// order them DESCENDING (`Kernel < Advisory`) and silently invert every
54    /// `min` / [`fence_strength`] into a **fail-open** (ADR 0012 D2). The order is
55    /// therefore defined **explicitly** here, never derived; this hand-written
56    /// `impl` also turns a future stray `#[derive(Ord)]` into a hard compile error
57    /// (conflicting impls) rather than a silent security bug.
58    fn rank(self) -> u8 {
59        match self {
60            AxisEnforcement::Advisory => 0,
61            AxisEnforcement::Interceptor => 1,
62            AxisEnforcement::Kernel => 2,
63        }
64    }
65}
66
67impl Ord for AxisEnforcement {
68    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
69        self.rank().cmp(&other.rank())
70    }
71}
72
73impl PartialOrd for AxisEnforcement {
74    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
75        Some(self.cmp(other))
76    }
77}
78
79/// Per-axis confinement report for the four OS-confinement Caveat axes
80/// (`fs_read`, `fs_write`, `exec`, `net`).
81///
82/// Only **restricted** (`Only(_)`) axes appear (`Some(_)`); an axis granted
83/// `All` is unrestricted — there is nothing to confine — and is `None`. The
84/// `max_calls` / `valid_for_generation` axes are gate-enforced budget/causality,
85/// not OS-confinement axes, so they are not part of this report.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
87pub struct EnforcementReport {
88    /// Enforcement of the `fs_read` axis, when restricted.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub fs_read: Option<AxisEnforcement>,
91    /// Enforcement of the `fs_write` axis, when restricted.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub fs_write: Option<AxisEnforcement>,
94    /// Enforcement of the `exec` axis, when restricted.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub exec: Option<AxisEnforcement>,
97    /// Enforcement of the `net` axis, when restricted.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub net: Option<AxisEnforcement>,
100}
101
102impl EnforcementReport {
103    /// `true` when no axis is restricted (every axis is `All`) — so the report
104    /// carries no information and may be omitted from a result envelope.
105    #[must_use]
106    pub fn is_empty(&self) -> bool {
107        self.fs_read.is_none()
108            && self.fs_write.is_none()
109            && self.exec.is_none()
110            && self.net.is_none()
111    }
112}
113
114/// `true` if a scope actually restricts (`Only(_)`); `All` does not confine.
115fn is_restricted<T: Ord + Clone>(scope: &Scope<T>) -> bool {
116    matches!(scope, Scope::Only(_))
117}
118
119/// Classify each restricted axis of `effective` under the `active` sandbox
120/// (ADR 0004 D1). Pure; no IO.
121///
122/// The mapping reflects what each layer *actually* enforces today:
123///
124/// - **`fs_read` / `fs_write`** — `kernel` under the native filesystem
125///   boundaries (Landlock, Seatbelt, AppContainer, minimal-rootfs, micro-VM);
126///   otherwise `interceptor` for the engine's own opens.
127/// - **`exec`** — `kernel` under Seatbelt and identity-closing tiers, and for
128///   AppContainer's deny-all scope. Landlock, AppContainer non-empty allowlists,
129///   and `None` remain `interceptor`.
130/// - **`net`** — `kernel` for a micro-VM; for empty or loopback-only scopes under
131///   Seatbelt/AppContainer; and for empty TCP scope under Landlock ABI v4.
132///   Other restricted scopes remain honestly `advisory`.
133#[must_use]
134pub fn enforcement_report(effective: &Caveats, active: SandboxKind) -> EnforcementReport {
135    // Filesystem axes: kernel when an OS sandbox actually governs them, else the
136    // in-process interceptor. Exhaustive over `SandboxKind` so a new backend
137    // must decide its mapping rather than silently defaulting.
138    let fs = |scope: &Scope<String>| {
139        is_restricted(scope).then_some(match active {
140            // Real OS sandboxes that govern the filesystem axes in the kernel —
141            // Landlock (Linux, FS allow-list via restrict_self), Seatbelt (macOS,
142            // SBPL read/write rules), and the Linux minimal-rootfs jail (read-only/
143            // read-write bind-mounts inside its mount namespace, ADR 0013 D3/D4).
144            SandboxKind::Landlock
145            | SandboxKind::Seatbelt
146            | SandboxKind::MinimalRootfs
147            | SandboxKind::MicroVm => AxisEnforcement::Kernel,
148            // AppContainer (#51): per-path ACEs are now wired in the launcher via
149            // Win32 ACL APIs. The container's default deny-all-user-directories
150            // combined with explicit DACL grants makes both read and write Kernel
151            // for user-space paths. System paths remain accessible via
152            // ALL_APPLICATION_PACKAGES (a known limitation documented in ADR 0009),
153            // but write access to system paths is still kernel-denied by NTFS.
154            SandboxKind::AppContainer => AxisEnforcement::Kernel,
155            SandboxKind::None => AxisEnforcement::Interceptor,
156        })
157    };
158    EnforcementReport {
159        fs_read: fs(&effective.fs_read),
160        fs_write: fs(&effective.fs_write),
161        exec: is_restricted(&effective.exec).then_some(match active {
162            // `exec → kernel` is reserved for modes that close the axis by
163            // *identity*: Seatbelt (macOS) via `process-exec*` — interior-covering,
164            // no trampoline bypass on Apple Silicon (ADR 0014) — and the Linux
165            // minimal-rootfs jail, where no un-granted binary physically *exists*
166            // to run or to `ld.so`-trampoline into (ADR 0013 D5, ADR 0011 D7's
167            // precondition made physically true). Landlock's exec axis is held
168            // (agent-bridle#31/#57) and a Noop host has no OS allow-list, so both
169            // stay interceptor. AppContainer: when exec is *fully denied* (empty
170            // allow-list), `PROCESS_CREATION_CHILD_PROCESS_RESTRICTED` prevents
171            // any child-process creation at the kernel level, closing the exec axis
172            // by OS enforcement (#123). A non-empty allow-list cannot be kernel-
173            // expressed (no WDAC policy), so it stays interceptor.
174            SandboxKind::AppContainer if crate::sandbox::exec_fully_denied(effective) => {
175                AxisEnforcement::Kernel
176            }
177            SandboxKind::Seatbelt | SandboxKind::MinimalRootfs | SandboxKind::MicroVm => {
178                AxisEnforcement::Kernel
179            }
180            SandboxKind::Landlock | SandboxKind::AppContainer | SandboxKind::None => {
181                AxisEnforcement::Interceptor
182            }
183        }),
184        net: is_restricted(&effective.net).then_some(match active {
185            _ if crate::sandbox::has_unix_socket_grants(effective)
186                && active != SandboxKind::Seatbelt =>
187            {
188                AxisEnforcement::Advisory
189            }
190            // AppContainer (#133, ADR 0016): the capability model kernel-denies all
191            // off-box egress when no internet capability SIDs are granted. Two net
192            // scopes reach Kernel: deny-all (empty set) and loopback-only — both
193            // route through the AppContainer capability block + loopback exemption.
194            // A general remote-host allow-list is enforced userspace by the egress
195            // proxy; net stays Advisory there (the proxy over-delivers above the
196            // AppContainer floor, ADR 0006). MicroVM: no guest NIC → always Kernel.
197            SandboxKind::AppContainer
198                if crate::sandbox::net_fully_denied(effective)
199                    || crate::sandbox::net_loopback_only(effective) =>
200            {
201                AxisEnforcement::Kernel
202            }
203            SandboxKind::AppContainer => AxisEnforcement::Advisory,
204            SandboxKind::MicroVm => AxisEnforcement::Kernel,
205            // Seatbelt kernel-denies *all* egress when the net scope is empty
206            // (`(deny network*)`), and confines a **loopback-only** allowlist to
207            // the loopback interface (`(allow network* (remote ip "localhost:*"))`)
208            // so the process's own off-box socket egress is kernel-denied (ADR
209            // 0015) — both honest `kernel`. A general remote host is inexpressible
210            // in SBPL (only
211            // `*`/`localhost` + ports), so it stays advisory. Landlock does not gate
212            // net this increment.
213            SandboxKind::Seatbelt
214                if crate::sandbox::net_fully_denied(effective)
215                    || crate::sandbox::net_loopback_only(effective)
216                    || crate::sandbox::net_unix_only(effective) =>
217            {
218                AxisEnforcement::Kernel
219            }
220            // Landlock V4 (kernel ≥ 6.7) can deny-all TCP when the net scope is
221            // empty (no NetPort rules → deny-by-default). Non-empty host allowlists
222            // are not expressible (port-based, not hostname-based) and stay advisory.
223            SandboxKind::Landlock
224                if crate::sandbox::net_fully_denied(effective)
225                    && crate::sandbox::landlock_net_capable() =>
226            {
227                AxisEnforcement::Kernel
228            }
229            // The minimal-rootfs jail does not namespace the network this tier, so
230            // egress is unconfined — advisory, never overclaimed (ADR 0013 D5).
231            SandboxKind::Landlock
232            | SandboxKind::Seatbelt
233            | SandboxKind::MinimalRootfs
234            | SandboxKind::None => AxisEnforcement::Advisory,
235        }),
236    }
237}
238
239/// The fence's overall strength: the greatest-lower-bound (weakest) enforcement
240/// across the **restricted** axes of `report` — a fence is only as strong as its
241/// weakest confined axis (ADR 0012 D1). Returns `None` when no axis is restricted
242/// (an empty report: a top grant confining nothing — a vacuous top with nothing
243/// to enforce). **Pure**: recomputed from the report on every call, never stored,
244/// so it cannot diverge from the lattice it summarizes (ADR 0004 D3 / ADR 0012's
245/// rejection of a parallel strength enum). Consumers that need to know *which*
246/// axis dropped the strength still read the per-axis [`EnforcementReport`].
247#[must_use]
248pub fn fence_strength(report: &EnforcementReport) -> Option<AxisEnforcement> {
249    [report.fs_read, report.fs_write, report.exec, report.net]
250        .into_iter()
251        .flatten()
252        .min()
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::{CountBound, Scope};
259
260    /// All axes restricted, so every axis appears in the report.
261    fn fully_restricted() -> Caveats {
262        Caveats {
263            fs_read: Scope::only(["/r".to_string()]),
264            fs_write: Scope::only(["/w".to_string()]),
265            exec: Scope::only(["echo".to_string()]),
266            net: Scope::only(["example.com".to_string()]),
267            max_calls: CountBound::Unlimited,
268            valid_for_generation: Scope::All,
269        }
270    }
271
272    #[test]
273    fn landlock_marks_fs_kernel_exec_interceptor_net_advisory() {
274        let r = enforcement_report(&fully_restricted(), SandboxKind::Landlock);
275        assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
276        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
277        assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
278        assert_eq!(r.net, Some(AxisEnforcement::Advisory));
279    }
280
281    /// Landlock V4 (kernel ≥ 6.7) kernel-denies ALL TCP when `net` is the empty
282    /// set (deny-all), because we declare `AccessNet` without adding any `NetPort`
283    /// rules — deny-by-default. On pre-V4 kernels the `handle_access` is a BestEffort
284    /// no-op so `net` stays advisory. The test dynamically queries the probe to stay
285    /// correct in both environments (ADR 0013 net-axis Landlock extension, issue #35).
286    #[test]
287    fn landlock_marks_net_kernel_when_net_fully_denied_and_v4_capable() {
288        let net_denied = crate::Caveats {
289            net: crate::Scope::none(),
290            ..crate::Caveats::top()
291        };
292        let r = enforcement_report(&net_denied, SandboxKind::Landlock);
293        let expected = if crate::sandbox::landlock_net_capable() {
294            Some(AxisEnforcement::Kernel)
295        } else {
296            Some(AxisEnforcement::Advisory)
297        };
298        assert_eq!(
299            r.net, expected,
300            "Landlock net enforcement depends on V4 kernel support"
301        );
302        // fs is not restricted, so those axes must be absent
303        assert_eq!(r.fs_read, None);
304        assert_eq!(r.fs_write, None);
305        assert_eq!(r.exec, None);
306    }
307
308    /// Seatbelt (macOS) governs the fs axes in the kernel like Landlock, **and**
309    /// the `exec` axis via `process-exec*` (ADR 0014) — so exec is `kernel`, not
310    /// `interceptor`. `net` here is a general remote host allowlist, which SBPL
311    /// cannot express, so it stays advisory (the empty-net and loopback-only kernel
312    /// cases are covered by
313    /// [`seatbelt_net_kernel_for_empty_and_loopback_advisory_for_remote_host`]).
314    #[test]
315    fn seatbelt_marks_fs_and_exec_kernel_net_advisory() {
316        let r = enforcement_report(&fully_restricted(), SandboxKind::Seatbelt);
317        assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
318        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
319        assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
320        assert_eq!(r.net, Some(AxisEnforcement::Advisory));
321    }
322
323    /// The macOS exec-axis honesty distinction from Landlock: a restricted `exec`
324    /// is `kernel` under Seatbelt but only `interceptor` under Landlock (its exec
325    /// axis is held) and a Noop host. ADR 0014.
326    #[test]
327    fn exec_is_kernel_under_seatbelt_interceptor_elsewhere() {
328        let cav = Caveats {
329            exec: Scope::only(["git".to_string()]),
330            ..Caveats::top()
331        };
332        assert_eq!(
333            enforcement_report(&cav, SandboxKind::Seatbelt).exec,
334            Some(AxisEnforcement::Kernel)
335        );
336        assert_eq!(
337            enforcement_report(&cav, SandboxKind::Landlock).exec,
338            Some(AxisEnforcement::Interceptor)
339        );
340        assert_eq!(
341            enforcement_report(&cav, SandboxKind::None).exec,
342            Some(AxisEnforcement::Interceptor)
343        );
344    }
345
346    /// ADR 0013 D5 (#110): a minimal-rootfs jail run governs the filesystem axes
347    /// (bind-mounts) **and** the `exec` axis (identity by existence) in the kernel;
348    /// `net` is not namespaced this tier, so it stays advisory.
349    #[test]
350    fn minimal_rootfs_marks_fs_and_exec_kernel_net_advisory() {
351        let r = enforcement_report(&fully_restricted(), SandboxKind::MinimalRootfs);
352        assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
353        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
354        assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
355        assert_eq!(r.net, Some(AxisEnforcement::Advisory));
356    }
357
358    /// ADR 0013 D5 (#110) acceptance: a restricted `exec` is `kernel` in the
359    /// minimal-rootfs mode but only `interceptor` under a Landlock-only boundary
360    /// (its exec axis is held — ADR 0011). `kernel` is reserved for the rootfs mode.
361    #[test]
362    fn exec_is_kernel_under_minimal_rootfs_interceptor_under_landlock() {
363        let cav = Caveats {
364            exec: Scope::only(["cat".to_string()]),
365            ..Caveats::top()
366        };
367        assert_eq!(
368            enforcement_report(&cav, SandboxKind::MinimalRootfs).exec,
369            Some(AxisEnforcement::Kernel),
370            "minimal-rootfs closes exec by identity ⇒ kernel"
371        );
372        assert_eq!(
373            enforcement_report(&cav, SandboxKind::Landlock).exec,
374            Some(AxisEnforcement::Interceptor),
375            "a Landlock-only boundary run stays exec→interceptor (ADR 0011)"
376        );
377    }
378
379    /// ADR 0013 D3 (#111): the Tier-2 micro-VM confines every OS axis in the
380    /// kernel — fs + exec by the guest boundary (identity by existence), and net
381    /// because the guest has no network device (egress impossible). The strongest
382    /// tier: `fence_strength` is therefore `Kernel` even with all axes restricted.
383    #[test]
384    fn micro_vm_marks_all_axes_kernel() {
385        let r = enforcement_report(&fully_restricted(), SandboxKind::MicroVm);
386        assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
387        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
388        assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
389        assert_eq!(r.net, Some(AxisEnforcement::Kernel));
390        assert_eq!(fence_strength(&r), Some(AxisEnforcement::Kernel));
391    }
392
393    /// AppContainer (#51): fs ACL narrowing is wired in the launcher. Both fs axes
394    /// are now Kernel (per-path DACL grants + container default deny-user-dirs).
395    /// exec stays Interceptor for non-deny-all (only deny-all → Kernel via #123).
396    /// net stays Advisory for a general remote-host allowlist (no egress proxy yet,
397    /// #133).
398    #[test]
399    fn appcontainer_marks_fs_kernel_exec_interceptor_net_advisory_for_allowlist() {
400        // `fully_restricted()` uses net: Only(["example.com"]) — a non-empty
401        // allowlist the launcher cannot kernel-express → Advisory.
402        let r = enforcement_report(&fully_restricted(), SandboxKind::AppContainer);
403        assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
404        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
405        assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
406        assert_eq!(r.net, Some(AxisEnforcement::Advisory));
407    }
408
409    /// net → Kernel only when the scope is empty (deny-all): the AppContainer
410    /// capability model withholds all network SIDs → kernel-denied egress.
411    #[test]
412    fn appcontainer_marks_net_kernel_for_deny_all() {
413        let net_deny_all = Caveats {
414            net: Scope::none(),
415            ..Caveats::top()
416        };
417        let r = enforcement_report(&net_deny_all, SandboxKind::AppContainer);
418        assert_eq!(r.net, Some(AxisEnforcement::Kernel));
419        // fs/exec are unrestricted (top) — not in the report.
420        assert_eq!(r.fs_read, None);
421        assert_eq!(r.fs_write, None);
422        assert_eq!(r.exec, None);
423    }
424
425    /// net → Kernel for loopback-only under AppContainer (#133, ADR 0016): the
426    /// container has no internet capability SIDs so off-box egress is kernel-denied;
427    /// the loopback exemption allows 127.0.0.1→proxy. Mirrors Seatbelt's
428    /// loopback-only Kernel claim (ADR 0015).
429    #[test]
430    fn appcontainer_marks_net_kernel_for_loopback_only() {
431        for host in ["localhost", "127.0.0.1", "::1"] {
432            let loopback_only = Caveats {
433                net: Scope::only([host.to_string()]),
434                ..Caveats::top()
435            };
436            let r = enforcement_report(&loopback_only, SandboxKind::AppContainer);
437            assert_eq!(
438                r.net,
439                Some(AxisEnforcement::Kernel),
440                "loopback host {host} must be Kernel under AppContainer"
441            );
442        }
443    }
444
445    /// exec → Kernel for AppContainer only when the scope is empty (deny-all):
446    /// `PROCESS_CREATION_CHILD_PROCESS_RESTRICTED` blocks any child-process
447    /// creation at the kernel level (#123, ADR 0013 D7).
448    #[test]
449    fn appcontainer_marks_exec_kernel_for_deny_all() {
450        let exec_deny_all = Caveats {
451            exec: Scope::none(),
452            ..Caveats::top()
453        };
454        let r = enforcement_report(&exec_deny_all, SandboxKind::AppContainer);
455        assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
456        // fs/net are unrestricted (top) — not in the report.
457        assert_eq!(r.fs_read, None);
458        assert_eq!(r.fs_write, None);
459        assert_eq!(r.net, None);
460    }
461
462    /// exec with a non-empty allow-list stays Interceptor: only the deny-all
463    /// case can be kernel-enforced (no WDAC policy in the AppContainer launcher).
464    #[test]
465    fn appcontainer_exec_allowlist_stays_interceptor() {
466        let exec_allowlist = Caveats {
467            exec: Scope::only(["echo".to_string()]),
468            ..Caveats::top()
469        };
470        let r = enforcement_report(&exec_allowlist, SandboxKind::AppContainer);
471        assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
472    }
473
474    /// Seatbelt's net honesty is scope-shaped (ADR 0015): kernel for the two
475    /// policies SBPL can express — an **empty** scope (`(deny network*)`) and a
476    /// **loopback-only** allowlist (egress confined to the loopback interface) —
477    /// and advisory for a general remote host, which SBPL cannot name.
478    #[test]
479    fn seatbelt_net_kernel_for_empty_and_loopback_advisory_for_remote_host() {
480        let net_report = |net| {
481            enforcement_report(
482                &Caveats {
483                    net,
484                    ..Caveats::top()
485                },
486                SandboxKind::Seatbelt,
487            )
488            .net
489        };
490
491        // Empty net (all egress denied) → kernel.
492        assert_eq!(net_report(Scope::none()), Some(AxisEnforcement::Kernel));
493        // Loopback-only allowlist (off-box egress kernel-impossible) → kernel.
494        for host in ["localhost", "127.0.0.1", "::1"] {
495            assert_eq!(
496                net_report(Scope::only([host.to_string()])),
497                Some(AxisEnforcement::Kernel),
498                "loopback host {host} must report kernel"
499            );
500        }
501        // A general remote host → advisory (inexpressible in SBPL).
502        assert_eq!(
503            net_report(Scope::only(["example.com".to_string()])),
504            Some(AxisEnforcement::Advisory)
505        );
506        // A single remote host taints an otherwise-loopback set → advisory.
507        assert_eq!(
508            net_report(Scope::only([
509                "localhost".to_string(),
510                "example.com".to_string()
511            ])),
512            Some(AxisEnforcement::Advisory)
513        );
514    }
515
516    /// The honesty oracle for a Noop host: NO restricted axis is ever `kernel`.
517    #[test]
518    fn noop_host_never_reports_kernel() {
519        let r = enforcement_report(&fully_restricted(), SandboxKind::None);
520        assert_eq!(r.fs_read, Some(AxisEnforcement::Interceptor));
521        assert_eq!(r.fs_write, Some(AxisEnforcement::Interceptor));
522        assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
523        assert_eq!(r.net, Some(AxisEnforcement::Advisory));
524        for axis in [r.fs_read, r.fs_write, r.exec, r.net] {
525            assert_ne!(
526                axis,
527                Some(AxisEnforcement::Kernel),
528                "Noop must never claim kernel"
529            );
530        }
531    }
532
533    /// Unrestricted axes (`All`) are omitted — there is nothing to confine.
534    #[test]
535    fn unrestricted_axes_are_omitted() {
536        let top = Caveats::top(); // every axis is All
537        let r = enforcement_report(&top, SandboxKind::Landlock);
538        assert!(
539            r.is_empty(),
540            "all-`All` caveats produce an empty report: {r:?}"
541        );
542        assert_eq!(r.fs_write, None);
543    }
544
545    /// A mix: only `fs_write` restricted under Landlock → that one axis kernel,
546    /// the rest absent.
547    #[test]
548    fn only_restricted_axes_appear() {
549        let caveats = Caveats {
550            fs_write: Scope::only(["/w".to_string()]),
551            ..Caveats::top()
552        };
553        let r = enforcement_report(&caveats, SandboxKind::Landlock);
554        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
555        assert_eq!(r.fs_read, None);
556        assert_eq!(r.exec, None);
557        assert_eq!(r.net, None);
558    }
559
560    #[test]
561    fn axis_enforcement_serializes_snake_case() {
562        assert_eq!(
563            serde_json::to_value(AxisEnforcement::Kernel).unwrap(),
564            serde_json::json!("kernel")
565        );
566        assert_eq!(
567            serde_json::to_value(AxisEnforcement::Interceptor).unwrap(),
568            serde_json::json!("interceptor")
569        );
570        assert_eq!(
571            serde_json::to_value(AxisEnforcement::Advisory).unwrap(),
572            serde_json::json!("advisory")
573        );
574    }
575
576    /// ADR 0012 D2 regression: the order is **ascending** `Advisory < Interceptor
577    /// < Kernel`, NOT the descending declaration order. A naive `#[derive(Ord)]`
578    /// would invert this — making `Kernel < Advisory` — and silently fail
579    /// `fence_strength` OPEN (picking the strongest axis as the floor).
580    #[test]
581    fn axis_enforcement_orders_ascending_advisory_to_kernel() {
582        use AxisEnforcement::{Advisory, Interceptor, Kernel};
583        assert!(Advisory < Interceptor);
584        assert!(Interceptor < Kernel);
585        assert!(
586            Advisory < Kernel,
587            "the fail-open footgun: Advisory must be < Kernel"
588        );
589        // The strongest claim is the MAX; the weakest (the GLB the fence takes) is
590        // the MIN.
591        assert_eq!(
592            [Interceptor, Kernel, Advisory].into_iter().max(),
593            Some(Kernel)
594        );
595        assert_eq!(
596            [Interceptor, Kernel, Advisory].into_iter().min(),
597            Some(Advisory)
598        );
599    }
600
601    /// A fence is only as strong as its weakest restricted axis: fully restricted
602    /// under Landlock is fs=Kernel, exec=Interceptor, net=Advisory ⇒ `Advisory`.
603    #[test]
604    fn fence_strength_is_the_weakest_restricted_axis() {
605        let r = enforcement_report(&fully_restricted(), SandboxKind::Landlock);
606        assert_eq!(fence_strength(&r), Some(AxisEnforcement::Advisory));
607    }
608
609    /// Only the fs axes restricted under Landlock ⇒ both `Kernel`, nothing weaker
610    /// present ⇒ the fence is `Kernel`.
611    #[test]
612    fn fence_strength_all_kernel_when_only_fs_restricted() {
613        let caveats = Caveats {
614            fs_read: Scope::only(["/r".to_string()]),
615            fs_write: Scope::only(["/w".to_string()]),
616            ..Caveats::top()
617        };
618        let r = enforcement_report(&caveats, SandboxKind::Landlock);
619        assert_eq!(fence_strength(&r), Some(AxisEnforcement::Kernel));
620    }
621
622    /// An empty report (top grant, nothing restricted) has no strength — there is
623    /// nothing to confine (ADR 0012 D1: a vacuous top ⇒ `None`, never a hole).
624    #[test]
625    fn fence_strength_empty_report_is_none() {
626        let r = enforcement_report(&Caveats::top(), SandboxKind::Landlock);
627        assert!(r.is_empty());
628        assert_eq!(fence_strength(&r), None);
629    }
630
631    /// One restricted axis with no kernel backend ⇒ the fence is that axis's
632    /// (interceptor) strength.
633    #[test]
634    fn fence_strength_single_axis_no_backend() {
635        let caveats = Caveats {
636            fs_write: Scope::only(["/w".to_string()]),
637            ..Caveats::top()
638        };
639        let r = enforcement_report(&caveats, SandboxKind::None);
640        assert_eq!(fence_strength(&r), Some(AxisEnforcement::Interceptor));
641    }
642
643    /// #114 / ADR 0013 D6 report guard: `exec → kernel` is **identity, not
644    /// behavior**. A granted *interpreter* (`sh`) still earns `exec → kernel`
645    /// under Seatbelt — only un-granted *programs* are excluded — which must not
646    /// be misread as constraining the interpreter's interior (that is governed
647    /// only by the fs/net axes, absent here because they are unrestricted).
648    #[test]
649    fn exec_kernel_is_identity_not_interpreter_behavior() {
650        let interp = Caveats {
651            exec: Scope::only(["sh".to_string()]),
652            ..Caveats::top()
653        };
654        let r = enforcement_report(&interp, SandboxKind::Seatbelt);
655        assert_eq!(
656            r.exec,
657            Some(AxisEnforcement::Kernel),
658            "a granted interpreter still earns exec→kernel (identity, not behavior)"
659        );
660        // exec→kernel does NOT imply the interior is constrained: fs/net are All
661        // (unrestricted) here, so they are absent from the report.
662        assert_eq!(r.fs_read, None);
663        assert_eq!(r.fs_write, None);
664        assert_eq!(r.net, None);
665    }
666}