agent-bridle-core 0.7.1

Capability-enforcement core for agent-bridle: the Tool trait, Gate (mint-token enforcement), Registry, and Caveats leash.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
//! Axis-granular confinement honesty (ADR 0004 D1).
//!
//! A single [`SandboxKind`] cannot honestly describe a run where one axis is
//! kernel-confined while others are only advisory. For example, the Landlock
//! backend governs `fs_write` (always) and `fs_read` (when restricted) but
//! leaves `exec` and `net` ungoverned for now (agent-bridle#31/#57). Reporting
//! `sandbox_kind: landlock` for such a run is true coarsely but misleading at
//! the grain a caller reasons about.
//!
//! [`enforcement_report`] classifies each **restricted** Caveat axis (`Only(_)`,
//! not `All`) as one of [`AxisEnforcement`]. It is a pure function of the
//! effective [`Caveats`] and the active [`SandboxKind`] — no IO. The coarse
//! `sandbox_kind` stays the **minimum** claim; this report refines it and is
//! never allowed to describe an `advisory` axis as confined.

use serde::{Deserialize, Serialize};

use crate::{Caveats, SandboxKind, Scope};

/// How a single restricted Caveat axis is actually enforced for a run
/// (ADR 0004 D1).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AxisEnforcement {
    /// An OS ruleset enforces this axis against the spawned program's
    /// **interior** (e.g. Landlock on `fs_write`). The strongest claim.
    ///
    /// **`exec → kernel` is about *identity*, not *behavior* (ADR 0013 D6 /
    /// agent-bridle#114).** It means "no **un-granted program** can run as a
    /// process" — via Seatbelt `process-exec*` (ADR 0014), or a Linux minimal
    /// rootfs that physically excludes un-granted binaries (ADR 0013). It does
    /// **NOT** mean a *granted* program — especially a granted **interpreter**
    /// (`sh`, `python`, `perl`) — is constrained in what it *does*: its interior
    /// logic is still bounded only by the `fs_read`/`fs_write`/`net` axes (read
    /// those for the data-side guarantee). Do not read `exec → kernel` as "this
    /// program will only do what I expect."
    Kernel,
    /// The in-process L2 leash gates this axis at the spawn/open chokepoint —
    /// it holds for the engine's own operations, **not** for a permitted
    /// external child's interior (a `find -exec` child's reads escape it).
    Interceptor,
    /// Validated at admission, then **ambient** — nothing backstops the spawned
    /// interior. Honest "we checked the request, we cannot confine the effect."
    Advisory,
}

impl AxisEnforcement {
    /// Ascending confinement strength: `Advisory (0) < Interceptor (1) <
    /// Kernel (2)`.
    ///
    /// The variants are *declared* strongest-first (`Kernel` first) so the type
    /// reads top-down — which means a naive `#[derive(PartialOrd, Ord)]` would
    /// order them DESCENDING (`Kernel < Advisory`) and silently invert every
    /// `min` / [`fence_strength`] into a **fail-open** (ADR 0012 D2). The order is
    /// therefore defined **explicitly** here, never derived; this hand-written
    /// `impl` also turns a future stray `#[derive(Ord)]` into a hard compile error
    /// (conflicting impls) rather than a silent security bug.
    fn rank(self) -> u8 {
        match self {
            AxisEnforcement::Advisory => 0,
            AxisEnforcement::Interceptor => 1,
            AxisEnforcement::Kernel => 2,
        }
    }
}

impl Ord for AxisEnforcement {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.rank().cmp(&other.rank())
    }
}

impl PartialOrd for AxisEnforcement {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

/// Per-axis confinement report for the four OS-confinement Caveat axes
/// (`fs_read`, `fs_write`, `exec`, `net`).
///
/// Only **restricted** (`Only(_)`) axes appear (`Some(_)`); an axis granted
/// `All` is unrestricted — there is nothing to confine — and is `None`. The
/// `max_calls` / `valid_for_generation` axes are gate-enforced budget/causality,
/// not OS-confinement axes, so they are not part of this report.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct EnforcementReport {
    /// Enforcement of the `fs_read` axis, when restricted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fs_read: Option<AxisEnforcement>,
    /// Enforcement of the `fs_write` axis, when restricted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fs_write: Option<AxisEnforcement>,
    /// Enforcement of the `exec` axis, when restricted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exec: Option<AxisEnforcement>,
    /// Enforcement of the `net` axis, when restricted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub net: Option<AxisEnforcement>,
}

impl EnforcementReport {
    /// `true` when no axis is restricted (every axis is `All`) — so the report
    /// carries no information and may be omitted from a result envelope.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.fs_read.is_none()
            && self.fs_write.is_none()
            && self.exec.is_none()
            && self.net.is_none()
    }
}

/// `true` if a scope actually restricts (`Only(_)`); `All` does not confine.
fn is_restricted<T: Ord + Clone>(scope: &Scope<T>) -> bool {
    matches!(scope, Scope::Only(_))
}

/// Classify each restricted axis of `effective` under the `active` sandbox
/// (ADR 0004 D1). Pure; no IO.
///
/// The mapping reflects what each layer *actually* enforces today:
///
/// - **`fs_read` / `fs_write`** — `kernel` when a real OS sandbox is active
///   (Landlock governs writes always and reads when restricted), otherwise
///   `interceptor` (the in-process leash gates the engine's own opens).
/// - **`exec`** — `kernel` under **Seatbelt** (macOS): the profile emits
///   `(deny process-exec*)` + an allow-list of the granted programs, and
///   `process-exec*` is kernel-checked on the confined process *and everything it
///   spawns*, so a permitted child's own `exec` is confined too. Apple-Silicon
///   hardware W^X + code signing close the `mmap(PROT_EXEC)` / loader-trampoline
///   bypass with no seccomp backstop (ADR 0014). Under **Landlock** and a
///   **Noop** host it stays `interceptor`: the spawn funnel (`before_exec` /
///   `check_exec`) gates the engine's own spawns, but no OS execute allow-list
///   confines a child's interior (the Landlock exec axis is held —
///   agent-bridle#31/#57).
/// - **`net`** — `advisory`: no kernel net rules are wired (agent-bridle#31) and
///   the shell engine does not gate a spawned program's egress. Reported
///   conservatively as advisory so the axis is never described as confined when
///   it is not (honesty rule); a tool that does gate net for its own requests
///   over-delivers relative to this floor, which is the safe direction.
#[must_use]
pub fn enforcement_report(effective: &Caveats, active: SandboxKind) -> EnforcementReport {
    // Filesystem axes: kernel when an OS sandbox actually governs them, else the
    // in-process interceptor. Exhaustive over `SandboxKind` so a new backend
    // must decide its mapping rather than silently defaulting.
    let fs = |scope: &Scope<String>| {
        is_restricted(scope).then_some(match active {
            // Real OS sandboxes that govern the filesystem axes in the kernel —
            // Landlock (Linux, FS allow-list via restrict_self), Seatbelt (macOS,
            // SBPL read/write rules), and the Linux minimal-rootfs jail (read-only/
            // read-write bind-mounts inside its mount namespace, ADR 0013 D3/D4).
            SandboxKind::Landlock
            | SandboxKind::Seatbelt
            | SandboxKind::MinimalRootfs
            | SandboxKind::MicroVm => AxisEnforcement::Kernel,
            // AppContainer (#51): per-path ACEs are now wired in the launcher via
            // Win32 ACL APIs. The container's default deny-all-user-directories
            // combined with explicit DACL grants makes both read and write Kernel
            // for user-space paths. System paths remain accessible via
            // ALL_APPLICATION_PACKAGES (a known limitation documented in ADR 0009),
            // but write access to system paths is still kernel-denied by NTFS.
            SandboxKind::AppContainer => AxisEnforcement::Kernel,
            SandboxKind::None => AxisEnforcement::Interceptor,
        })
    };
    EnforcementReport {
        fs_read: fs(&effective.fs_read),
        fs_write: fs(&effective.fs_write),
        exec: is_restricted(&effective.exec).then_some(match active {
            // `exec → kernel` is reserved for modes that close the axis by
            // *identity*: Seatbelt (macOS) via `process-exec*` — interior-covering,
            // no trampoline bypass on Apple Silicon (ADR 0014) — and the Linux
            // minimal-rootfs jail, where no un-granted binary physically *exists*
            // to run or to `ld.so`-trampoline into (ADR 0013 D5, ADR 0011 D7's
            // precondition made physically true). Landlock's exec axis is held
            // (agent-bridle#31/#57) and a Noop host has no OS allow-list, so both
            // stay interceptor. AppContainer: when exec is *fully denied* (empty
            // allow-list), `PROCESS_CREATION_CHILD_PROCESS_RESTRICTED` prevents
            // any child-process creation at the kernel level, closing the exec axis
            // by OS enforcement (#123). A non-empty allow-list cannot be kernel-
            // expressed (no WDAC policy), so it stays interceptor.
            SandboxKind::AppContainer if crate::sandbox::exec_fully_denied(effective) => {
                AxisEnforcement::Kernel
            }
            SandboxKind::Seatbelt | SandboxKind::MinimalRootfs | SandboxKind::MicroVm => {
                AxisEnforcement::Kernel
            }
            SandboxKind::Landlock | SandboxKind::AppContainer | SandboxKind::None => {
                AxisEnforcement::Interceptor
            }
        }),
        net: is_restricted(&effective.net).then_some(match active {
            // AppContainer (#133, ADR 0016): the capability model kernel-denies all
            // off-box egress when no internet capability SIDs are granted. Two net
            // scopes reach Kernel: deny-all (empty set) and loopback-only — both
            // route through the AppContainer capability block + loopback exemption.
            // A general remote-host allow-list is enforced userspace by the egress
            // proxy; net stays Advisory there (the proxy over-delivers above the
            // AppContainer floor, ADR 0006). MicroVM: no guest NIC → always Kernel.
            SandboxKind::AppContainer
                if crate::sandbox::net_fully_denied(effective)
                    || crate::sandbox::net_loopback_only(effective) =>
            {
                AxisEnforcement::Kernel
            }
            SandboxKind::AppContainer => AxisEnforcement::Advisory,
            SandboxKind::MicroVm => AxisEnforcement::Kernel,
            // Seatbelt kernel-denies *all* egress when the net scope is empty
            // (`(deny network*)`), and confines a **loopback-only** allowlist to
            // the loopback interface (`(allow network* (remote ip "localhost:*"))`)
            // so the process's own off-box socket egress is kernel-denied (ADR
            // 0015) — both honest `kernel`. A general remote host is inexpressible
            // in SBPL (only
            // `*`/`localhost` + ports), so it stays advisory. Landlock does not gate
            // net this increment.
            SandboxKind::Seatbelt
                if crate::sandbox::net_fully_denied(effective)
                    || crate::sandbox::net_loopback_only(effective) =>
            {
                AxisEnforcement::Kernel
            }
            // Landlock V4 (kernel ≥ 6.7) can deny-all TCP when the net scope is
            // empty (no NetPort rules → deny-by-default). Non-empty host allowlists
            // are not expressible (port-based, not hostname-based) and stay advisory.
            SandboxKind::Landlock
                if crate::sandbox::net_fully_denied(effective)
                    && crate::sandbox::landlock_net_capable() =>
            {
                AxisEnforcement::Kernel
            }
            // The minimal-rootfs jail does not namespace the network this tier, so
            // egress is unconfined — advisory, never overclaimed (ADR 0013 D5).
            SandboxKind::Landlock
            | SandboxKind::Seatbelt
            | SandboxKind::MinimalRootfs
            | SandboxKind::None => AxisEnforcement::Advisory,
        }),
    }
}

/// The fence's overall strength: the greatest-lower-bound (weakest) enforcement
/// across the **restricted** axes of `report` — a fence is only as strong as its
/// weakest confined axis (ADR 0012 D1). Returns `None` when no axis is restricted
/// (an empty report: a top grant confining nothing — a vacuous top with nothing
/// to enforce). **Pure**: recomputed from the report on every call, never stored,
/// so it cannot diverge from the lattice it summarizes (ADR 0004 D3 / ADR 0012's
/// rejection of a parallel strength enum). Consumers that need to know *which*
/// axis dropped the strength still read the per-axis [`EnforcementReport`].
#[must_use]
pub fn fence_strength(report: &EnforcementReport) -> Option<AxisEnforcement> {
    [report.fs_read, report.fs_write, report.exec, report.net]
        .into_iter()
        .flatten()
        .min()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{CountBound, Scope};

    /// All axes restricted, so every axis appears in the report.
    fn fully_restricted() -> Caveats {
        Caveats {
            fs_read: Scope::only(["/r".to_string()]),
            fs_write: Scope::only(["/w".to_string()]),
            exec: Scope::only(["echo".to_string()]),
            net: Scope::only(["example.com".to_string()]),
            max_calls: CountBound::Unlimited,
            valid_for_generation: Scope::All,
        }
    }

    #[test]
    fn landlock_marks_fs_kernel_exec_interceptor_net_advisory() {
        let r = enforcement_report(&fully_restricted(), SandboxKind::Landlock);
        assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
        assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
        assert_eq!(r.net, Some(AxisEnforcement::Advisory));
    }

    /// Landlock V4 (kernel ≥ 6.7) kernel-denies ALL TCP when `net` is the empty
    /// set (deny-all), because we declare `AccessNet` without adding any `NetPort`
    /// rules — deny-by-default. On pre-V4 kernels the `handle_access` is a BestEffort
    /// no-op so `net` stays advisory. The test dynamically queries the probe to stay
    /// correct in both environments (ADR 0013 net-axis Landlock extension, issue #35).
    #[test]
    fn landlock_marks_net_kernel_when_net_fully_denied_and_v4_capable() {
        let net_denied = crate::Caveats {
            net: crate::Scope::none(),
            ..crate::Caveats::top()
        };
        let r = enforcement_report(&net_denied, SandboxKind::Landlock);
        let expected = if crate::sandbox::landlock_net_capable() {
            Some(AxisEnforcement::Kernel)
        } else {
            Some(AxisEnforcement::Advisory)
        };
        assert_eq!(
            r.net, expected,
            "Landlock net enforcement depends on V4 kernel support"
        );
        // fs is not restricted, so those axes must be absent
        assert_eq!(r.fs_read, None);
        assert_eq!(r.fs_write, None);
        assert_eq!(r.exec, None);
    }

    /// Seatbelt (macOS) governs the fs axes in the kernel like Landlock, **and**
    /// the `exec` axis via `process-exec*` (ADR 0014) — so exec is `kernel`, not
    /// `interceptor`. `net` here is a general remote host allowlist, which SBPL
    /// cannot express, so it stays advisory (the empty-net and loopback-only kernel
    /// cases are covered by
    /// [`seatbelt_net_kernel_for_empty_and_loopback_advisory_for_remote_host`]).
    #[test]
    fn seatbelt_marks_fs_and_exec_kernel_net_advisory() {
        let r = enforcement_report(&fully_restricted(), SandboxKind::Seatbelt);
        assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
        assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
        assert_eq!(r.net, Some(AxisEnforcement::Advisory));
    }

    /// The macOS exec-axis honesty distinction from Landlock: a restricted `exec`
    /// is `kernel` under Seatbelt but only `interceptor` under Landlock (its exec
    /// axis is held) and a Noop host. ADR 0014.
    #[test]
    fn exec_is_kernel_under_seatbelt_interceptor_elsewhere() {
        let cav = Caveats {
            exec: Scope::only(["git".to_string()]),
            ..Caveats::top()
        };
        assert_eq!(
            enforcement_report(&cav, SandboxKind::Seatbelt).exec,
            Some(AxisEnforcement::Kernel)
        );
        assert_eq!(
            enforcement_report(&cav, SandboxKind::Landlock).exec,
            Some(AxisEnforcement::Interceptor)
        );
        assert_eq!(
            enforcement_report(&cav, SandboxKind::None).exec,
            Some(AxisEnforcement::Interceptor)
        );
    }

    /// ADR 0013 D5 (#110): a minimal-rootfs jail run governs the filesystem axes
    /// (bind-mounts) **and** the `exec` axis (identity by existence) in the kernel;
    /// `net` is not namespaced this tier, so it stays advisory.
    #[test]
    fn minimal_rootfs_marks_fs_and_exec_kernel_net_advisory() {
        let r = enforcement_report(&fully_restricted(), SandboxKind::MinimalRootfs);
        assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
        assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
        assert_eq!(r.net, Some(AxisEnforcement::Advisory));
    }

    /// ADR 0013 D5 (#110) acceptance: a restricted `exec` is `kernel` in the
    /// minimal-rootfs mode but only `interceptor` under a Landlock-only boundary
    /// (its exec axis is held — ADR 0011). `kernel` is reserved for the rootfs mode.
    #[test]
    fn exec_is_kernel_under_minimal_rootfs_interceptor_under_landlock() {
        let cav = Caveats {
            exec: Scope::only(["cat".to_string()]),
            ..Caveats::top()
        };
        assert_eq!(
            enforcement_report(&cav, SandboxKind::MinimalRootfs).exec,
            Some(AxisEnforcement::Kernel),
            "minimal-rootfs closes exec by identity ⇒ kernel"
        );
        assert_eq!(
            enforcement_report(&cav, SandboxKind::Landlock).exec,
            Some(AxisEnforcement::Interceptor),
            "a Landlock-only boundary run stays exec→interceptor (ADR 0011)"
        );
    }

    /// ADR 0013 D3 (#111): the Tier-2 micro-VM confines every OS axis in the
    /// kernel — fs + exec by the guest boundary (identity by existence), and net
    /// because the guest has no network device (egress impossible). The strongest
    /// tier: `fence_strength` is therefore `Kernel` even with all axes restricted.
    #[test]
    fn micro_vm_marks_all_axes_kernel() {
        let r = enforcement_report(&fully_restricted(), SandboxKind::MicroVm);
        assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
        assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
        assert_eq!(r.net, Some(AxisEnforcement::Kernel));
        assert_eq!(fence_strength(&r), Some(AxisEnforcement::Kernel));
    }

    /// AppContainer (#51): fs ACL narrowing is wired in the launcher. Both fs axes
    /// are now Kernel (per-path DACL grants + container default deny-user-dirs).
    /// exec stays Interceptor for non-deny-all (only deny-all → Kernel via #123).
    /// net stays Advisory for a general remote-host allowlist (no egress proxy yet,
    /// #133).
    #[test]
    fn appcontainer_marks_fs_kernel_exec_interceptor_net_advisory_for_allowlist() {
        // `fully_restricted()` uses net: Only(["example.com"]) — a non-empty
        // allowlist the launcher cannot kernel-express → Advisory.
        let r = enforcement_report(&fully_restricted(), SandboxKind::AppContainer);
        assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
        assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
        assert_eq!(r.net, Some(AxisEnforcement::Advisory));
    }

    /// net → Kernel only when the scope is empty (deny-all): the AppContainer
    /// capability model withholds all network SIDs → kernel-denied egress.
    #[test]
    fn appcontainer_marks_net_kernel_for_deny_all() {
        let net_deny_all = Caveats {
            net: Scope::none(),
            ..Caveats::top()
        };
        let r = enforcement_report(&net_deny_all, SandboxKind::AppContainer);
        assert_eq!(r.net, Some(AxisEnforcement::Kernel));
        // fs/exec are unrestricted (top) — not in the report.
        assert_eq!(r.fs_read, None);
        assert_eq!(r.fs_write, None);
        assert_eq!(r.exec, None);
    }

    /// net → Kernel for loopback-only under AppContainer (#133, ADR 0016): the
    /// container has no internet capability SIDs so off-box egress is kernel-denied;
    /// the loopback exemption allows 127.0.0.1→proxy. Mirrors Seatbelt's
    /// loopback-only Kernel claim (ADR 0015).
    #[test]
    fn appcontainer_marks_net_kernel_for_loopback_only() {
        for host in ["localhost", "127.0.0.1", "::1"] {
            let loopback_only = Caveats {
                net: Scope::only([host.to_string()]),
                ..Caveats::top()
            };
            let r = enforcement_report(&loopback_only, SandboxKind::AppContainer);
            assert_eq!(
                r.net,
                Some(AxisEnforcement::Kernel),
                "loopback host {host} must be Kernel under AppContainer"
            );
        }
    }

    /// exec → Kernel for AppContainer only when the scope is empty (deny-all):
    /// `PROCESS_CREATION_CHILD_PROCESS_RESTRICTED` blocks any child-process
    /// creation at the kernel level (#123, ADR 0013 D7).
    #[test]
    fn appcontainer_marks_exec_kernel_for_deny_all() {
        let exec_deny_all = Caveats {
            exec: Scope::none(),
            ..Caveats::top()
        };
        let r = enforcement_report(&exec_deny_all, SandboxKind::AppContainer);
        assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
        // fs/net are unrestricted (top) — not in the report.
        assert_eq!(r.fs_read, None);
        assert_eq!(r.fs_write, None);
        assert_eq!(r.net, None);
    }

    /// exec with a non-empty allow-list stays Interceptor: only the deny-all
    /// case can be kernel-enforced (no WDAC policy in the AppContainer launcher).
    #[test]
    fn appcontainer_exec_allowlist_stays_interceptor() {
        let exec_allowlist = Caveats {
            exec: Scope::only(["echo".to_string()]),
            ..Caveats::top()
        };
        let r = enforcement_report(&exec_allowlist, SandboxKind::AppContainer);
        assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
    }

    /// Seatbelt's net honesty is scope-shaped (ADR 0015): kernel for the two
    /// policies SBPL can express — an **empty** scope (`(deny network*)`) and a
    /// **loopback-only** allowlist (egress confined to the loopback interface) —
    /// and advisory for a general remote host, which SBPL cannot name.
    #[test]
    fn seatbelt_net_kernel_for_empty_and_loopback_advisory_for_remote_host() {
        let net_report = |net| {
            enforcement_report(
                &Caveats {
                    net,
                    ..Caveats::top()
                },
                SandboxKind::Seatbelt,
            )
            .net
        };

        // Empty net (all egress denied) → kernel.
        assert_eq!(net_report(Scope::none()), Some(AxisEnforcement::Kernel));
        // Loopback-only allowlist (off-box egress kernel-impossible) → kernel.
        for host in ["localhost", "127.0.0.1", "::1"] {
            assert_eq!(
                net_report(Scope::only([host.to_string()])),
                Some(AxisEnforcement::Kernel),
                "loopback host {host} must report kernel"
            );
        }
        // A general remote host → advisory (inexpressible in SBPL).
        assert_eq!(
            net_report(Scope::only(["example.com".to_string()])),
            Some(AxisEnforcement::Advisory)
        );
        // A single remote host taints an otherwise-loopback set → advisory.
        assert_eq!(
            net_report(Scope::only([
                "localhost".to_string(),
                "example.com".to_string()
            ])),
            Some(AxisEnforcement::Advisory)
        );
    }

    /// The honesty oracle for a Noop host: NO restricted axis is ever `kernel`.
    #[test]
    fn noop_host_never_reports_kernel() {
        let r = enforcement_report(&fully_restricted(), SandboxKind::None);
        assert_eq!(r.fs_read, Some(AxisEnforcement::Interceptor));
        assert_eq!(r.fs_write, Some(AxisEnforcement::Interceptor));
        assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
        assert_eq!(r.net, Some(AxisEnforcement::Advisory));
        for axis in [r.fs_read, r.fs_write, r.exec, r.net] {
            assert_ne!(
                axis,
                Some(AxisEnforcement::Kernel),
                "Noop must never claim kernel"
            );
        }
    }

    /// Unrestricted axes (`All`) are omitted — there is nothing to confine.
    #[test]
    fn unrestricted_axes_are_omitted() {
        let top = Caveats::top(); // every axis is All
        let r = enforcement_report(&top, SandboxKind::Landlock);
        assert!(
            r.is_empty(),
            "all-`All` caveats produce an empty report: {r:?}"
        );
        assert_eq!(r.fs_write, None);
    }

    /// A mix: only `fs_write` restricted under Landlock → that one axis kernel,
    /// the rest absent.
    #[test]
    fn only_restricted_axes_appear() {
        let caveats = Caveats {
            fs_write: Scope::only(["/w".to_string()]),
            ..Caveats::top()
        };
        let r = enforcement_report(&caveats, SandboxKind::Landlock);
        assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
        assert_eq!(r.fs_read, None);
        assert_eq!(r.exec, None);
        assert_eq!(r.net, None);
    }

    #[test]
    fn axis_enforcement_serializes_snake_case() {
        assert_eq!(
            serde_json::to_value(AxisEnforcement::Kernel).unwrap(),
            serde_json::json!("kernel")
        );
        assert_eq!(
            serde_json::to_value(AxisEnforcement::Interceptor).unwrap(),
            serde_json::json!("interceptor")
        );
        assert_eq!(
            serde_json::to_value(AxisEnforcement::Advisory).unwrap(),
            serde_json::json!("advisory")
        );
    }

    /// ADR 0012 D2 regression: the order is **ascending** `Advisory < Interceptor
    /// < Kernel`, NOT the descending declaration order. A naive `#[derive(Ord)]`
    /// would invert this — making `Kernel < Advisory` — and silently fail
    /// `fence_strength` OPEN (picking the strongest axis as the floor).
    #[test]
    fn axis_enforcement_orders_ascending_advisory_to_kernel() {
        use AxisEnforcement::{Advisory, Interceptor, Kernel};
        assert!(Advisory < Interceptor);
        assert!(Interceptor < Kernel);
        assert!(
            Advisory < Kernel,
            "the fail-open footgun: Advisory must be < Kernel"
        );
        // The strongest claim is the MAX; the weakest (the GLB the fence takes) is
        // the MIN.
        assert_eq!(
            [Interceptor, Kernel, Advisory].into_iter().max(),
            Some(Kernel)
        );
        assert_eq!(
            [Interceptor, Kernel, Advisory].into_iter().min(),
            Some(Advisory)
        );
    }

    /// A fence is only as strong as its weakest restricted axis: fully restricted
    /// under Landlock is fs=Kernel, exec=Interceptor, net=Advisory ⇒ `Advisory`.
    #[test]
    fn fence_strength_is_the_weakest_restricted_axis() {
        let r = enforcement_report(&fully_restricted(), SandboxKind::Landlock);
        assert_eq!(fence_strength(&r), Some(AxisEnforcement::Advisory));
    }

    /// Only the fs axes restricted under Landlock ⇒ both `Kernel`, nothing weaker
    /// present ⇒ the fence is `Kernel`.
    #[test]
    fn fence_strength_all_kernel_when_only_fs_restricted() {
        let caveats = Caveats {
            fs_read: Scope::only(["/r".to_string()]),
            fs_write: Scope::only(["/w".to_string()]),
            ..Caveats::top()
        };
        let r = enforcement_report(&caveats, SandboxKind::Landlock);
        assert_eq!(fence_strength(&r), Some(AxisEnforcement::Kernel));
    }

    /// An empty report (top grant, nothing restricted) has no strength — there is
    /// nothing to confine (ADR 0012 D1: a vacuous top ⇒ `None`, never a hole).
    #[test]
    fn fence_strength_empty_report_is_none() {
        let r = enforcement_report(&Caveats::top(), SandboxKind::Landlock);
        assert!(r.is_empty());
        assert_eq!(fence_strength(&r), None);
    }

    /// One restricted axis with no kernel backend ⇒ the fence is that axis's
    /// (interceptor) strength.
    #[test]
    fn fence_strength_single_axis_no_backend() {
        let caveats = Caveats {
            fs_write: Scope::only(["/w".to_string()]),
            ..Caveats::top()
        };
        let r = enforcement_report(&caveats, SandboxKind::None);
        assert_eq!(fence_strength(&r), Some(AxisEnforcement::Interceptor));
    }

    /// #114 / ADR 0013 D6 report guard: `exec → kernel` is **identity, not
    /// behavior**. A granted *interpreter* (`sh`) still earns `exec → kernel`
    /// under Seatbelt — only un-granted *programs* are excluded — which must not
    /// be misread as constraining the interpreter's interior (that is governed
    /// only by the fs/net axes, absent here because they are unrestricted).
    #[test]
    fn exec_kernel_is_identity_not_interpreter_behavior() {
        let interp = Caveats {
            exec: Scope::only(["sh".to_string()]),
            ..Caveats::top()
        };
        let r = enforcement_report(&interp, SandboxKind::Seatbelt);
        assert_eq!(
            r.exec,
            Some(AxisEnforcement::Kernel),
            "a granted interpreter still earns exec→kernel (identity, not behavior)"
        );
        // exec→kernel does NOT imply the interior is constrained: fs/net are All
        // (unrestricted) here, so they are absent from the report.
        assert_eq!(r.fs_read, None);
        assert_eq!(r.fs_write, None);
        assert_eq!(r.net, None);
    }
}