agent-bridle-core 0.7.3

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
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
//! OS-level sandbox plumbing.
//!
//! The L3 boundary is the only layer that can confine a *permitted external
//! program's own syscalls* once it has spawned — what neither the static
//! decomposition (L1) nor the in-process interceptor (L2) can see. It is
//! OS-specific, so each operating system gets its own backend behind one
//! [`Sandbox`] trait, selected in code by [`best_available_sandbox`] (one
//! `cfg(target_os, feature)` arm per backend, with a runtime capability probe),
//! never overclaiming: a build either compiles a real backend for its host or
//! falls back to the advisory [`NoopSandbox`] reporting [`SandboxKind::None`]
//! (DESIGN §6, ADR 0001 L3, **ADR 0006** per-OS backends, **ADR 0009** the
//! cross-platform strategy).
//!
//! - **Linux** — [`LandlockSandbox`] (`linux-landlock`): a real Landlock ruleset
//!   confining the `fs_write` axis, and `fs_read` when restricted. `restrict_self`
//!   confines the calling thread (inherited across `fork`/`execve`).
//! - **macOS** — [`SeatbeltSandbox`] (`macos-seatbelt`): an SBPL profile derived
//!   from the effective [`Caveats`], applied by wrapping the spawned program in
//!   `sandbox-exec(1)` (no FFI — core forbids `unsafe`). Confines the `fs_write`
//!   and `fs_read` axes, and kernel-denies **all** network egress when `net` is
//!   empty (a confinement Landlock cannot provide); `exec` and non-empty `net`
//!   host allowlists are follow-ups (agent-bridle#31/#57).
//!
//! A backend confines either by restricting the calling thread in [`Sandbox::apply`]
//! (Landlock) **or** by wrapping the spawned command via
//! [`Sandbox::command_prefix`] (Seatbelt); a spawn site honors both, so the
//! mechanism is uniform at the call site.

use crate::{Caveats, SandboxPolicy, ToolResult};
use std::sync::Arc;

/// Which OS-level sandbox actually backs an authorization.
///
/// Recorded in every [`crate::ToolContext`] and surfaced in every result
/// envelope so callers can tell whether the leash is kernel-enforced or merely
/// advisory.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SandboxKind {
    /// A real Landlock ruleset is active (Linux). Kernel-enforced.
    Landlock,
    /// A real Seatbelt (`sandbox-exec` SBPL) profile is active (macOS).
    /// Kernel-enforced against the spawned program's interior.
    Seatbelt,
    /// A real AppContainer token is active (Windows). Kernel-enforced.
    AppContainer,
    /// A Linux **minimal-rootfs mount-namespace jail** is active (ADR 0013 D3/D4,
    /// agent-bridle#109/#108). The process runs in a `pivot_root` jail that
    /// physically contains only the granted program files, so `exec` is
    /// kernel-confined by **identity** — no un-granted binary *exists* to run or to
    /// `ld.so`-trampoline into (ADR 0011 D7's precondition is now physically true,
    /// not asserted) — and the filesystem axes are kernel-confined by the
    /// read-only/read-write bind-mounts. Network is not namespaced at this tier, so
    /// `net` stays advisory (never overclaimed). Reserved for the minimal-rootfs
    /// mode: a Landlock-only boundary run stays [`SandboxKind::Landlock`] (its exec
    /// axis is held — ADR 0011).
    MinimalRootfs,
    /// A Linux **Tier-2 micro-VM** is active (ADR 0013 D3, ADR 0009 D2,
    /// agent-bridle#111): the same minimal rootfs booted as a qemu guest under a
    /// separate kernel. Identity is closed as in [`SandboxKind::MinimalRootfs`]
    /// (only the granted program exists in the guest) and the filesystem is confined
    /// by the guest boundary; with no guest network device, egress is impossible —
    /// so `exec`, the fs axes, **and** `net` are all kernel-confined, and a
    /// guest-kernel compromise is still contained. The strongest tier.
    MicroVm,
    /// No OS-level sandbox — the leash is in-process/advisory only. This is the
    /// honest default on a host with no compiled-and-capable backend.
    #[default]
    None,
}

/// An OS-level confinement that can be applied from a set of [`Caveats`].
///
/// Implementations translate the lattice's `fs_read`/`fs_write`/`exec`/`net`
/// axes into kernel rules (Landlock, namespaces). For P0 only [`NoopSandbox`]
/// exists.
pub trait Sandbox: Send + Sync {
    /// The kind of confinement this sandbox provides.
    fn kind(&self) -> SandboxKind;

    /// Apply the confinement for the given effective caveats. Called by a tool
    /// *before* it does any privileged work, on the thread/process that will do
    /// it. A `Noop` implementation succeeds without restricting anything.
    ///
    /// This is the confinement mechanism for *thread-confining* backends
    /// (Landlock's `restrict_self`). *Wrapper-based* backends (macOS Seatbelt)
    /// confine via [`Sandbox::command_prefix`] instead and make this a no-op.
    fn apply(&self, effective: &Caveats) -> ToolResult<()>;

    /// The argv prefix that wraps a child so a *wrapper-based* L3 backend
    /// confines it (macOS `sandbox-exec`). The returned vector, prepended to a
    /// `(program, args…)`, is the argv that must actually be spawned.
    ///
    /// Backends that confine the spawning thread in [`Sandbox::apply`]
    /// (Landlock) or that do not confine ([`NoopSandbox`]) return an **empty**
    /// prefix. A spawn site applies *both* `apply()` and this prefix, so either
    /// mechanism is honored without the caller knowing which backend is active.
    ///
    /// **Fail-closed:** a backend that is selected but cannot build its wrapper
    /// (e.g. the wrapper binary is missing) returns `Err` — never an empty
    /// (silently unconfined) prefix. The default is the empty prefix.
    fn command_prefix(&self, effective: &Caveats) -> ToolResult<Vec<String>> {
        let _ = effective;
        Ok(Vec::new())
    }
}

/// The P0 sandbox: applies nothing and reports [`SandboxKind::None`].
///
/// This is the honest default until the Landlock ruleset (P3) lands. Tools that
/// consult `sandbox_kind()` can see that their exec/fs guarantees are advisory.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopSandbox;

impl Sandbox for NoopSandbox {
    fn kind(&self) -> SandboxKind {
        SandboxKind::None
    }

    fn apply(&self, _effective: &Caveats) -> ToolResult<()> {
        // Intentionally a no-op: the advisory default. Real kernel enforcement
        // lives in `LandlockSandbox` (Linux + `linux-landlock`).
        Ok(())
    }
}

/// `true` if either filesystem axis is actually restricted (`Only(_)`) — the
/// condition under which the fs-confining backends (Landlock, Seatbelt) have
/// something to enforce. When **no** fs axis is restricted, an fs-only backend
/// governs nothing, so honest reporting downgrades the [`SandboxKind`] to
/// [`SandboxKind::None`] rather than overclaiming a boundary that confines
/// nothing (I9 / ADR 0006 D3). Used by every spawn site that reports a kind.
#[must_use]
pub(crate) fn restricts_fs(caveats: &Caveats) -> bool {
    matches!(caveats.fs_write, crate::Scope::Only(_))
        || matches!(caveats.fs_read, crate::Scope::Only(_))
}

/// `true` if the `exec` axis is actually restricted (`Only(_)`) — the condition
/// under which an exec-confining backend has an allow-list to enforce. Today only
/// the macOS Seatbelt backend acts on this: `process-exec*` is a kernel-checked
/// operation that confines the spawned program's **interior** execs (covering the
/// loader trampoline that Landlock cannot — ADR 0014), so an `exec:Only` grant
/// engages Seatbelt even when no fs/net axis is restricted. Landlock's exec axis
/// stays held (agent-bridle#31/#57), so it does **not** engage on this alone.
#[must_use]
pub(crate) fn restricts_exec(caveats: &Caveats) -> bool {
    matches!(caveats.exec, crate::Scope::Only(_))
}

/// `true` if the `net` axis is restricted to the **empty** set — i.e. *all*
/// network egress is denied. This is the one network policy SBPL can soundly
/// express (`(deny network*)`); a non-empty host allowlist filters by socket,
/// not hostname, so it is **not** expressible and stays advisory (never silently
/// dropped). Only the macOS Seatbelt backend acts on this today.
#[must_use]
pub(crate) fn net_fully_denied(caveats: &Caveats) -> bool {
    matches!(&caveats.net, crate::Scope::Only(s) if s.is_empty())
}

/// `true` when the `exec` axis is a deny-all empty allow-list (`Scope::Only([])`).
///
/// An empty allow-list means *no program may be spawned* — any `exec` call is
/// refused. On Windows AppContainer this maps to the
/// `PROCESS_CREATION_CHILD_PROCESS_RESTRICTED` kernel mitigation, so the
/// sandboxed process cannot create child processes at the kernel level.
#[must_use]
pub(crate) fn exec_fully_denied(caveats: &Caveats) -> bool {
    matches!(&caveats.exec, crate::Scope::Only(s) if s.is_empty())
}

/// `true` if this kernel supports Landlock TCP network rules (ABI V4, kernel ≥ 6.7).
/// Always `false` on non-Linux or builds without `linux-landlock`.
#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
pub(crate) fn landlock_net_capable() -> bool {
    landlock_impl::landlock_net_is_supported()
}
#[cfg(not(all(target_os = "linux", feature = "linux-landlock")))]
pub(crate) fn landlock_net_capable() -> bool {
    false
}

/// The host tokens that name the machine's own **loopback interface**. SBPL's
/// `(remote ip "localhost:*")` filter matches exactly these destinations
/// (`127.0.0.1` and `::1`) — empirically the *only* remote a non-empty SBPL net
/// rule can name (an arbitrary IP is rejected: "host must be * or localhost").
pub(crate) const LOOPBACK_HOSTS: &[&str] = &["localhost", "127.0.0.1", "::1"];

/// `true` if the `net` axis is restricted to a **non-empty** allow-list whose
/// every host is a [loopback identifier](LOOPBACK_HOSTS) — the one non-deny-all
/// net policy SBPL *can* kernel-enforce (`(deny network*)` + `(allow network*
/// (remote ip "localhost:*"))`), confining egress to the loopback interface so the
/// process's **own off-box socket egress is kernel-denied** (ADR 0015; the
/// system-resolver DNS residual is shared with the empty-net case). A general remote
/// host cannot be named in SBPL (only `*`/`localhost` + ports), so a mixed or
/// non-loopback allow-list is **not** loopback-only and stays advisory — never
/// silently dropped. Mutually exclusive with [`net_fully_denied`] (empty set).
///
/// The kernel rule confines egress to the loopback *interface* — `localhost` =
/// `127.0.0.1` **and** `::1`, the finest grain SBPL can name. For a **spawned
/// child** (governed only by the kernel rule, not the in-process leash) that
/// interface *is* the boundary, so a grant naming a single loopback address
/// (e.g. `127.0.0.1`) still permits the other (`::1`) — a widening strictly
/// *within* loopback, never off-box. Admission (`ToolContext::check_net`,
/// exact-match) narrows to the granted host for the engine's *own* operations.
/// Unlike the fs `(subpath root)` case — where the kernel subtree and the granted
/// root denote the same set — the loopback interface can exceed a single-address
/// grant; see ADR 0015 D2.
#[must_use]
pub(crate) fn net_loopback_only(caveats: &Caveats) -> bool {
    matches!(&caveats.net, crate::Scope::Only(s)
        if !s.is_empty() && s.iter().all(|h| LOOPBACK_HOSTS.contains(&h.as_str())))
}

/// The granted host set of a **general remote-host** `net` allow-list — the case
/// SBPL cannot express and [`net_loopback_only`] therefore leaves advisory
/// (ADR 0015 D3). `Some(hosts)` iff `net` is `Only(set)`, non-empty, with **at
/// least one non-loopback host**; `None` for `All`, the empty set (deny-all), and
/// a loopback-only allow-list — those three keep their existing owners
/// ([`net_fully_denied`] / [`net_loopback_only`]).
///
/// This is the trigger for the macOS **egress-proxy** mechanism (#124, ADR 0016):
/// a caller confines a spawned child's egress to the loopback interface
/// ([`loopback_fenced_caveats`], reusing the ADR 0015 kernel fence) and runs a
/// loopback forward proxy that enforces this host set. Pure; no IO. The returned
/// set is the **full** grant (loopback members included — the proxy admits them
/// too), matching `ToolContext::check_net`'s exact-name membership.
#[must_use]
pub fn net_egress_proxy_hosts(caveats: &Caveats) -> Option<Vec<String>> {
    match &caveats.net {
        crate::Scope::Only(s)
            if !s.is_empty() && s.iter().any(|h| !LOOPBACK_HOSTS.contains(&h.as_str())) =>
        {
            Some(s.iter().cloned().collect())
        }
        _ => None,
    }
}

/// The confinement caveats for a spawned child paired with a loopback **egress
/// proxy** (#124, ADR 0016): identical to `caveats` except the `net` axis is
/// replaced by the loopback set, so its [`seatbelt_profile`] emits the ADR 0015
/// kernel fence — `(deny network*)` + `(allow network* (remote ip
/// "localhost:*"))` — while the `fs`/`exec` rules are preserved verbatim. The
/// child can then reach *nothing* off-box directly; its only path off the
/// loopback interface is the proxy it is pointed at via `*_PROXY` env. Pure; no
/// IO. Only meaningful for a grant where [`net_egress_proxy_hosts`] is `Some`.
#[must_use]
pub fn loopback_fenced_caveats(caveats: &Caveats) -> Caveats {
    Caveats {
        net: crate::Scope::Only(LOOPBACK_HOSTS.iter().map(|h| (*h).to_string()).collect()),
        ..caveats.clone()
    }
}

/// The [`SandboxKind`] honestly in force for `caveats` given the strongest
/// `available` backend: the backend's own kind when it will actually confine
/// *something*, else [`SandboxKind::None`]. The single honesty rule shared by the
/// subprocess primitive ([`crate::ConfinedCommand`]) and the shell engine, so
/// neither overclaims.
///
/// Capabilities differ per backend, so the engaging condition does too: Landlock
/// governs the filesystem axes; Seatbelt governs those, kernel-denies all egress
/// when `net` is empty ([`net_fully_denied`]) or confines it to the loopback
/// interface for a loopback-only allow-list ([`net_loopback_only`], ADR 0015),
/// **and** confines the `exec` axis via `process-exec*` ([`restricts_exec`]) — a
/// confinement Landlock cannot supply (ADR 0014). Landlock's exec axis stays held
/// (agent-bridle#31/#57), so a Landlock host does not engage on `exec` alone.
/// AppContainer (Windows, #51 / #123 / #133) engages when: `net` is fully denied
/// (deny-all capability model), `net` is loopback-only (egress-proxy fence, ADR 0016),
/// `exec` is fully denied (`PROCESS_CREATION_CHILD_PROCESS_RESTRICTED`, ADR 0013 D7),
/// or `fs` is restricted (per-path DACL grants, ADR 0009).
#[must_use]
pub fn effective_sandbox_kind(available: SandboxKind, caveats: &Caveats) -> SandboxKind {
    match available {
        SandboxKind::Landlock
            if restricts_fs(caveats) || (net_fully_denied(caveats) && landlock_net_capable()) =>
        {
            SandboxKind::Landlock
        }
        SandboxKind::Seatbelt
            if restricts_fs(caveats)
                || net_fully_denied(caveats)
                || net_loopback_only(caveats)
                || restricts_exec(caveats) =>
        {
            SandboxKind::Seatbelt
        }
        SandboxKind::AppContainer
            if net_fully_denied(caveats)
                || net_loopback_only(caveats)
                || exec_fully_denied(caveats)
                || restricts_fs(caveats) =>
        {
            SandboxKind::AppContainer
        }
        _ => SandboxKind::None,
    }
}

/// Return the strongest [`Sandbox`] available in this build on this host.
///
/// One `cfg(target_os, feature)` arm per backend, each with a runtime capability
/// probe (ADR 0006 D2): on Linux with `linux-landlock` and a capable kernel a
/// [`LandlockSandbox`]; on macOS with `macos-seatbelt` and `sandbox-exec`
/// present a [`SeatbeltSandbox`]. Otherwise the advisory [`NoopSandbox`] — so a
/// caller that wants confinement gets the real thing where it exists and an
/// honest [`SandboxKind::None`] where it does not, rather than silently
/// overclaiming. Enabling a backend's feature off its target OS compiles nothing
/// and selects nothing (the arm is `cfg`-gated away).
pub fn best_available_sandbox(policy: &Arc<SandboxPolicy>) -> Box<dyn Sandbox> {
    #[cfg(all(target_os = "windows", feature = "windows-appcontainer"))]
    {
        let _ = policy; // AppContainer configures per-process via the launcher.
        Box::new(appcontainer_impl::AppContainerSandbox::new())
    }

    #[cfg(not(all(target_os = "windows", feature = "windows-appcontainer")))]
    {
        #[cfg(all(target_os = "linux", feature = "linux-landlock"))]
        {
            if landlock_impl::landlock_is_supported() {
                return Box::new(landlock_impl::LandlockSandbox::with_policy(policy.clone()));
            }
        }
        #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
        {
            if seatbelt_impl::seatbelt_is_supported() {
                return Box::new(seatbelt_impl::SeatbeltSandbox::with_policy(policy.clone()));
            }
        }
        let _ = policy; // NoopSandbox is unconfigurable (advisory).
        Box::new(NoopSandbox)
    }
}

#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
pub use landlock_impl::{landlock_is_supported, landlock_net_is_supported, LandlockSandbox};

#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
pub use seatbelt_impl::{seatbelt_is_supported, SeatbeltSandbox};

#[cfg(all(target_os = "windows", feature = "windows-appcontainer"))]
pub(crate) mod appcontainer_impl {
    use std::sync::atomic::{AtomicU64, Ordering};

    use super::{
        exec_fully_denied, net_fully_denied, net_loopback_only, restricts_fs, Sandbox, SandboxKind,
    };
    use crate::{Caveats, Scope, ToolError, ToolResult};

    /// Monotonic counter for unique container names (PID + counter → no clock).
    static SPAWN_N: AtomicU64 = AtomicU64::new(0);

    /// A Windows AppContainer process sandbox.
    ///
    /// AppContainer is attached when creating a new process via
    /// `PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES`; it cannot be installed on
    /// the current thread and inherited across a later spawn the way Landlock
    /// can. The spawn path must therefore use the `agent-bridle-aclaunch`
    /// wrapper binary returned by [`command_prefix`] rather than the thread
    /// `apply` path (ADR 0006 / agent-bridle#51).
    ///
    /// Calling [`Sandbox::apply`] directly fails closed: it is never correct to
    /// call `apply` expecting AppContainer confinement on the current thread.
    #[derive(Debug, Default, Clone, Copy)]
    pub struct AppContainerSandbox;

    impl AppContainerSandbox {
        /// Construct the sandbox. (Stateless; confinement is per-process.)
        pub fn new() -> Self {
            Self
        }
    }

    /// Return the path of `agent-bridle-aclaunch.exe`, searching first next to
    /// the current executable and then via `PATH`.
    fn find_launcher() -> Option<String> {
        const LAUNCHER: &str = "agent-bridle-aclaunch.exe";

        // Same directory as the current exe — the normal install layout.
        if let Ok(mut p) = std::env::current_exe() {
            p.set_file_name(LAUNCHER);
            if p.exists() {
                return Some(p.to_string_lossy().into_owned());
            }
        }
        // Fall back to PATH.
        std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
            .map(|dir| dir.join(LAUNCHER))
            .find(|p| p.exists())
            .map(|p| p.to_string_lossy().into_owned())
    }

    impl Sandbox for AppContainerSandbox {
        fn kind(&self) -> SandboxKind {
            SandboxKind::AppContainer
        }

        /// No-op: AppContainer confinement is applied at process creation via the
        /// `command_prefix` launcher wrapper (`agent-bridle-aclaunch`), not via
        /// this thread. `apply` is reached only when `command_prefix` returned an
        /// empty prefix (nothing to confine), so a no-op is correct here.
        fn apply(&self, _effective: &Caveats) -> ToolResult<()> {
            Ok(())
        }

        /// Build the `["agent-bridle-aclaunch.exe", ...]` prefix that wraps the
        /// child inside a fresh AppContainer profile.
        ///
        /// Returns an empty prefix when nothing on a governed axis is restricted
        /// (so the spawn runs unwrapped — the backend engages only when it
        /// actually confines something). Fails closed if the launcher binary is
        /// not found.
        fn command_prefix(&self, effective: &Caveats) -> ToolResult<Vec<String>> {
            // The launcher engages when:
            //  - net is fully denied (deny-by-default network policy)
            //  - net is loopback-only (egress proxy path, #133)
            //  - exec is fully denied (kernel child-process-creation block)
            //  - fs is restricted (ACL grants let the container reach its workspace)
            if !net_fully_denied(effective)
                && !net_loopback_only(effective)
                && !exec_fully_denied(effective)
                && !restricts_fs(effective)
            {
                return Ok(Vec::new());
            }

            // Fail-closed: without the launcher we cannot enforce.
            let launcher = find_launcher().ok_or_else(|| {
                ToolError::denied(
                    "windows-appcontainer: agent-bridle-aclaunch.exe not found next to the \
                     current executable or on PATH; cannot confine",
                )
            })?;

            // Unique container name: PID + monotonic counter (no wall clock).
            let n = SPAWN_N.fetch_add(1, Ordering::Relaxed);
            let container_name = format!("ab{}{}", std::process::id(), n);

            let mut prefix = vec![launcher, "--name".to_string(), container_name];

            // Grant network capabilities only when net is fully unrestricted
            // (Scope::All). Any non-All net scope denies egress by default via
            // the AppContainer's deny-by-default network policy.
            if matches!(effective.net, Scope::All) {
                prefix.push("--net-allow".to_string());
            }

            // Loopback-only fence (#133, ADR 0016): AppContainers block loopback
            // by default. For the egress-proxy pattern the child must reach the
            // parent's loopback proxy, so grant the loopback exemption via the
            // NetworkIsolationSetAppContainerConfig API.
            if net_loopback_only(effective) {
                prefix.push("--loopback-exemption".to_string());
            }

            // Kernel-block child process creation when exec is fully denied.
            // The `--no-child-process` flag sets PROCESS_CREATION_CHILD_PROCESS_RESTRICTED
            // on the spawned process — the kernel refuses any CreateProcess call
            // it makes, closing the exec axis by OS enforcement (#123).
            if exec_fully_denied(effective) {
                prefix.push("--no-child-process".to_string());
            }

            // FS ACL narrowing (#51): grant the AppContainer SID access to the
            // allowed paths so the container can read/write its workspace.
            // AppContainers are denied user directories by default; without this
            // grant the child cannot access its working directory.
            if let Scope::Only(paths) = &effective.fs_write {
                for p in paths {
                    prefix.push("--fs-write".to_string());
                    prefix.push(p.clone());
                }
            }
            // Read-only paths that are not already covered by fs_write.
            let write_set: std::collections::HashSet<&str> =
                if let Scope::Only(paths) = &effective.fs_write {
                    paths.iter().map(String::as_str).collect()
                } else {
                    std::collections::HashSet::new()
                };
            if let Scope::Only(paths) = &effective.fs_read {
                for p in paths {
                    if !write_set.contains(p.as_str()) {
                        prefix.push("--fs-read".to_string());
                        prefix.push(p.clone());
                    }
                }
            }

            Ok(prefix)
        }
    }
}

#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
pub(crate) mod landlock_impl {
    use super::{Sandbox, SandboxKind};
    use crate::{Caveats, SandboxPolicy, Scope, ToolError, ToolResult};
    use landlock::{
        path_beneath_rules, Access, AccessFs, AccessNet, CompatLevel, Compatible, Ruleset,
        RulesetAttr, RulesetCreatedAttr, RulesetStatus, ABI,
    };
    use std::sync::Arc;

    /// Map a configured ABI floor to the landlock `ABI` enum. `apply` runs
    /// `BestEffort`, so a floor above the running kernel still degrades
    /// gracefully; unknown/too-high values clamp to the highest ABI this crate
    /// (landlock 0.4.5) models — V7 — so raising a floor to reach a newer axis
    /// (e.g. `IoctlDev` at V5) is honored, not silently dropped to V4. The
    /// default floors (fs 3 / net 4) reproduce the previous `ABI::V3` / `ABI::V4`
    /// constants.
    ///
    /// The *lower* bound is deliberately NOT enforced here: it is axis-specific
    /// and applied at the call site via [`fs_abi_floor`] / [`net_abi_floor`],
    /// because fs and net have different safe minimums below which the honesty
    /// report would overclaim.
    fn abi_from_u32(v: u32) -> ABI {
        match v {
            0 | 1 => ABI::V1,
            2 => ABI::V2,
            3 => ABI::V3,
            4 => ABI::V4,
            5 => ABI::V5,
            6 => ABI::V6,
            _ => ABI::V7,
        }
    }

    /// The fs-axis ABI floor actually installed — never below V3 (the default).
    ///
    /// Security-critical clamp: a configured `landlock_abi_floor` below 3 would
    /// drop `Refer` (V2) / `Truncate` (V3) from the governed write set, letting a
    /// confined child `truncate`/`rename` files OUTSIDE its `fs_write` scope while
    /// [`crate::enforcement_report`] still reports `fs_write = Kernel` — a silent
    /// weakening *and* an overclaim. Lowering a floor has no legitimate use
    /// (`BestEffort` already degrades on genuinely older kernels), so we clamp up
    /// to the claimed baseline rather than honor a weakening. Raising above the
    /// default stays allowed (explicit opt-in hardening).
    fn fs_abi_floor(policy: &SandboxPolicy) -> ABI {
        abi_from_u32(policy.landlock_abi_floor.max(3))
    }

    /// The net-axis ABI floor actually installed — never below V4 (the default).
    ///
    /// TCP net rights first exist at V4, so a configured `landlock_net_abi_floor`
    /// below 4 makes `AccessNet::from_all` EMPTY; under `BestEffort`,
    /// `handle_access` of an empty set governs nothing, silently dropping a
    /// requested deny-all-egress even on a capable (≥ 6.7) kernel while the report
    /// claims `net = Kernel`. Clamp up to V4 for the same reason as
    /// [`fs_abi_floor`].
    fn net_abi_floor(policy: &SandboxPolicy) -> ABI {
        abi_from_u32(policy.landlock_net_abi_floor.max(4))
    }

    /// `true` if this kernel can enforce a Landlock ruleset.
    ///
    /// Non-destructive: it creates (but never `restrict_self`s) a throwaway
    /// ruleset under `HardRequirement`, so an unsupported kernel surfaces as
    /// `Err` rather than being silently swallowed by best-effort.
    pub fn landlock_is_supported() -> bool {
        Ruleset::default()
            .set_compatibility(CompatLevel::HardRequirement)
            .handle_access(AccessFs::from_all(ABI::V1))
            .and_then(|r| r.create())
            .is_ok()
    }

    /// `true` if this kernel supports Landlock TCP network rules (ABI V4,
    /// kernel ≥ 6.7). Probed non-destructively — creates but never
    /// `restrict_self`s a throwaway ruleset. This is the *capability* threshold
    /// (TCP rules first appear at V4), distinct from the configurable request
    /// floor in [`abi_from_u32`].
    pub fn landlock_net_is_supported() -> bool {
        Ruleset::default()
            .set_compatibility(CompatLevel::HardRequirement)
            .handle_access(AccessNet::from_all(ABI::V4))
            .and_then(|r| r.create())
            .is_ok()
    }

    // The Landlock read/exec allow-lists now live in `SandboxPolicy`
    // (config.rs) and are read from `self.policy` in `apply`. Their security
    // rationale is unchanged (ADR 0011 D3/D7):
    //
    // - `base_read_paths`: the loader/library trees + system DATA a permitted,
    //   dynamically-linked program needs to start — but NOT the executable dirs
    //   (`/usr/bin`, `/bin`, `/sbin`). Keeping bin dirs out of the read set
    //   shrinks the loader-trampoline corpus: `/usr/bin/curl` is unreadable and
    //   so cannot be `mmap`-exec'd via `ld.so`. This shrinks, but does not close,
    //   the trampoline (`/usr/lib` still hides interpreters), so `exec` stays
    //   `interceptor`, never `kernel`. `/etc` is never granted wholesale.
    // - `bin_read_paths`: executable dirs, read-allowed ONLY when `exec` is
    //   ambient (`All`); when `exec` is confined the granted binaries are added
    //   by resolved path instead, narrowing the corpus to exactly them.
    // - `loader_paths`: the dynamic linker(s) only — specific FILES, never
    //   directories (a `path_beneath` dir grant would expose every ELF beneath
    //   `/usr/lib` via the merged-usr symlink, defeating the exec axis).
    //
    // The `PathList` shrink-guard (config) means an operator can *widen* these
    // (disclosed) but can only *remove* an entry with an explicit `replace=true`.

    /// A real, kernel-enforced Landlock sandbox (Linux).
    ///
    /// **The `fs_write`, `fs_read`, and `exec` axes.** Writes are always governed
    /// (from `fs_write`); reads are governed only when `fs_read` is *restricted*
    /// (`Only(_)`), in which case the granted read roots plus the configured
    /// `base_read_paths` are read-allowed and everything else is denied — so a
    /// permitted external program cannot read user data outside `fs_read` (closing
    /// `grep -f /etc/shadow`-style reads) yet can still load its libraries.
    ///
    /// `Execute` is governed only when `exec` is restricted: the *resolved*
    /// granted program files plus the configured `loader_paths` (the dynamic linker only — never
    /// library directories, which `path_beneath` would make recursively executable
    /// and expose `/usr/lib`'s interpreters) are execute-allowed and all else
    /// denied. This kernel-denies a **direct** `execve` of a different, un-granted
    /// tool (`find -exec curl`, a written/symlinked payload, a shebang to an
    /// un-granted interpreter) — the ADR 0011 boundary increment.
    ///
    /// It does **not** close the loader/interpreter *trampoline*: with reads
    /// allow-listed, `ld.so` can `mmap`-exec any readable ELF, and a granted
    /// interpreter runs arbitrary in-process code — neither is an `execve` the
    /// `Execute` rule sees (ADR 0011 D2; Landlock has no `mmap` hook). So this is
    /// the filesystem **boundary** + direct-execve denial, **not** program
    /// identity — the per-axis report therefore keeps `exec → interceptor`, never
    /// `kernel` (ADR 0011 D7); a strong principal still fails closed on a
    /// restricted `exec` (ADR 0012 D4, already wired). The trampoline-tight close
    /// (narrowed read base + W^X + seccomp `execve`/namespace deny, or a
    /// micro-VM rootfs) is the Tier-2 follow-up (#57 / ADR 0009). When an axis is
    /// `All` it stays ambient. `net` remains a follow-up (#35).
    ///
    /// `restrict_self` is per-thread and irreversible, and is inherited across
    /// `fork`/`execve`. Callers must therefore call [`Sandbox::apply`] on the
    /// very thread that will spawn the confined work, immediately before the
    /// spawn.
    #[derive(Debug, Default, Clone)]
    pub struct LandlockSandbox {
        /// The read/exec allow-lists + ABI floors this backend enforces (I5-B).
        policy: Arc<SandboxPolicy>,
    }

    impl LandlockSandbox {
        /// Construct with the built-in defaults (today's allow-lists).
        pub fn new() -> Self {
            Self::default()
        }

        /// Construct configured with an operator-supplied [`SandboxPolicy`].
        pub fn with_policy(policy: Arc<SandboxPolicy>) -> Self {
            Self { policy }
        }
    }

    impl Sandbox for LandlockSandbox {
        fn kind(&self) -> SandboxKind {
            SandboxKind::Landlock
        }

        fn apply(&self, effective: &Caveats) -> ToolResult<()> {
            let write = AccessFs::from_write(fs_abi_floor(&self.policy));
            // Pure read rights — `from_read` also bundles `Execute`, which we
            // govern separately (only when `exec` is restricted), never via the
            // read axis.
            let read = AccessFs::ReadFile | AccessFs::ReadDir;

            // Govern writes always; govern reads / execute only when their axis is
            // actually restricted (`Only`). `All` means no confinement was asked
            // for, so that axis stays ambient and needs no base allow-list.
            let confine_read = matches!(effective.fs_read, Scope::Only(_));
            let confine_exec = matches!(effective.exec, Scope::Only(_));
            // `net: Scope::Only([])` (empty) = deny ALL TCP bind + connect.
            // Non-empty host allow-lists are not expressible in Landlock (port-
            // based, not hostname-based) and stay advisory — only the empty-set
            // case maps cleanly to a deny-all TCP rule.
            let confine_net = super::net_fully_denied(effective);
            let mut handled = write;
            if confine_read {
                handled |= read;
            }
            if confine_exec {
                handled |= AccessFs::Execute;
            }

            let write_roots = scope_roots(&effective.fs_write);
            // Build the ruleset: fs axes first (V3 floor), then optionally the
            // net axis (V4+). BestEffort means handle_access silently skips
            // access types the kernel doesn't know — so on pre-6.7 kernels the
            // TCP handle is a no-op and only fs rules apply.
            let ruleset = Ruleset::default()
                .set_compatibility(CompatLevel::BestEffort)
                .handle_access(handled)
                .map_err(landlock_denied)?;
            // When net is fully denied: declare AccessNet without adding any
            // NetPort rules → deny-by-default for all TCP bind + connect.
            let ruleset = if confine_net {
                ruleset
                    .handle_access(AccessNet::from_all(net_abi_floor(&self.policy)))
                    .map_err(landlock_denied)?
            } else {
                ruleset
            };
            let ruleset = ruleset
                .create()
                .map_err(landlock_denied)?
                .add_rules(path_beneath_rules(&write_roots, write))
                .map_err(landlock_denied)?;

            let ruleset = if confine_read {
                // Granted read roots + the loader/library/data base list, so a
                // permitted binary loads while out-of-scope reads stay denied.
                let mut read_roots = scope_roots(&effective.fs_read);
                read_roots.extend(self.policy.base_read_paths.resolve());
                // The program's own binary must be readable to load. When `exec`
                // is confined, read-allow ONLY the resolved granted programs — so
                // the bin dirs stay OUT of the trampoline corpus (`/usr/bin/curl`
                // unreadable ⇒ not `ld.so`-trampolinable; ADR 0011 D3). When `exec`
                // is ambient the program is unknown, so the bin dirs are
                // read-allowed wholesale.
                if confine_exec {
                    read_roots.extend(resolve_exec_paths(&effective.exec));
                } else {
                    read_roots.extend(self.policy.bin_read_paths.resolve());
                }
                read_roots.retain(|p| std::path::Path::new(p).exists());
                ruleset
                    .add_rules(path_beneath_rules(&read_roots, read))
                    .map_err(landlock_denied)?
            } else {
                ruleset
            };

            let ruleset = if confine_exec {
                // Execute-allow ONLY the resolved granted program files plus the
                // dynamic linker(s) — never library directories (recursive +
                // expose `/usr/lib`'s interpreters). A permitted binary still runs
                // (its own execve + the loader + .so reads), but cannot DIRECTLY
                // execve a different, un-granted program.
                let mut exec_roots = resolve_exec_paths(&effective.exec);
                exec_roots.extend(self.policy.loader_paths.resolve());
                exec_roots.retain(|p| std::path::Path::new(p).exists());
                ruleset
                    .add_rules(path_beneath_rules(&exec_roots, AccessFs::Execute))
                    .map_err(landlock_denied)?
            } else {
                ruleset
            };

            let status = ruleset.restrict_self().map_err(landlock_denied)?;

            // Fail closed: if the kernel did not actually enforce the ruleset,
            // do not let the caller believe it is confined.
            if status.ruleset == RulesetStatus::NotEnforced {
                return Err(ToolError::denied(
                    "landlock ruleset was not enforced by this kernel",
                ));
            }
            Ok(())
        }
    }

    /// Resolve the granted `exec` scope to absolute, existing program **files**
    /// for the `Execute` allow-list: a path-bearing entry is taken as-is (if it
    /// exists); a bare name is resolved against the exec search dirs. Canonicalized
    /// so the rule anchors the real inode. `All` => empty (exec stays ambient).
    fn resolve_exec_paths(scope: &Scope<String>) -> Vec<String> {
        let set = match scope {
            Scope::All => return Vec::new(),
            Scope::Only(set) => set,
        };
        let dirs = exec_search_dirs();
        let mut out = Vec::new();
        for entry in set {
            let candidate = if entry.contains('/') {
                let p = std::path::PathBuf::from(entry);
                p.exists().then_some(p)
            } else {
                dirs.iter()
                    .map(|d| std::path::Path::new(d).join(entry))
                    .find(|c| c.is_file())
            };
            if let Some(p) = candidate {
                if let Ok(canon) = p.canonicalize() {
                    out.push(canon.to_string_lossy().into_owned());
                }
            }
        }
        out
    }

    /// The directories a bare program name is resolved against: `$PATH` if set,
    /// else a conventional fallback. Used only to anchor the `Execute` allow-list
    /// (the spawn itself still resolves the program normally).
    fn exec_search_dirs() -> Vec<String> {
        if let Ok(path) = std::env::var("PATH") {
            let dirs: Vec<String> = path
                .split(':')
                .filter(|s| !s.is_empty())
                .map(String::from)
                .collect();
            if !dirs.is_empty() {
                return dirs;
            }
        }
        [
            "/usr/local/bin",
            "/usr/bin",
            "/bin",
            "/usr/local/sbin",
            "/usr/sbin",
            "/sbin",
        ]
        .iter()
        .map(|s| (*s).to_string())
        .collect()
    }

    /// The existing path roots a [`Scope`] grants: `All` => the whole tree
    /// (`/`); `Only(set)` => exactly those paths that exist (a non-existent path
    /// cannot anchor a Landlock rule and is skipped — safe, since its parent is
    /// ungranted, so access beneath it stays denied).
    fn scope_roots(scope: &Scope<String>) -> Vec<String> {
        match scope {
            Scope::All => vec!["/".to_string()],
            Scope::Only(set) => set
                .iter()
                .filter(|p| std::path::Path::new(p).exists())
                .cloned()
                .collect(),
        }
    }

    fn landlock_denied(e: impl std::fmt::Display) -> ToolError {
        ToolError::denied(format!("landlock: {e}"))
    }
}

#[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
mod seatbelt_impl {
    use super::{Sandbox, SandboxKind};
    use crate::{Caveats, SandboxPolicy, Scope, ToolError, ToolResult};
    use std::path::Path;
    use std::sync::Arc;

    /// The macOS sandbox wrapper. We invoke it by **absolute path** (never via
    /// `PATH`) so the boundary cannot be shadowed by a `sandbox-exec` planted
    /// earlier in a caller's `PATH`. `sandbox-exec(1)` is deprecated-but-present
    /// on stock macOS; using it keeps the boundary FFI-free, which core requires
    /// (`unsafe_code = "forbid"`).
    const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";

    // Read-side base allow-list (subpaths): the system/loader paths a
    // dynamically-linked Mach-O binary must read to *start and run* — the dynamic
    // linker and dyld shared cache (under `/System`, incl. the Cryptex volume),
    // system dylibs/frameworks, the binaries themselves, the name-service and
    // locale config (`/private/etc`, the real target of `/etc`), the dyld closure
    // db, and the `/dev` essentials. Added whenever `fs_read` is confined,
    // alongside the literal root entry, so a *permitted* program still loads while
    // user data outside scope stays unreadable. Non-existent entries are dropped
    // during canonicalization, so extra entries are harmless across macOS layouts
    // (verified on Apple Silicon: `grep`/`cat`/`cp` load read-confined). The list
    // now lives in `SandboxPolicy::base_read_paths` (config.rs), whose default is
    // macOS-specific on this platform (I5-B, #144).

    /// `true` if this host can enforce a Seatbelt profile — i.e. the
    /// `sandbox-exec` wrapper is present. The wrapper itself is the boundary, so
    /// its presence is the capability (the analog of `landlock_is_supported`).
    #[must_use]
    pub fn seatbelt_is_supported() -> bool {
        Path::new(SANDBOX_EXEC).exists()
    }

    /// A real, kernel-enforced Seatbelt sandbox (macOS).
    ///
    /// **The `fs_write` and `fs_read` axes** — the same *axes* the Linux Landlock
    /// backend governs (not necessarily the same path-level strictness; see
    /// below). Confinement is applied by wrapping the spawned program in
    /// `sandbox-exec -p <profile>`, where the SBPL profile is generated from the
    /// effective [`Caveats`] (see [`seatbelt_profile`]): writes are denied
    /// outside the granted `fs_write` roots, and — when `fs_read` is restricted —
    /// reads are denied outside the granted roots plus the loader/system base
    /// list. It also kernel-denies **all** network egress when `net` is empty
    /// (`(deny network*)`) — a confinement Landlock cannot provide, closing the
    /// `find -exec curl` egress path at L3. A non-empty `net` host allowlist is
    /// not expressible in SBPL (it filters by socket, not hostname) and stays
    /// advisory.
    ///
    /// **The `exec` axis** — when restricted, the profile emits
    /// `(deny process-exec*)` and re-allows exactly the granted programs (resolved
    /// to absolute paths). Because `process-exec*` is a kernel-checked operation
    /// applied to the confined process *and everything it spawns*, this confines
    /// the program's **interior** execs — the L3 gap a path allow-list alone
    /// cannot reach. Unlike Landlock, no seccomp backstop is needed: the loader
    /// trampoline (`dyld TARGET`) is itself a governed `process-exec`, and the
    /// `mmap(PROT_EXEC)` read-as-code path is closed by Apple-Silicon hardware
    /// W^X + code signing — so "the readable set equals the runnable set" (the
    /// fact that forces the Linux seccomp filter) does **not** hold here. The axis
    /// is therefore honestly reported `Kernel` (ADR 0014; agent-bridle#31/#57).
    ///
    /// Read confinement here is **content-level**: file *metadata* (stat,
    /// existence, directory traversal) stays ambient so binaries can load through
    /// symlink ancestors, and the system read base (the configured `base_read_paths`, incl.
    /// `/private/etc`) is broadly readable — looser than Landlock's file-level
    /// `/etc` allow-list, but the protected resource (out-of-scope file
    /// *contents*, the exfil threat) is denied identically. macOS keeps user
    /// secrets in the Keychain and `$HOME`, not `/etc`.
    ///
    /// Unlike Landlock's per-thread `restrict_self`, Seatbelt confinement is
    /// carried by the wrapper process and inherited by the child, so
    /// [`Sandbox::apply`] is a no-op and the boundary lives entirely in
    /// [`Sandbox::command_prefix`].
    #[derive(Debug, Default, Clone)]
    pub struct SeatbeltSandbox {
        /// The read base this backend's SBPL profile allows (I5-B).
        policy: Arc<SandboxPolicy>,
    }

    impl SeatbeltSandbox {
        /// Construct with the built-in defaults (today's read base).
        #[must_use]
        pub fn new() -> Self {
            Self::default()
        }

        /// Construct configured with an operator-supplied [`SandboxPolicy`].
        #[must_use]
        pub fn with_policy(policy: Arc<SandboxPolicy>) -> Self {
            Self { policy }
        }
    }

    impl Sandbox for SeatbeltSandbox {
        fn kind(&self) -> SandboxKind {
            SandboxKind::Seatbelt
        }

        fn apply(&self, _effective: &Caveats) -> ToolResult<()> {
            // Deliberate no-op: Seatbelt confines via the `sandbox-exec` wrapper
            // (see `command_prefix`), not by restricting the calling thread. The
            // boundary is the wrapped spawn.
            Ok(())
        }

        fn command_prefix(&self, effective: &Caveats) -> ToolResult<Vec<String>> {
            // Nothing on a governed axis (fs, all-egress-denied or loopback-only
            // net, or a restricted exec allow-list) => nothing to confine; run
            // unwrapped (coarse honesty falls to `None` upstream, and the per-axis
            // report omits unrestricted axes).
            if !super::restricts_fs(effective)
                && !super::net_fully_denied(effective)
                && !super::net_loopback_only(effective)
                && !super::restricts_exec(effective)
            {
                return Ok(Vec::new());
            }
            // Fail-closed: if the wrapper is gone we cannot enforce, so refuse
            // rather than hand back an empty (silently unconfined) prefix.
            if !seatbelt_is_supported() {
                return Err(ToolError::denied(
                    "macOS seatbelt: /usr/bin/sandbox-exec is unavailable; cannot confine",
                ));
            }
            Ok(vec![
                SANDBOX_EXEC.to_string(),
                "-p".to_string(),
                seatbelt_profile_with(effective, &self.policy.base_read_paths.resolve()),
            ])
        }
    }

    /// Generate the SBPL profile for `effective`. **Pure** (modulo path
    /// canonicalization against the real filesystem); no spawning.
    ///
    /// Model (the macOS analog of Landlock handling only the write/read access
    /// rights and leaving the rest ambient): start from `(allow default)` so
    /// unhandled operations — `exec`, `network`, mach lookups a normal process
    /// needs — stay ambient, then `(deny file-write*)` / `(deny file-read*)` for
    /// a restricted axis and re-allow exactly the granted roots (canonicalized,
    /// so `/tmp` → `/private/tmp` matches). An empty `fs_write` scope emits the
    /// deny with no re-allow — every write denied. SBPL evaluates last-match-wins,
    /// so the trailing allow-roots override the deny.
    // Convenience over the built-in read base — **tests only** (production uses
    // `command_prefix` → `seatbelt_profile_with` with the configured
    // `SandboxPolicy::base_read_paths`, I5-B #144).
    #[cfg(test)]
    #[must_use]
    pub fn seatbelt_profile(effective: &Caveats) -> String {
        seatbelt_profile_with(
            effective,
            &SandboxPolicy::default().base_read_paths.resolve(),
        )
    }

    /// SBPL profile builder, parameterized on the read base (`base_read`).
    #[must_use]
    fn seatbelt_profile_with(effective: &Caveats, base_read: &[String]) -> String {
        let mut p = String::from("(version 1)\n(allow default)\n");

        // fs_write: deny writes, then re-allow the granted roots.
        if let Scope::Only(_) = &effective.fs_write {
            p.push_str("(deny file-write*)\n");
            let roots = confined_roots(&effective.fs_write);
            if !roots.is_empty() {
                p.push_str("(allow file-write*");
                for r in &roots {
                    p.push_str(&format!(" (subpath {})", sbpl_string(r)));
                }
                p.push_str(")\n");
            }
        }

        // fs_read: deny reads, then re-allow. `(allow file-read-metadata)`
        // permits path *traversal* and `stat` everywhere — without it, reaching
        // an in-scope file through a symlink ancestor (`/tmp`, `/var`, `/etc` →
        // `/private/…`) is denied at the symlink lookup. Metadata reveals only
        // existence/size, never **content**; the data axis stays confined to the
        // loader/system base, the root directory *entry* (dyld reads `/` itself),
        // and the granted roots — so a permitted program loads and reads in-scope
        // files while out-of-scope file *contents* (the exfil threat) stay denied.
        if let Scope::Only(_) = &effective.fs_read {
            p.push_str("(deny file-read*)\n");
            p.push_str("(allow file-read-metadata)\n");
            p.push_str("(allow file-read* (literal \"/\")");
            for base in base_read {
                if let Some(c) = canonical_path(base) {
                    p.push_str(&format!(" (subpath {})", sbpl_string(&c)));
                }
            }
            for r in confined_roots(&effective.fs_read) {
                p.push_str(&format!(" (subpath {})", sbpl_string(&r)));
            }
            p.push_str(")\n");
        }

        // net: SBPL can name only `*`/`localhost` + ports as a remote (an
        // arbitrary IP is rejected: "host must be * or localhost"; ADR 0015), so a
        // general host allowlist is inexpressible and left ambient (reported
        // advisory, never silently dropped). The two policies it *can* enforce:
        //   • empty scope  → `(deny network*)`: every socket kernel-denied — a
        //     confinement no Landlock increment can supply.
        //   • loopback-only allowlist → deny all, then re-allow the loopback
        //     interface (`localhost` = 127.0.0.1 + ::1). The process's own off-box
        //     socket egress stays kernel-denied; the exact loopback host is narrowed
        //     by admission. Last-match-wins, so the allow overrides.
        if super::net_fully_denied(effective) {
            p.push_str("(deny network*)\n");
        } else if super::net_loopback_only(effective) {
            p.push_str("(deny network*)\n");
            p.push_str("(allow network* (remote ip \"localhost:*\"))\n");
        }

        // exec: deny *all* further execs, then re-allow exactly the granted
        // programs (resolved to absolute, canonical paths). `process-exec*` is
        // kernel-checked on the confined process AND everything it spawns, so this
        // is the `exec` axis at interior grain — no seccomp backstop needed (the
        // dyld trampoline is itself a governed `process-exec`, and `mmap(PROT_EXEC)`
        // read-as-code is closed by hardware W^X + code signing; ADR 0014). An
        // empty/unresolvable grant emits the deny with no re-allow — every exec
        // (including the wrapped program's own launch) denied: fail-closed, never
        // ambient. SBPL is last-match-wins, so the trailing allow overrides.
        if let Scope::Only(_) = &effective.exec {
            p.push_str("(deny process-exec*)\n");
            let targets = resolve_exec_targets(&effective.exec);
            if !targets.is_empty() {
                p.push_str("(allow process-exec*");
                for t in &targets {
                    p.push_str(&format!(" (literal {})", sbpl_string(t)));
                }
                p.push_str(")\n");
            }
        }

        p
    }

    /// The canonicalized, existing roots a restricted [`Scope`] grants. A path
    /// that cannot be resolved to any existing ancestor is dropped (it cannot
    /// anchor a rule — safe, since its parent is ungranted, so access beneath it
    /// stays denied). `All` yields nothing (callers only pass a restricted axis).
    fn confined_roots(scope: &Scope<String>) -> Vec<String> {
        let Scope::Only(set) = scope else {
            return Vec::new();
        };
        let mut roots: Vec<String> = set.iter().filter_map(|p| canonical_path(p)).collect();
        roots.sort();
        roots.dedup();
        roots
    }

    /// System binary directories searched to resolve a **bare-name** `exec` grant
    /// (e.g. `["git"]`) to absolute path(s) for the `process-exec*` allow-list.
    /// SIP-protected, read-only system locations — a trustworthy pin. Bare names
    /// resolve through this *fixed* list, never the ambient `$PATH` (ADR 0014 /
    /// ADR 0011 D5), so a binary planted earlier on a caller's `$PATH` cannot
    /// widen the kernel allow-list. An absolute-path grant is honored verbatim
    /// (then canonicalized); a basename collision outside these dirs is not.
    const TRUSTED_EXEC_DIRS: &[&str] = &["/usr/bin", "/bin", "/usr/sbin", "/sbin"];

    /// Resolve a restricted `exec` [`Scope`] to the absolute, canonical program
    /// paths that anchor the SBPL `(allow process-exec* (literal …))` rules. The
    /// kernel matches `process-exec` against the *resolved* path of the exec
    /// target, so each grant must become a realpath: an absolute grant is
    /// canonicalized; a bare name is resolved against [`TRUSTED_EXEC_DIRS`] (each
    /// existing hit included, mirroring admission's basename semantics in
    /// [`crate::context`] but pinned to trusted dirs). A relative-path or
    /// unresolvable grant is dropped — it cannot anchor a rule, so the program
    /// stays denied (fail-closed). `All` yields nothing (callers pass a restricted
    /// axis). Results are sorted+deduped so the emitted profile is deterministic.
    fn resolve_exec_targets(scope: &Scope<String>) -> Vec<String> {
        let Scope::Only(set) = scope else {
            return Vec::new();
        };
        let canon_file = |path: &Path, out: &mut Vec<String>| {
            if let Ok(c) = std::fs::canonicalize(path) {
                if c.is_file() {
                    out.push(c.to_string_lossy().into_owned());
                }
            }
        };
        let mut out: Vec<String> = Vec::new();
        for token in set {
            if token.starts_with('/') {
                // Absolute grant: honored verbatim (canonicalized, must exist).
                canon_file(Path::new(token), &mut out);
            } else if !token.contains('/') {
                // Bare name: resolve against the fixed trusted system dirs only.
                for dir in TRUSTED_EXEC_DIRS {
                    canon_file(&Path::new(dir).join(token), &mut out);
                }
            }
            // else: a relative path grant cannot anchor a kernel rule safely — drop.
        }
        out.sort();
        out.dedup();
        out
    }

    /// Resolve `p` to an absolute, symlink-free path suitable for `(subpath …)`
    /// matching, which the kernel performs against the *resolved* path (so a
    /// granted `/tmp/x` must become `/private/tmp/x` or it never matches). If the
    /// leaf does not yet exist, canonicalize the longest existing ancestor and
    /// re-append the remainder. `None` if not even an ancestor resolves.
    fn canonical_path(p: &str) -> Option<String> {
        let path = Path::new(p);
        if let Ok(c) = std::fs::canonicalize(path) {
            return Some(c.to_string_lossy().into_owned());
        }
        let mut tail: Vec<std::ffi::OsString> = Vec::new();
        let mut cur = path;
        while let Some(parent) = cur.parent() {
            if let Some(name) = cur.file_name() {
                tail.push(name.to_owned());
            }
            if let Ok(c) = std::fs::canonicalize(parent) {
                let mut resolved = c;
                for seg in tail.iter().rev() {
                    resolved.push(seg);
                }
                return Some(resolved.to_string_lossy().into_owned());
            }
            cur = parent;
        }
        None
    }

    /// Quote `s` as an SBPL string literal, escaping `\` and `"` so a crafted
    /// path can never break out of the quotes and inject profile syntax.
    fn sbpl_string(s: &str) -> String {
        let mut out = String::with_capacity(s.len() + 2);
        out.push('"');
        for ch in s.chars() {
            if ch == '\\' || ch == '"' {
                out.push('\\');
            }
            out.push(ch);
        }
        out.push('"');
        out
    }

    #[cfg(test)]
    mod unit {
        use super::*;
        use crate::Scope;

        #[test]
        fn unrestricted_caveats_make_no_wrapper() {
            assert!(SeatbeltSandbox::new()
                .command_prefix(&Caveats::top())
                .unwrap()
                .is_empty());
        }

        /// #144 (I5-B) regression guard: the Seatbelt backend must read its base
        /// allow-list from `self.policy` on the PRODUCTION path (`command_prefix`
        /// → `seatbelt_profile_with`), not a hardcoded const. A widened
        /// `base_read_paths` must appear in the generated SBPL profile; the
        /// default policy must not admit it. Mirrors the Landlock proof
        /// `landlock_config_widens_base_read`, so a revert of the const path is
        /// caught on macOS too (previously only Landlock had this coverage).
        #[test]
        fn command_prefix_widens_the_read_base_from_policy() {
            if !seatbelt_is_supported() {
                eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
                return;
            }
            let extra = std::env::temp_dir().join("abridle-seatbelt-cfg-widen");
            std::fs::create_dir_all(&extra).unwrap();
            let extra_str = extra.to_string_lossy().into_owned();
            // The profile carries the canonicalized path (e.g. /tmp → /private/tmp).
            let want = canonical_path(&extra_str).expect("temp dir canonicalizes");

            // fs_read must be restricted for the read base to be emitted at all.
            let cav = Caveats {
                fs_read: Scope::only(["/usr".to_string()]),
                ..Caveats::top()
            };

            // Control: the default read base does NOT admit the extra dir.
            let default_prefix = SeatbeltSandbox::new().command_prefix(&cav).unwrap();
            assert!(
                !default_prefix.iter().any(|a| a.contains(&want)),
                "default read base must not include the extra dir: {default_prefix:?}"
            );

            // Widened policy: add `extra` to base_read_paths → it appears.
            let mut base = SandboxPolicy::default().base_read_paths;
            base.extra.push(extra_str);
            let policy = Arc::new(SandboxPolicy {
                base_read_paths: base,
                ..SandboxPolicy::default()
            });
            let widened_prefix = SeatbeltSandbox::with_policy(policy)
                .command_prefix(&cav)
                .unwrap();
            assert!(
                widened_prefix.iter().any(|a| a.contains(&want)),
                "config-widened base_read_paths must reach the SBPL profile: {widened_prefix:?}"
            );

            let _ = std::fs::remove_dir_all(&extra);
        }

        #[test]
        fn empty_net_denies_all_egress_and_engages_the_wrapper() {
            // net:none with fs unrestricted still confines (network), so the
            // wrapper must engage and the profile must deny all egress.
            let cav = Caveats {
                net: Scope::none(),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            assert!(prof.contains("(deny network*)"), "{prof}");
            assert!(
                !SeatbeltSandbox::new()
                    .command_prefix(&cav)
                    .unwrap()
                    .is_empty(),
                "net:none must engage the sandbox-exec wrapper"
            );
        }

        #[test]
        fn nonempty_net_allowlist_is_not_denied() {
            // A general (non-loopback) host allowlist is not expressible in SBPL —
            // it can name only `*`/`localhost` + ports as a remote — so no network
            // rule is emitted; left ambient (advisory), never silently dropped.
            let cav = Caveats {
                net: Scope::only(["example.com".to_string()]),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            assert!(
                !prof.contains("network"),
                "non-loopback net must stay ambient: {prof}"
            );
        }

        #[test]
        fn loopback_only_net_confines_to_loopback_and_engages() {
            // A loopback-only allowlist IS expressible: deny all egress, then
            // re-allow the loopback interface (ADR 0015). Off-box egress stays
            // kernel-denied; the wrapper engages even with fs/exec unrestricted.
            for host in ["localhost", "127.0.0.1", "::1"] {
                let cav = Caveats {
                    net: Scope::only([host.to_string()]),
                    ..Caveats::top()
                };
                let prof = seatbelt_profile(&cav);
                assert!(prof.contains("(deny network*)"), "{host}: {prof}");
                assert!(
                    prof.contains("(allow network* (remote ip \"localhost:*\"))"),
                    "{host}: loopback re-allow missing: {prof}"
                );
                assert!(
                    !SeatbeltSandbox::new()
                        .command_prefix(&cav)
                        .unwrap()
                        .is_empty(),
                    "{host}: a loopback-only net grant must engage the wrapper"
                );
            }
        }

        #[test]
        fn mixed_loopback_and_remote_host_stays_ambient() {
            // A single non-loopback host taints the set: SBPL cannot express the
            // remote, so the whole allowlist stays ambient (advisory) rather than
            // emit a rule that would silently drop `example.com`.
            let cav = Caveats {
                net: Scope::only(["localhost".to_string(), "example.com".to_string()]),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            assert!(
                !prof.contains("network"),
                "a mixed loopback+remote allowlist must stay ambient: {prof}"
            );
        }

        #[test]
        fn loopback_fenced_caveats_emit_the_egress_proxy_fence() {
            // The egress-proxy mechanism (#124, ADR 0016) fences a remote-host
            // grant to loopback via `loopback_fenced_caveats`: the resulting
            // profile must carry the ADR 0015 loopback fence AND preserve fs/exec.
            let granted = Caveats {
                net: Scope::only(["example.com".to_string()]),
                fs_write: Scope::only(["/tmp".to_string()]),
                ..Caveats::top()
            };
            // The remote grant alone emits NO net rule (advisory) …
            assert!(!seatbelt_profile(&granted).contains("network"));
            // … but its loopback-fenced form emits the kernel egress fence.
            let prof = seatbelt_profile(&super::super::loopback_fenced_caveats(&granted));
            assert!(prof.contains("(deny network*)"), "{prof}");
            assert!(
                prof.contains("(allow network* (remote ip \"localhost:*\"))"),
                "fence must re-allow loopback: {prof}"
            );
            assert!(
                prof.contains("(deny file-write*)"),
                "fs_write rule must survive the fence: {prof}"
            );
        }

        #[test]
        fn restricted_write_yields_sandbox_exec_wrapper() {
            let cav = Caveats {
                fs_write: Scope::only(["/tmp".to_string()]),
                ..Caveats::top()
            };
            let prefix = SeatbeltSandbox::new().command_prefix(&cav).unwrap();
            assert_eq!(prefix[0], SANDBOX_EXEC);
            assert_eq!(prefix[1], "-p");
            assert!(prefix[2].contains("(deny file-write*)"));
        }

        #[test]
        fn profile_denies_then_reallows_write_roots() {
            let cav = Caveats {
                fs_write: Scope::only(["/tmp".to_string()]),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            assert!(prof.contains("(allow default)"));
            assert!(prof.contains("(deny file-write*)"));
            // `/tmp` must be canonicalized to its real target for subpath match.
            assert!(prof.contains("(subpath \"/private/tmp\")"), "{prof}");
            // No read axis restricted => no read deny.
            assert!(!prof.contains("(deny file-read*)"));
        }

        #[test]
        fn empty_write_scope_denies_all_writes_no_allow() {
            let cav = Caveats {
                fs_write: Scope::none(),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            assert!(prof.contains("(deny file-write*)"));
            assert!(
                !prof.contains("(allow file-write*"),
                "an empty scope must grant no write roots: {prof}"
            );
        }

        #[test]
        fn restricted_read_includes_loader_base_and_root_entry() {
            let cav = Caveats {
                fs_read: Scope::only(["/tmp".to_string()]),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            assert!(prof.contains("(deny file-read*)"));
            assert!(prof.contains("(literal \"/\")"), "{prof}");
            assert!(prof.contains("(subpath \"/usr\")"), "{prof}");
            assert!(prof.contains("(subpath \"/System\")"), "{prof}");
        }

        #[test]
        fn sbpl_string_escapes_quotes_and_backslashes() {
            assert_eq!(sbpl_string("/a/b"), "\"/a/b\"");
            assert_eq!(sbpl_string("/a\"b"), "\"/a\\\"b\"");
            assert_eq!(sbpl_string("/a\\b"), "\"/a\\\\b\"");
        }

        /// Count double-quotes that are *not* backslash-escaped — the structural
        /// quotes SBPL actually sees. Each `(subpath "…")` term I emit
        /// contributes exactly two; any extra would mean a path broke out of its
        /// literal.
        fn unescaped_quotes(s: &str) -> usize {
            let b = s.as_bytes();
            (0..b.len())
                .filter(|&i| b[i] == b'"' && (i == 0 || b[i - 1] != b'\\'))
                .count()
        }

        #[test]
        fn crafted_path_cannot_inject_profile_syntax() {
            // A path crafted to close the string and add its own allow rule must
            // stay inside one escaped literal — its quotes get backslash-escaped,
            // so SBPL sees exactly the two structural quotes of the single term.
            let cav = Caveats {
                fs_write: Scope::only(["/tmp/x\") (allow file-write* (subpath \"/".to_string()]),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            assert_eq!(
                unescaped_quotes(&prof),
                2,
                "exactly one structural (subpath \"\") term — no breakout: {prof}"
            );
            assert!(
                prof.contains("\\\""),
                "the crafted quotes must be backslash-escaped: {prof}"
            );
        }

        #[test]
        fn restricted_exec_emits_deny_and_allowlist() {
            let cav = Caveats {
                exec: Scope::only(["/bin/echo".to_string()]),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            assert!(prof.contains("(deny process-exec*)"), "{prof}");
            assert!(
                prof.contains("(allow process-exec* (literal \"/bin/echo\")"),
                "{prof}"
            );
        }

        #[test]
        fn bare_name_exec_resolves_through_trusted_dirs() {
            // A bare name is pinned to the fixed trusted system dirs, never $PATH.
            let cav = Caveats {
                exec: Scope::only(["true".to_string()]),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            // `/usr/bin/true` exists on every macOS host and canonicalizes to
            // itself, so the literal must name the absolute resolved path.
            assert!(
                prof.contains("(literal \"/usr/bin/true\")"),
                "bare name must resolve to its trusted-dir absolute path: {prof}"
            );
        }

        #[test]
        fn restricted_exec_engages_the_wrapper() {
            // exec-only (no fs/net restriction) must still engage sandbox-exec.
            let cav = Caveats {
                exec: Scope::only(["/bin/echo".to_string()]),
                ..Caveats::top()
            };
            let prefix = SeatbeltSandbox::new().command_prefix(&cav).unwrap();
            assert_eq!(prefix.first().map(String::as_str), Some(SANDBOX_EXEC));
        }

        #[test]
        fn empty_exec_scope_denies_all_exec_with_no_allow() {
            // exec:none — the program may exec nothing. The deny is emitted with no
            // re-allow, so even the wrapped program's launch is denied: fail-closed,
            // never silently ambient.
            let cav = Caveats {
                exec: Scope::none(),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            assert!(prof.contains("(deny process-exec*)"), "{prof}");
            assert!(
                !prof.contains("(allow process-exec*"),
                "an empty exec scope must grant no exec targets: {prof}"
            );
        }

        #[test]
        fn relative_and_unresolvable_exec_grants_are_dropped() {
            // A relative-path grant cannot anchor a kernel rule; a bare name with no
            // trusted-dir hit resolves to nothing. Either way: deny with no allow.
            let cav = Caveats {
                exec: Scope::only(["./payload".to_string(), "no-such-binary-xyzzy".to_string()]),
                ..Caveats::top()
            };
            let prof = seatbelt_profile(&cav);
            assert!(prof.contains("(deny process-exec*)"), "{prof}");
            assert!(
                !prof.contains("(allow process-exec*"),
                "unresolvable/relative grants must not anchor an allow: {prof}"
            );
        }

        #[test]
        fn unrestricted_exec_emits_no_exec_rules() {
            // exec:All (the default) is ambient on the exec axis — no rules.
            let prof = seatbelt_profile(&Caveats::top());
            assert!(!prof.contains("process-exec"), "{prof}");
        }
    }
}

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

    #[test]
    fn noop_reports_none_and_never_fails() {
        let s = NoopSandbox;
        assert_eq!(s.kind(), SandboxKind::None);
        assert!(s.apply(&Caveats::top()).is_ok());
    }

    #[test]
    fn net_egress_proxy_hosts_triggers_only_on_a_general_remote_allowlist() {
        let with_net = |net| {
            net_egress_proxy_hosts(&Caveats {
                net,
                ..Caveats::top()
            })
        };
        // No trigger: unrestricted, deny-all, or loopback-only — owned elsewhere.
        assert_eq!(with_net(Scope::All), None);
        assert_eq!(with_net(Scope::none()), None); // empty = deny-all (net_fully_denied)
        for lo in ["localhost", "127.0.0.1", "::1"] {
            assert_eq!(
                with_net(Scope::only([lo.to_string()])),
                None,
                "{lo} is loopback-only"
            );
        }
        // Trigger: a remote host, alone or mixed with loopback (full set returned).
        assert_eq!(
            with_net(Scope::only(["example.com".to_string()])),
            Some(vec!["example.com".to_string()])
        );
        let mixed = with_net(Scope::only([
            "example.com".to_string(),
            "localhost".to_string(),
        ]))
        .expect("mixed set triggers");
        assert_eq!(
            mixed.len(),
            2,
            "the FULL grant is returned, loopback included: {mixed:?}"
        );
        assert!(
            mixed.contains(&"example.com".to_string()) && mixed.contains(&"localhost".to_string())
        );
    }

    #[test]
    fn loopback_fenced_caveats_swaps_net_to_loopback_preserving_other_axes() {
        let granted = Caveats {
            net: Scope::only(["example.com".to_string()]),
            fs_write: Scope::only(["/tmp/x".to_string()]),
            exec: Scope::only(["git".to_string()]),
            ..Caveats::top()
        };
        let fenced = loopback_fenced_caveats(&granted);
        // net is now loopback-only, so it engages the ADR 0015 kernel fence …
        assert!(
            net_loopback_only(&fenced),
            "fenced net must be loopback-only"
        );
        assert!(
            net_egress_proxy_hosts(&fenced).is_none(),
            "fenced caveats no longer trigger the proxy"
        );
        // … while fs/exec are preserved verbatim (the fence keeps their rules).
        assert_eq!(fenced.fs_write, granted.fs_write);
        assert_eq!(fenced.exec, granted.exec);
    }

    #[test]
    fn sandbox_kind_serde_is_snake_case() {
        assert_eq!(
            serde_json::to_string(&SandboxKind::None).unwrap(),
            "\"none\""
        );
        assert_eq!(
            serde_json::to_string(&SandboxKind::Landlock).unwrap(),
            "\"landlock\""
        );
        assert_eq!(
            serde_json::to_string(&SandboxKind::Seatbelt).unwrap(),
            "\"seatbelt\""
        );
        assert_eq!(
            serde_json::to_string(&SandboxKind::AppContainer).unwrap(),
            "\"app_container\""
        );
        assert_eq!(
            serde_json::to_string(&SandboxKind::MinimalRootfs).unwrap(),
            "\"minimal_rootfs\""
        );
        assert_eq!(
            serde_json::to_string(&SandboxKind::MicroVm).unwrap(),
            "\"micro_vm\""
        );
    }

    #[test]
    fn effective_kind_downgrades_to_none_when_no_fs_axis_restricted() {
        // The honesty rule (I9): a backend that confines nothing must not be
        // reported. With every axis `All`, even a real backend → None. (For
        // AppContainer the rule keeps it `None` here regardless — its shell/spawn
        // launcher is a follow-up, so it is not engaged via this path yet.)
        for available in [
            SandboxKind::Landlock,
            SandboxKind::Seatbelt,
            SandboxKind::AppContainer,
            SandboxKind::None,
        ] {
            assert_eq!(
                effective_sandbox_kind(available, &Caveats::top()),
                SandboxKind::None,
                "unrestricted fs must report None for {available:?}"
            );
        }
        // With a restricted fs axis, the backend's own kind is reported …
        let restricted = Caveats {
            fs_write: Scope::only(["/w".to_string()]),
            ..Caveats::top()
        };
        assert_eq!(
            effective_sandbox_kind(SandboxKind::Landlock, &restricted),
            SandboxKind::Landlock
        );
        assert_eq!(
            effective_sandbox_kind(SandboxKind::Seatbelt, &restricted),
            SandboxKind::Seatbelt
        );
        // … except a None host is always None (nothing to enforce with).
        assert_eq!(
            effective_sandbox_kind(SandboxKind::None, &restricted),
            SandboxKind::None
        );
        // A restricted *read* axis also engages (Landlock/Seatbelt govern reads).
        let read_only = Caveats {
            fs_read: Scope::only(["/r".to_string()]),
            ..Caveats::top()
        };
        assert_eq!(
            effective_sandbox_kind(SandboxKind::Seatbelt, &read_only),
            SandboxKind::Seatbelt
        );
        // An empty net scope (all egress denied), even with fs unrestricted,
        // engages Seatbelt. Landlock engages only on V4+ kernels (≥ 6.7) where
        // TCP deny-all is expressible; on older kernels it falls back to None.
        let net_denied = Caveats {
            net: Scope::none(),
            ..Caveats::top()
        };
        assert_eq!(
            effective_sandbox_kind(SandboxKind::Seatbelt, &net_denied),
            SandboxKind::Seatbelt,
            "Seatbelt kernel-denies egress, so net:none engages it"
        );
        let expected_landlock_net = if landlock_net_capable() {
            SandboxKind::Landlock
        } else {
            SandboxKind::None
        };
        assert_eq!(
            effective_sandbox_kind(SandboxKind::Landlock, &net_denied),
            expected_landlock_net,
            "Landlock engages for net:none only when V4 TCP-deny support is present"
        );
    }

    /// AppContainer engages for a loopback-only net scope (#133, ADR 0016).
    /// This enables the egress-proxy pattern: `loopback_fenced_caveats` produces
    /// a net=loopback grant, and with AppContainer that fence is kernel-expressed
    /// (off-box egress is denied; loopback exemption lets the child reach the proxy).
    #[test]
    fn appcontainer_engages_for_loopback_only_net() {
        for host in ["localhost", "127.0.0.1", "::1"] {
            let loopback_only = Caveats {
                net: Scope::only([host.to_string()]),
                ..Caveats::top()
            };
            assert_eq!(
                effective_sandbox_kind(SandboxKind::AppContainer, &loopback_only),
                SandboxKind::AppContainer,
                "AppContainer must engage for loopback host {host}"
            );
        }
        // A general remote host is NOT loopback-only → falls through to None
        // (net advisory; handled by egress-proxy when the sandbox is AppContainer).
        let remote = Caveats {
            net: Scope::only(["example.com".to_string()]),
            ..Caveats::top()
        };
        assert_eq!(
            effective_sandbox_kind(SandboxKind::AppContainer, &remote),
            SandboxKind::None,
            "general remote host must not directly engage AppContainer"
        );
    }

    /// `loopback_fenced_caveats` + AppContainer engages the backend, enabling
    /// `egress_proxy_plan` to route through the loopback proxy on Windows (#133).
    #[test]
    fn loopback_fenced_caveats_engages_appcontainer() {
        let remote = Caveats {
            net: Scope::only(["example.com".to_string()]),
            ..Caveats::top()
        };
        let fenced = loopback_fenced_caveats(&remote);
        assert!(
            net_loopback_only(&fenced),
            "loopback_fenced_caveats must produce a loopback-only net scope"
        );
        assert_eq!(
            effective_sandbox_kind(SandboxKind::AppContainer, &fenced),
            SandboxKind::AppContainer,
            "loopback-fenced caveats must engage AppContainer"
        );
    }

    #[test]
    fn best_available_sandbox_is_a_sandbox() {
        // Always returns *some* sandbox; on a non-landlock build/kernel it is the
        // advisory Noop. Just exercise the trait object.
        // AppContainer's `apply` is a deliberate no-op (confinement is applied at
        // process creation via `command_prefix`, not to the current thread).
        let sb = best_available_sandbox(&Arc::new(SandboxPolicy::default()));
        assert!(sb.apply(&Caveats::top()).is_ok());
    }

    #[cfg(all(target_os = "windows", feature = "windows-appcontainer"))]
    #[test]
    fn windows_appcontainer_feature_selects_appcontainer_backend() {
        assert_eq!(
            best_available_sandbox(&Arc::new(SandboxPolicy::default())).kind(),
            SandboxKind::AppContainer
        );
    }
}

// Real kernel enforcement test. Only meaningful with the feature on Linux; it
// asserts the leash is the *kernel's*, not ours — the regression proof that
// `fs_write` confines a process even outside the in-process L2 interceptor.
#[cfg(all(target_os = "linux", feature = "linux-landlock", test))]
mod landlock_kernel_tests {
    use super::*;
    use crate::Scope;
    use std::fs;
    use std::path::PathBuf;

    fn unique_dir(tag: &str) -> PathBuf {
        // No rand dep: derive a unique path from pid + a per-call atomic counter.
        use std::sync::atomic::{AtomicU64, Ordering};
        static N: AtomicU64 = AtomicU64::new(0);
        let mut d = std::env::temp_dir();
        d.push(format!(
            "agent-bridle-ll-{}-{}-{}",
            tag,
            std::process::id(),
            N.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(&d).unwrap();
        d
    }

    /// Whether a kernel-enforcement proof should run, skip, or hard-**FAIL** — a
    /// pure decision over (Landlock supported?, enforcement required?). Required
    /// but unsupported is a FAILURE: a security library must not ship a green
    /// build in which its kernel boundary was never exercised (#74).
    #[derive(Debug, PartialEq, Eq)]
    enum ProofGate {
        Run,
        Skip,
        Fail,
    }

    fn proof_gate(supported: bool, required: bool) -> ProofGate {
        match (supported, required) {
            (true, _) => ProofGate::Run,
            (false, true) => ProofGate::Fail,
            (false, false) => ProofGate::Skip,
        }
    }

    /// `true` if the caller should `return` (skip the proof). **Panics** when
    /// Landlock is *required* (`BRIDLE_REQUIRE_LANDLOCK` set, as CI does) but the
    /// kernel lacks it — so a flagged run cannot pass without actually exercising
    /// the boundary. A local run without the flag legitimately skips (#74).
    fn skip_proof_unless_landlock() -> bool {
        let required = std::env::var("BRIDLE_REQUIRE_LANDLOCK")
            .map(|v| !v.is_empty() && v != "0")
            .unwrap_or(false);
        match proof_gate(landlock_is_supported(), required) {
            ProofGate::Run => false,
            ProofGate::Skip => {
                eprintln!(
                    "skipping Landlock proof: kernel lacks Landlock \
                     (set BRIDLE_REQUIRE_LANDLOCK=1 to require it, as CI does)"
                );
                true
            }
            ProofGate::Fail => panic!(
                "BRIDLE_REQUIRE_LANDLOCK is set but this kernel lacks Landlock — the \
                 fs_write/fs_read kernel-enforcement proofs cannot be verified (#74)"
            ),
        }
    }

    #[test]
    fn proof_gate_required_but_unsupported_is_a_failure() {
        assert_eq!(proof_gate(true, false), ProofGate::Run);
        assert_eq!(proof_gate(true, true), ProofGate::Run);
        assert_eq!(proof_gate(false, false), ProofGate::Skip);
        // The crux (#74): required + unsupported must FAIL, never silently skip,
        // so CI cannot pass without exercising the kernel boundary.
        assert_eq!(proof_gate(false, true), ProofGate::Fail);
    }

    #[test]
    fn fs_write_is_kernel_enforced_outside_scope_denied_inside_allowed() {
        if skip_proof_unless_landlock() {
            return;
        }

        let allowed = unique_dir("allowed");
        let forbidden = unique_dir("forbidden");
        let allowed_t = allowed.clone();
        let forbidden_t = forbidden.clone();

        // `restrict_self` is per-thread and irreversible, so confine a throwaway
        // thread rather than poisoning the test runner's threads.
        let (inside_ok, outside) = std::thread::spawn(move || {
            let cav = Caveats {
                fs_write: Scope::only([allowed_t.to_string_lossy().into_owned()]),
                ..Caveats::top()
            };
            LandlockSandbox::new().apply(&cav).expect("apply landlock");

            let inside = fs::write(allowed_t.join("ok.txt"), b"hi");
            let outside = fs::write(forbidden_t.join("escape.txt"), b"nope");
            (inside.is_ok(), outside)
        })
        .join()
        .unwrap();

        assert!(inside_ok, "writing within fs_write scope must succeed");
        let err = outside.expect_err("writing outside fs_write scope must be denied by Landlock");
        assert_eq!(
            err.kind(),
            std::io::ErrorKind::PermissionDenied,
            "the denial must come from the kernel (EACCES)"
        );

        let _ = fs::remove_dir_all(&allowed);
        let _ = fs::remove_dir_all(&forbidden);
    }

    /// #144 (I5-B): the Landlock read base is config-driven. Widening
    /// `base_read_paths` lets a confined thread read a path that is otherwise
    /// outside `fs_read` scope — proving `apply` reads `self.policy`, not the old
    /// module const. The control (default policy) denies the same read.
    #[test]
    fn landlock_config_widens_base_read() {
        if skip_proof_unless_landlock() {
            return;
        }
        let allowed = unique_dir("cfg-allowed");
        let extra = unique_dir("cfg-extra");
        fs::write(extra.join("data.txt"), b"configured").unwrap();

        let cav = Caveats {
            fs_read: Scope::only([allowed.to_string_lossy().into_owned()]),
            ..Caveats::top()
        };

        // Control: with the DEFAULT policy the out-of-scope `extra` dir is denied.
        let (extra_c, cav_c) = (extra.clone(), cav.clone());
        let denied = std::thread::spawn(move || {
            LandlockSandbox::new().apply(&cav_c).expect("apply");
            fs::read(extra_c.join("data.txt"))
        })
        .join()
        .unwrap();
        assert!(
            denied.is_err(),
            "default base read must NOT include the out-of-scope extra dir"
        );

        // Widened policy: add `extra` to base_read_paths → the same read succeeds.
        let mut base = SandboxPolicy::default().base_read_paths;
        base.extra.push(extra.to_string_lossy().into_owned());
        let policy = Arc::new(SandboxPolicy {
            base_read_paths: base,
            ..SandboxPolicy::default()
        });
        let extra_w = extra.clone();
        let allowed_read = std::thread::spawn(move || {
            LandlockSandbox::with_policy(policy)
                .apply(&cav)
                .expect("apply");
            fs::read(extra_w.join("data.txt"))
        })
        .join()
        .unwrap();
        assert!(
            allowed_read.is_ok(),
            "config-widened base_read_paths must allow the extra dir: {allowed_read:?}"
        );

        let _ = fs::remove_dir_all(&allowed);
        let _ = fs::remove_dir_all(&extra);
    }

    #[test]
    fn empty_fs_write_scope_denies_all_writes() {
        if skip_proof_unless_landlock() {
            return;
        }
        let dir = unique_dir("none");
        let dir_t = dir.clone();
        let outside = std::thread::spawn(move || {
            let cav = Caveats {
                fs_write: Scope::none(),
                ..Caveats::top()
            };
            LandlockSandbox::new().apply(&cav).expect("apply landlock");
            fs::write(dir_t.join("x.txt"), b"nope")
        })
        .join()
        .unwrap();
        assert_eq!(
            outside
                .expect_err("empty fs_write must deny all writes")
                .kind(),
            std::io::ErrorKind::PermissionDenied
        );
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn fs_read_is_kernel_enforced_outside_scope_denied_inside_allowed() {
        if skip_proof_unless_landlock() {
            return;
        }
        let allowed = unique_dir("read-allowed");
        let forbidden = unique_dir("read-forbidden");
        // Create both files BEFORE confining (afterwards the forbidden dir is
        // unreadable, but it must already hold a file to attempt the read).
        fs::write(allowed.join("ok.txt"), b"in-scope").unwrap();
        fs::write(forbidden.join("secret.txt"), b"out-of-scope").unwrap();
        let allowed_t = allowed.clone();
        let forbidden_t = forbidden.clone();

        let (inside, outside) = std::thread::spawn(move || {
            let cav = Caveats {
                fs_read: Scope::only([allowed_t.to_string_lossy().into_owned()]),
                ..Caveats::top()
            };
            LandlockSandbox::new().apply(&cav).expect("apply landlock");
            let inside = fs::read(allowed_t.join("ok.txt"));
            let outside = fs::read(forbidden_t.join("secret.txt"));
            (inside, outside)
        })
        .join()
        .unwrap();

        assert_eq!(inside.expect("in-scope read must succeed"), b"in-scope");
        assert_eq!(
            outside
                .expect_err("reading outside fs_read scope must be denied by Landlock")
                .kind(),
            std::io::ErrorKind::PermissionDenied,
            "the denial must come from the kernel (EACCES)"
        );

        let _ = fs::remove_dir_all(&allowed);
        let _ = fs::remove_dir_all(&forbidden);
    }

    #[test]
    fn read_confined_binary_still_loads_via_base_allowlist() {
        if skip_proof_unless_landlock() {
            return;
        }
        let allowed = unique_dir("rc-allowed");
        let forbidden = unique_dir("rc-forbidden");
        fs::write(allowed.join("ok.txt"), b"hello\n").unwrap();
        fs::write(forbidden.join("secret.txt"), b"nope\n").unwrap();
        let allowed_t = allowed.clone();
        let forbidden_t = forbidden.clone();

        // Confine reads, then run a *real* dynamically-linked binary (`cat`):
        // it must still load (proving the base allow-list covers the loader and
        // libc) and read the in-scope file, but be denied the out-of-scope one.
        let (inside, outside) = std::thread::spawn(move || {
            let cav = Caveats {
                fs_read: Scope::only([allowed_t.to_string_lossy().into_owned()]),
                ..Caveats::top()
            };
            LandlockSandbox::new().apply(&cav).expect("apply landlock");
            let inside = std::process::Command::new("cat")
                .arg(allowed_t.join("ok.txt"))
                .output();
            let outside = std::process::Command::new("cat")
                .arg(forbidden_t.join("secret.txt"))
                .output();
            (inside, outside)
        })
        .join()
        .unwrap();

        let inside = inside.expect("cat must still load+run under read confinement");
        assert!(
            inside.status.success(),
            "in-scope cat must succeed: {inside:?}"
        );
        assert_eq!(inside.stdout, b"hello\n");

        let outside = outside.expect("cat launches (loader is allowed) even for a denied target");
        assert!(
            !outside.status.success(),
            "cat of an out-of-scope file must fail (read denied): {outside:?}"
        );

        let _ = fs::remove_dir_all(&allowed);
        let _ = fs::remove_dir_all(&forbidden);
    }

    #[test]
    fn fs_read_all_leaves_reads_ambient() {
        if skip_proof_unless_landlock() {
            return;
        }
        // With fs_read: All (only fs_write restricted), reads are NOT governed —
        // a path outside the write scope is still readable.
        let outside_dir = unique_dir("ambient-read");
        fs::write(outside_dir.join("readable.txt"), b"still readable").unwrap();
        let write_scope = unique_dir("ambient-write");
        let outside_t = outside_dir.clone();
        let write_t = write_scope.clone();

        let read = std::thread::spawn(move || {
            let cav = Caveats {
                fs_write: Scope::only([write_t.to_string_lossy().into_owned()]),
                ..Caveats::top() // fs_read stays All
            };
            LandlockSandbox::new().apply(&cav).expect("apply landlock");
            fs::read(outside_t.join("readable.txt"))
        })
        .join()
        .unwrap();

        assert_eq!(
            read.expect("fs_read: All must leave reads ambient"),
            b"still readable"
        );
        let _ = fs::remove_dir_all(&outside_dir);
        let _ = fs::remove_dir_all(&write_scope);
    }

    /// #57 boundary: with `exec` confined to `cat`, the granted program (and its
    /// libraries) still runs, but a DIRECT `execve` of an un-granted tool (`head`)
    /// — the `find -exec curl` escape in miniature — is kernel-denied by the
    /// `Execute` allow-list. (This is the boundary/direct-execve close, NOT the
    /// trampoline; `exec` stays reported `interceptor`, ADR 0011 D7.)
    #[test]
    fn exec_direct_execve_of_ungranted_tool_is_kernel_denied() {
        if skip_proof_unless_landlock() {
            return;
        }
        let dir = unique_dir("exec");
        fs::write(dir.join("data.txt"), b"payload\n").unwrap();
        let dir_t = dir.clone();

        let (granted, ungranted) = std::thread::spawn(move || {
            let cav = Caveats {
                exec: Scope::only(["cat".to_string()]),
                ..Caveats::top()
            };
            LandlockSandbox::new().apply(&cav).expect("apply landlock");
            let granted = std::process::Command::new("cat")
                .arg(dir_t.join("data.txt"))
                .output();
            let ungranted = std::process::Command::new("head")
                .arg(dir_t.join("data.txt"))
                .output();
            (granted, ungranted)
        })
        .join()
        .unwrap();

        let granted = granted.expect("granted `cat` must still load and run");
        assert!(
            granted.status.success(),
            "granted cat must succeed: {granted:?}"
        );
        assert_eq!(granted.stdout, b"payload\n");

        // execve of the un-granted binary is kernel-denied: std surfaces the
        // post-fork exec failure as a PermissionDenied spawn error.
        let err = ungranted.expect_err("un-granted `head` must be exec-denied by Landlock");
        assert_eq!(
            err.kind(),
            std::io::ErrorKind::PermissionDenied,
            "the denial must come from the kernel (EACCES on execve)"
        );

        let _ = fs::remove_dir_all(&dir);
    }

    /// #57 adversarial sweep: with `exec` confined to `cat` and writes confined to
    /// a scratch dir, EVERY classic "make the permitted program launch something
    /// else" DIRECT-execve escape must be kernel-denied — an un-granted tool, a
    /// payload the context could write+run, a shebang script (un-granted
    /// interpreter), a symlink to an un-granted tool, and the real
    /// shells/interpreters that live under `/usr/lib*` (which a recursive lib-dir
    /// Execute grant — the narrowing this avoids — would have exposed). The
    /// granted program still works (control). (Direct-execve boundary only; the
    /// ld.so/interpreter trampoline is out of scope — `exec` stays `interceptor`.)
    #[test]
    fn exec_escape_attempts_are_all_denied() {
        use std::os::unix::fs::{symlink, PermissionsExt};

        if skip_proof_unless_landlock() {
            return;
        }
        let scratch = unique_dir("exec-escape"); // in fs_write scope
        fs::write(scratch.join("data.txt"), b"ok\n").unwrap();

        // A real ELF the confined context could try to run from the scratch dir (a
        // "written payload"); copy an existing binary to avoid needing a compiler.
        let payload = scratch.join("payload");
        if let Ok(src) = std::fs::read("/bin/cat").or_else(|_| std::fs::read("/usr/bin/cat")) {
            fs::write(&payload, src).unwrap();
            fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)).unwrap();
        }
        // A shebang script + a symlink to an un-granted interpreter.
        let script = scratch.join("script.sh");
        fs::write(&script, b"#!/bin/sh\necho pwned\n").unwrap();
        fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
        let link = scratch.join("sh-link");
        let _ = symlink("/bin/sh", &link);

        // Real shells/interpreters that live UNDER the library tree (/usr/lib*):
        // loader-only Execute must deny them. Tested only where present.
        let lib_execs: Vec<PathBuf> = [
            "/usr/lib/klibc/bin/sh",
            "/usr/lib/initramfs-tools/bin/busybox",
            "/usr/lib/git-core/git",
        ]
        .iter()
        .map(PathBuf::from)
        .filter(|p| p.exists())
        .collect();

        let scratch_t = scratch.clone();
        let (attempts, control) = std::thread::spawn(move || {
            let cav = Caveats {
                exec: Scope::only(["cat".to_string()]),
                fs_write: Scope::only([scratch_t.to_string_lossy().into_owned()]),
                ..Caveats::top()
            };
            LandlockSandbox::new().apply(&cav).expect("apply landlock");

            let mut attempts = vec![
                (
                    "ungranted-tool".to_string(),
                    std::process::Command::new("head")
                        .arg("/etc/hostname")
                        .output(),
                ),
                (
                    "written-payload".to_string(),
                    std::process::Command::new(scratch_t.join("payload")).output(),
                ),
                (
                    "shebang-script".to_string(),
                    std::process::Command::new(scratch_t.join("script.sh")).output(),
                ),
                (
                    "symlink-to-sh".to_string(),
                    std::process::Command::new(scratch_t.join("sh-link"))
                        .arg("-c")
                        .arg("echo pwned")
                        .output(),
                ),
            ];
            for p in &lib_execs {
                attempts.push((
                    format!("under-usr-lib:{}", p.display()),
                    std::process::Command::new(p).arg("--version").output(),
                ));
            }
            // Control: the granted program still runs.
            let control = std::process::Command::new("cat")
                .arg(scratch_t.join("data.txt"))
                .output();
            (attempts, control)
        })
        .join()
        .unwrap();

        for (label, res) in attempts {
            match res {
                Err(e) => assert_eq!(
                    e.kind(),
                    std::io::ErrorKind::PermissionDenied,
                    "escape `{label}` failed for the wrong reason: {e:?}"
                ),
                Ok(out) => panic!(
                    "escape `{label}` was NOT denied — it ran (status {:?}, stdout {:?})",
                    out.status, out.stdout
                ),
            }
        }
        let control = control.expect("granted `cat` must still run");
        assert!(
            control.status.success() && control.stdout == b"ok\n",
            "control: {control:?}"
        );

        let _ = fs::remove_dir_all(&scratch);
    }

    /// #57 / ADR 0011 D3: when BOTH `exec` and `fs_read` are confined, the read
    /// base excludes the bin dirs — the granted program (and its libs) still
    /// loads, but an un-granted system binary is NOT readable, so it cannot be
    /// `ld.so`-trampolined (the trampoline corpus is shrunk to the granted set).
    #[test]
    fn read_base_excludes_bin_dirs_when_exec_confined() {
        if skip_proof_unless_landlock() {
            return;
        }
        let dir = unique_dir("read-narrow");
        fs::write(dir.join("data.txt"), b"payload\n").unwrap();
        let dir_t = dir.clone();

        let (granted, head_bytes) = std::thread::spawn(move || {
            let cav = Caveats {
                exec: Scope::only(["cat".to_string()]),
                fs_read: Scope::only([dir_t.to_string_lossy().into_owned()]),
                ..Caveats::top()
            };
            LandlockSandbox::new().apply(&cav).expect("apply landlock");
            // Granted `cat` loads (its binary + libs are read-allowed) and reads
            // the in-scope file.
            let granted = std::process::Command::new("cat")
                .arg(dir_t.join("data.txt"))
                .output();
            // Reading an un-granted bin-dir binary's bytes (a would-be trampoline
            // payload) is denied — the bin dirs are not in the read set.
            let head_bytes = std::fs::read("/usr/bin/head").or_else(|_| std::fs::read("/bin/head"));
            (granted, head_bytes)
        })
        .join()
        .unwrap();

        let granted = granted.expect("granted `cat` must load + run under narrowed reads");
        assert!(
            granted.status.success() && granted.stdout == b"payload\n",
            "granted cat under narrowed reads: {granted:?}"
        );
        assert!(
            head_bytes.is_err(),
            "an un-granted bin-dir binary must be unreadable (trampoline corpus shrunk): {head_bytes:?}"
        );

        let _ = fs::remove_dir_all(&dir);
    }
}

// Real kernel-enforcement proof for macOS Seatbelt. Only meaningful on macOS
// with the feature; it asserts the leash is the *kernel's* (sandbox-exec's),
// not ours — the spawned child's own out-of-scope writes/reads are denied even
// though L2 cannot see its syscalls. Mirrors the Landlock proofs above.
#[cfg(all(target_os = "macos", feature = "macos-seatbelt", test))]
mod seatbelt_kernel_tests {
    use super::*;
    use crate::Scope;
    use std::fs;
    use std::path::PathBuf;

    /// Whether a proof should run, skip, or hard-**FAIL** — the same gate as the
    /// Landlock proofs (#74): *required but unsupported is a FAILURE*, so a
    /// macOS CI job that sets `BRIDLE_REQUIRE_SEATBELT` can never go green with
    /// the kernel boundary unexercised.
    #[derive(Debug, PartialEq, Eq)]
    enum ProofGate {
        Run,
        Skip,
        Fail,
    }

    fn proof_gate(supported: bool, required: bool) -> ProofGate {
        match (supported, required) {
            (true, _) => ProofGate::Run,
            (false, true) => ProofGate::Fail,
            (false, false) => ProofGate::Skip,
        }
    }

    /// `true` if the caller should skip the proof. **Panics** when Seatbelt is
    /// *required* (`BRIDLE_REQUIRE_SEATBELT` set, as a macOS CI job does) but the
    /// host lacks `sandbox-exec`. A local run without the flag legitimately skips.
    fn skip_proof_unless_seatbelt() -> bool {
        let required = std::env::var("BRIDLE_REQUIRE_SEATBELT")
            .map(|v| !v.is_empty() && v != "0")
            .unwrap_or(false);
        match proof_gate(seatbelt_is_supported(), required) {
            ProofGate::Run => false,
            ProofGate::Skip => {
                eprintln!(
                    "skipping Seatbelt proof: /usr/bin/sandbox-exec unavailable \
                     (set BRIDLE_REQUIRE_SEATBELT=1 to require it, as macOS CI does)"
                );
                true
            }
            ProofGate::Fail => panic!(
                "BRIDLE_REQUIRE_SEATBELT is set but /usr/bin/sandbox-exec is unavailable — \
                 the fs_write/fs_read kernel-enforcement proofs cannot be verified"
            ),
        }
    }

    fn unique_dir(tag: &str) -> PathBuf {
        use std::sync::atomic::{AtomicU64, Ordering};
        static N: AtomicU64 = AtomicU64::new(0);
        let mut d = std::env::temp_dir();
        d.push(format!(
            "agent-bridle-sb-{}-{}-{}",
            tag,
            std::process::id(),
            N.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(&d).unwrap();
        d
    }

    /// Spawn `program args` through the real `sandbox-exec` wrapper that
    /// [`SeatbeltSandbox::command_prefix`] builds for `cav`, and return its exit
    /// status. This exercises the *production* profile path end to end.
    fn run_wrapped(cav: &Caveats, program: &str, args: &[&str]) -> std::process::ExitStatus {
        let prefix = SeatbeltSandbox::new()
            .command_prefix(cav)
            .expect("a restricted axis must yield a wrapper prefix");
        assert!(!prefix.is_empty(), "expected a sandbox-exec wrapper");
        std::process::Command::new(&prefix[0])
            .args(&prefix[1..])
            .arg(program)
            .args(args)
            .status()
            .expect("spawn sandbox-exec")
    }

    #[test]
    fn proof_gate_required_but_unsupported_is_a_failure() {
        assert_eq!(proof_gate(true, false), ProofGate::Run);
        assert_eq!(proof_gate(true, true), ProofGate::Run);
        assert_eq!(proof_gate(false, false), ProofGate::Skip);
        assert_eq!(proof_gate(false, true), ProofGate::Fail);
    }

    #[test]
    fn fs_write_is_kernel_enforced_outside_scope_denied_inside_allowed() {
        if skip_proof_unless_seatbelt() {
            return;
        }
        let allowed = unique_dir("w-allowed");
        let forbidden = unique_dir("w-forbidden");
        let cav = Caveats {
            fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
            ..Caveats::top()
        };

        let inside = run_wrapped(
            &cav,
            "/usr/bin/touch",
            &[allowed.join("ok.txt").to_str().unwrap()],
        );
        assert!(
            inside.success(),
            "writing within fs_write scope must succeed"
        );
        assert!(
            allowed.join("ok.txt").exists(),
            "the in-scope file must exist"
        );

        let outside = run_wrapped(
            &cav,
            "/usr/bin/touch",
            &[forbidden.join("escape.txt").to_str().unwrap()],
        );
        assert!(
            !outside.success(),
            "the kernel must deny a write outside fs_write scope"
        );
        assert!(
            !forbidden.join("escape.txt").exists(),
            "the out-of-scope file must NOT have been created"
        );

        let _ = fs::remove_dir_all(&allowed);
        let _ = fs::remove_dir_all(&forbidden);
    }

    #[test]
    fn empty_fs_write_scope_denies_all_writes() {
        if skip_proof_unless_seatbelt() {
            return;
        }
        let dir = unique_dir("w-none");
        let cav = Caveats {
            fs_write: Scope::none(),
            ..Caveats::top()
        };
        let target = dir.join("x.txt");
        let prefix = SeatbeltSandbox::new().command_prefix(&cav).expect("prefix");
        let out = std::process::Command::new(&prefix[0])
            .args(&prefix[1..])
            .arg("/usr/bin/touch")
            .arg(&target)
            .output()
            .expect("spawn sandbox-exec");
        assert!(!out.status.success(), "empty fs_write must deny all writes");
        // Positive control: the failure is the *kernel* denying the write (EPERM),
        // not a spurious touch error — so this assertion cannot pass vacuously.
        let stderr = String::from_utf8_lossy(&out.stderr);
        assert!(
            stderr.contains("Operation not permitted"),
            "denial must be a sandbox EPERM, got: {stderr:?}"
        );
        assert!(!target.exists());
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn fs_read_is_kernel_enforced_outside_scope_denied_inside_allowed() {
        if skip_proof_unless_seatbelt() {
            return;
        }
        let allowed = unique_dir("r-allowed");
        let forbidden = unique_dir("r-forbidden");
        fs::write(allowed.join("ok.txt"), b"in-scope").unwrap();
        fs::write(forbidden.join("secret.txt"), b"out-of-scope").unwrap();
        let cav = Caveats {
            fs_read: Scope::only([allowed.to_string_lossy().into_owned()]),
            ..Caveats::top()
        };

        // A real dynamically-linked binary (`cat`) must still load (the base
        // allow-list covers dyld) and read the in-scope file …
        let inside = run_wrapped(
            &cav,
            "/bin/cat",
            &[allowed.join("ok.txt").to_str().unwrap()],
        );
        assert!(
            inside.success(),
            "in-scope cat must load and read under read-confinement"
        );
        // … but be denied the out-of-scope one.
        let outside = run_wrapped(
            &cav,
            "/bin/cat",
            &[forbidden.join("secret.txt").to_str().unwrap()],
        );
        assert!(
            !outside.success(),
            "reading outside fs_read scope must be kernel-denied"
        );

        let _ = fs::remove_dir_all(&allowed);
        let _ = fs::remove_dir_all(&forbidden);
    }

    #[test]
    fn net_fully_denied_kernel_blocks_egress() {
        if skip_proof_unless_seatbelt() {
            return;
        }
        let curl = "/usr/bin/curl";
        if !std::path::Path::new(curl).exists() {
            eprintln!("skipping: no curl(1) on this host");
            return;
        }
        let cav = Caveats {
            net: Scope::none(),
            ..Caveats::top()
        };
        // Positive control: a benign NON-network command under the SAME net:none
        // profile must succeed — proving the profile parsed and only egress is
        // denied. Without this, a malformed `(deny network*)` (sandbox-exec exit
        // 65, child never launches) would let the denial assertion pass vacuously.
        let benign = run_wrapped(&cav, "/bin/echo", &["ok"]);
        assert!(
            benign.success(),
            "net:none must still allow non-network commands (profile must parse)"
        );
        // Egress denied: curl to a literal IP (no DNS) exits **7** ("couldn't
        // connect") because the socket is kernel-denied immediately. Asserting
        // exactly 7 — not merely non-zero — rules out the vacuous passes: a
        // no-egress host times out (28), a broken profile never launches the child
        // (65). `--max-time` bounds it regardless.
        let confined = run_wrapped(&cav, curl, &["-sS", "--max-time", "5", "http://1.1.1.1/"]);
        assert_eq!(
            confined.code(),
            Some(7),
            "egress under net:none must be kernel-denied at the socket (curl exit 7)"
        );
    }

    /// A one-shot loopback listener answering a single HTTP request, so an ALLOW
    /// assertion tests a *reachable* socket (curl 0) — not "connection refused"
    /// (also 7). Detached, so an unexpected deny can't hang the test on a
    /// never-accepted connection. Returns the bound `SocketAddr`, or `None` if the
    /// family is unavailable on this host (e.g. no `::1`), so a caller can skip.
    fn spawn_loopback_http(bind: &str) -> Option<std::net::SocketAddr> {
        let listener = std::net::TcpListener::bind(bind).ok()?;
        let addr = listener.local_addr().ok()?;
        std::thread::spawn(move || {
            if let Ok((mut sock, _)) = listener.accept() {
                use std::io::{Read, Write};
                let mut buf = [0u8; 1024];
                let _ = sock.read(&mut buf);
                let _ = sock.write_all(b"HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok");
            }
        });
        Some(addr)
    }

    /// A loopback-only `net` grant kernel-confines egress to the loopback
    /// *interface* (ADR 0015): the process reaches loopback (v4 **and** v6, since
    /// SBPL's `localhost` denotes both) and is kernel-DENIED any off-box host. The
    /// grant here names a **single** v4 address (`127.0.0.1`) yet `::1` is still
    /// reachable — the documented interface-granular widening (D2): a spawned child
    /// is governed only by the kernel rule, not the exact-host admission leash.
    #[test]
    fn net_loopback_only_permits_loopback_interface_denies_offbox() {
        if skip_proof_unless_seatbelt() {
            return;
        }
        let curl = "/usr/bin/curl";
        if !std::path::Path::new(curl).exists() {
            eprintln!("skipping: no curl(1) on this host");
            return;
        }
        let v4 = spawn_loopback_http("127.0.0.1:0").expect("bind v4 loopback");

        // A single v4 loopback address — the case that widens to the interface.
        let cav = Caveats {
            net: Scope::only(["127.0.0.1".to_string()]),
            ..Caveats::top()
        };
        // Positive control: a benign non-network command runs — the loopback
        // profile parsed (a malformed one exits 65 and never launches the child).
        assert!(
            run_wrapped(&cav, "/bin/echo", &["ok"]).success(),
            "loopback-only profile must still run non-network commands (must parse)"
        );
        // ALLOW (v4): egress to the loopback listener succeeds (curl exit 0). A
        // deny-all or malformed rule would fail this — so it cannot pass vacuously.
        let v4_url = format!("http://127.0.0.1:{}/", v4.port());
        assert!(
            run_wrapped(&cav, curl, &["-sS", "--max-time", "5", &v4_url]).success(),
            "net:Only([127.0.0.1]) must kernel-PERMIT v4 loopback egress"
        );
        // ALLOW (v6): `::1` is reachable too — locking the interface-granular
        // widening documented in ADR 0015 D2 (kernel `localhost` = 127.0.0.1 + ::1,
        // broader than the single-address grant). Skipped only if v6 loopback is
        // unavailable on the host (never on stock macOS).
        if let Some(v6) = spawn_loopback_http("[::1]:0") {
            let v6_url = format!("http://[::1]:{}/", v6.port());
            assert!(
                run_wrapped(&cav, curl, &["-sS", "--max-time", "5", &v6_url]).success(),
                "net:Only([127.0.0.1]) kernel-permits the whole loopback interface, incl. ::1 (ADR 0015 D2)"
            );
        }
        // DENY: off-box egress to a literal IP (no DNS) is kernel-denied at the
        // socket. Assert both curl exit 7 AND the EPERM signal ("Operation not
        // permitted") in stderr — so a no-internet runner (ENETUNREACH, also exit
        // 7) cannot make this pass vacuously; it must be a *permission* denial.
        let offbox = run_wrapped_output(
            &cav,
            curl,
            &["-sS", "-v", "--max-time", "5", "http://1.1.1.1/"],
        );
        assert_eq!(
            offbox.status.code(),
            Some(7),
            "net:Only([127.0.0.1]) must kernel-DENY off-box egress (curl exit 7)"
        );
        let stderr = String::from_utf8_lossy(&offbox.stderr);
        assert!(
            stderr.contains("Operation not permitted"),
            "off-box denial must be a kernel EPERM, not a routing failure: {stderr}"
        );
    }

    /// Like [`run_wrapped`] but captures stdout/stderr, so a proof can assert on
    /// the *interior* exec behavior (a granted program's child exec statuses) the
    /// kernel produced — the L3-grain the `exec` axis claims.
    fn run_wrapped_output(cav: &Caveats, program: &str, args: &[&str]) -> std::process::Output {
        let prefix = SeatbeltSandbox::new()
            .command_prefix(cav)
            .expect("a restricted axis must yield a wrapper prefix");
        assert!(!prefix.is_empty(), "expected a sandbox-exec wrapper");
        std::process::Command::new(&prefix[0])
            .args(&prefix[1..])
            .arg(program)
            .args(args)
            .output()
            .expect("spawn sandbox-exec")
    }

    /// The exec allow-list is kernel-enforced at the **interior**: a granted shell
    /// runs, may exec a *listed* binary, but is kernel-denied an *unlisted* one —
    /// the L3 gap a path allow-list alone cannot reach (ADR 0014). The discriminator
    /// is exact: the unlisted `/usr/bin/false` must fail at **exec** (status 127),
    /// not run-and-return-1 — so this cannot pass vacuously.
    #[test]
    fn exec_allowlist_permits_listed_denies_unlisted_child() {
        if skip_proof_unless_seatbelt() {
            return;
        }
        let cav = Caveats {
            exec: Scope::only(["/bin/zsh".to_string(), "/usr/bin/true".to_string()]),
            ..Caveats::top()
        };
        let out = run_wrapped_output(
            &cav,
            "/bin/zsh",
            &["-c", "/usr/bin/true; echo T=$?; /usr/bin/false; echo F=$?"],
        );
        let stdout = String::from_utf8_lossy(&out.stdout);
        assert!(
            stdout.contains("T=0"),
            "a listed binary must exec and run (T=0): {stdout:?}"
        );
        assert!(
            stdout.contains("F=127"),
            "an unlisted binary must be kernel-denied at EXEC (status 127), not run: {stdout:?}"
        );
    }

    /// The `exec:none`-style floor: when the granted set is just the entry shell,
    /// the shell launches but may exec **nothing** further — every child exec is
    /// kernel-denied. This is the interior "no further exec" guarantee.
    #[test]
    fn granted_shell_cannot_exec_any_unlisted_child() {
        if skip_proof_unless_seatbelt() {
            return;
        }
        let cav = Caveats {
            exec: Scope::only(["/bin/zsh".to_string()]),
            ..Caveats::top()
        };
        let out = run_wrapped_output(&cav, "/bin/zsh", &["-c", "/usr/bin/true; echo S=$?"]);
        let stdout = String::from_utf8_lossy(&out.stdout);
        assert!(
            stdout.contains("S=127"),
            "a shell granted only itself must be denied every child exec (S=127): {stdout:?}"
        );
    }

    /// The ADR 0011 loader trampoline — the bypass that has **no Landlock hook**
    /// and forces the Linux seccomp backstop — is *closed by the platform* on
    /// macOS. A granted interpreter (`perl`) cannot reach an unlisted binary by:
    /// (a) directly `exec`ing it, nor (b) trampolining through `dyld`. Both are
    /// governed `process-exec`s; `dyld` is not allow-listed, so both are denied.
    #[test]
    fn granted_interpreter_cannot_trampoline_to_unlisted_binary() {
        if skip_proof_unless_seatbelt() {
            return;
        }
        let cav = Caveats {
            exec: Scope::only(["/usr/bin/perl".to_string()]),
            ..Caveats::top()
        };
        // Each `exec` returns (and perl continues) only when the exec was DENIED.
        let script = "print \"PERL-RAN\\n\"; \
                      exec(\"/usr/bin/true\"); print \"DIRECT-DENIED\\n\"; \
                      exec(\"/usr/lib/dyld\", \"/usr/bin/true\"); print \"TRAMPOLINE-DENIED\\n\";";
        let out = run_wrapped_output(&cav, "/usr/bin/perl", &["-e", script]);
        let stdout = String::from_utf8_lossy(&out.stdout);
        assert!(
            stdout.contains("PERL-RAN"),
            "the granted interpreter must run: {stdout:?}"
        );
        assert!(
            stdout.contains("DIRECT-DENIED"),
            "direct exec of an unlisted binary must be denied: {stdout:?}"
        );
        assert!(
            stdout.contains("TRAMPOLINE-DENIED"),
            "the dyld loader trampoline must be denied (no standing loader entry): {stdout:?}"
        );
    }

    /// Positive control / no deny-of-function: an allow-listed **dynamically
    /// linked** binary still loads its dylibs (via the kernel-trusted dyld path,
    /// which the exec allow-list does not gate) and runs normally under exec
    /// confinement — proving the axis confines *spawning*, not legitimate linking.
    #[test]
    fn exec_confinement_does_not_break_dynamic_linking() {
        if skip_proof_unless_seatbelt() {
            return;
        }
        let curl = "/usr/bin/curl";
        if !std::path::Path::new(curl).exists() {
            eprintln!("skipping: no curl(1) on this host");
            return;
        }
        let cav = Caveats {
            exec: Scope::only([curl.to_string()]),
            ..Caveats::top()
        };
        let status = run_wrapped(&cav, curl, &["--version"]);
        assert!(
            status.success(),
            "an allow-listed dynamic binary must load + run under exec confinement"
        );
    }
}