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}