agent-bridle-core 0.7.14

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
//! Spawn an **arbitrary** child process confined by a [`ToolContext`]'s caveats.
//!
//! The in-process leash (L2) gates operations the bridle process can observe.
//! But a host often needs to launch a separate program — an MCP capability
//! server, a language runtime — and put *its own syscalls* under the leash. L2
//! cannot follow a child across a process boundary; that requires an available
//! native L3 backend ([`crate::sandbox`]).
//!
//! [`ConfinedCommand`] is that primitive. It is deliberately *not* a confused
//! deputy: the parent attenuates **before** the spawn (the child is never trusted
//! to confine itself), the environment is **cleared** so nothing ambient leaks
//! (only explicitly-granted vars reach the child — the external-boundary
//! invariant), and exec is admission-checked against the granted `exec` scope.
//!
//! Mechanism (mirrors [`crate::sandbox`]'s contract): thread-confining backends
//! such as Landlock are applied on a fresh throwaway thread immediately before
//! spawn; wrapper backends such as Seatbelt and AppContainer prefix the child
//! launch. In either case the confined child and its descendants inherit the
//! active OS boundary.
//!
//! Honesty & fail-closed: the achieved [`SandboxKind`] is returned on the
//! [`ConfinedChild`]. A restricted filesystem axis that no active backend can
//! kernel-enforce is **refused** rather than launched unconfined. Restricted
//! `exec` and `net` axes are checked against the principal's requested strength
//! floor because their kernel coverage differs by backend and scope. The
//! per-axis enforcement report is the authoritative statement of what held.

use std::ffi::{OsStr, OsString};
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};

use std::sync::Arc;
use std::time::Duration;

use crate::{
    best_available_sandbox, effective_sandbox_kind, enforcement_report, fence_strength,
    AxisEnforcement, Caveats, SandboxKind, SandboxPolicy, ToolContext, ToolError, ToolResult,
};
use agent_mesh_protocol::Fingerprint;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
// Used only by the test modules below (each `use super::*`); kept here so all
// three (`tests`, `landlock_child_tests`, `seatbelt_child_tests`) see it without
// an unused-import warning in the non-test build.
#[cfg(test)]
use crate::Scope;

/// A spawned child together with the OS sandbox actually in force around it.
///
/// The caller owns `child` (it does its own `wait`/`kill`/pipe plumbing).
/// `sandbox_kind` is the honest record of what confinement was achieved —
/// [`SandboxKind::None`] means the leash on this child is advisory only.
#[derive(Debug)]
pub struct ConfinedChild {
    /// The spawned process.
    pub child: Child,
    /// The OS-level sandbox actually applied to the child.
    pub sandbox_kind: SandboxKind,
}

/// A fixed internal worker together with its take-once parent control channel.
///
/// Unlike an ordinary [`ConfinedChild`], a trusted worker is launched with a
/// kernel object that model-selected commands do not receive. The worker
/// validates that channel and its peer before accepting any authority-bearing
/// request. The channel is private by default and can be taken only once by the
/// trusted supervisor.
#[derive(Debug)]
pub struct SandboxedWorkerChild {
    /// The spawned worker process.
    pub child: Child,
    /// The OS-level sandbox actually applied to the worker.
    pub sandbox_kind: SandboxKind,
    control: Option<TrustedWorkerControl>,
}

impl SandboxedWorkerChild {
    /// Authenticate the fixed worker and send one authority-bearing request.
    ///
    /// Core—not the caller—serializes the effective caveats, strength floor,
    /// and launch nonce captured by [`SandboxedWorker::spawn`]. `payload`
    /// contains only tool-specific, non-authority fields. The control endpoint
    /// is consumed and closed after this frame, so a launch can authorize at
    /// most one request.
    pub fn send_payload<T: Serialize>(&mut self, payload: &T, timeout: Duration) -> ToolResult<()> {
        let mut control = self
            .control
            .take()
            .ok_or_else(|| ToolError::denied("trusted worker request was already sent"))?;
        control.send(payload, self.child.id(), timeout)
    }
}

/// The supervisor-owned end of a trusted worker's private control channel.
///
/// The stream and authority are intentionally private. Callers can only send a
/// non-authority payload through [`SandboxedWorkerChild::send_payload`].
#[derive(Debug)]
struct TrustedWorkerControl {
    #[cfg(any(target_os = "linux", target_os = "macos"))]
    stream: std::os::unix::net::UnixStream,
    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
    unavailable: (),
    nonce: String,
    caveats: Caveats,
    strength_floor: AxisEnforcement,
}

impl TrustedWorkerControl {
    #[cfg(any(target_os = "linux", target_os = "macos"))]
    fn send<T: Serialize>(
        &mut self,
        payload: &T,
        child_pid: u32,
        timeout: Duration,
    ) -> ToolResult<()> {
        self.stream
            .set_read_timeout(Some(timeout))
            .map_err(ToolError::from)?;
        self.stream
            .set_write_timeout(Some(timeout))
            .map_err(ToolError::from)?;

        // Establish kernel peer metadata before the worker snapshots its
        // parent. On macOS LOCAL_PEERTOKEN is populated only after a write
        // from that peer; this fixed prelude carries no authority.
        self.stream
            .write_all(&TRUSTED_WORKER_BOOTSTRAP)
            .and_then(|()| self.stream.flush())
            .map_err(ToolError::from)?;
        let mut hello = [0_u8; TRUSTED_WORKER_HELLO_LEN];
        self.stream
            .read_exact(&mut hello)
            .map_err(ToolError::from)?;
        let (reported_pid, challenge) = decode_trusted_worker_hello(&hello)
            .map_err(|error| ToolError::denied(format!("invalid worker hello: {error}")))?;
        if reported_pid != child_pid {
            return Err(ToolError::denied(format!(
                "worker hello PID mismatch: spawned {child_pid}, reported {reported_pid}"
            )));
        }

        let request = TrustedWorkerRequest {
            version: TRUSTED_WORKER_PROTOCOL_VERSION,
            nonce: self.nonce.clone(),
            caveats: self.caveats.clone(),
            strength_floor: self.strength_floor,
            payload,
        };
        let body = serde_json::to_vec(&request)
            .map_err(|error| ToolError::denied(format!("encode worker request: {error}")))?;
        if body.len() > TRUSTED_WORKER_MAX_BODY {
            return Err(ToolError::denied(
                "trusted worker request exceeds its 1 MiB cap",
            ));
        }
        let header = encode_trusted_worker_frame_header(
            challenge,
            trusted_worker_frame_digest(&challenge, &body),
            body.len(),
        )
        .map_err(ToolError::denied)?;
        self.stream.write_all(&header).map_err(ToolError::from)?;
        self.stream.write_all(&body).map_err(ToolError::from)?;
        self.stream.flush().map_err(ToolError::from)?;
        let mut ack = [0_u8; TRUSTED_WORKER_ACK.len()];
        self.stream.read_exact(&mut ack).map_err(ToolError::from)?;
        if ack != TRUSTED_WORKER_ACK {
            return Err(ToolError::denied(
                "trusted worker returned an invalid authentication ACK",
            ));
        }
        // The control object is consumed immediately after this method. The
        // worker may close its endpoint as soon as it writes the ACK, so a
        // racing ENOTCONN here is not an authentication failure.
        let _ = self.stream.shutdown(std::net::Shutdown::Write);
        Ok(())
    }

    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
    fn send<T: Serialize>(
        &mut self,
        payload: &T,
        child_pid: u32,
        timeout: Duration,
    ) -> ToolResult<()> {
        let _ = (
            &self.unavailable,
            &self.nonce,
            &self.caveats,
            self.strength_floor,
            payload,
            child_pid,
            timeout,
        );
        Err(ToolError::denied(
            "trusted worker control channels are unavailable on this platform",
        ))
    }
}

/// Version of the private trusted-worker authority envelope.
pub const TRUSTED_WORKER_PROTOCOL_VERSION: u8 = 1;
/// Maximum serialized trusted-worker request body.
pub const TRUSTED_WORKER_MAX_BODY: usize = 1024 * 1024;
/// Fixed, non-authority prelude used to establish kernel peer metadata.
pub const TRUSTED_WORKER_BOOTSTRAP: [u8; 8] = *b"ABTW-B1\0";
/// Fixed acknowledgement emitted only after a worker authenticates its frame.
pub const TRUSTED_WORKER_ACK: [u8; 8] = *b"ABTW-A1\0";
const TRUSTED_WORKER_HELLO_MAGIC: [u8; 8] = *b"ABTW-H1\0";
const TRUSTED_WORKER_FRAME_MAGIC: [u8; 8] = *b"ABTW-R1\0";
/// Exact byte length of a trusted-worker hello frame.
pub const TRUSTED_WORKER_HELLO_LEN: usize = 8 + 4 + 32;
/// Exact byte length of a trusted-worker response header.
pub const TRUSTED_WORKER_FRAME_HEADER_LEN: usize = 8 + 4 + 32 + 32;
const TRUSTED_WORKER_DIGEST_DOMAIN: &[u8] = b"agent-bridle/trusted-worker-frame/v1";

/// Core-owned authority envelope received by a fixed trusted worker.
///
/// `P` is tool-specific data only. Authority fields are captured from the
/// supervisor's minted [`ToolContext`] and cannot be supplied through
/// [`SandboxedWorkerChild::send_payload`].
#[derive(Debug, Serialize, Deserialize)]
pub struct TrustedWorkerRequest<P> {
    version: u8,
    nonce: String,
    caveats: Caveats,
    strength_floor: AxisEnforcement,
    payload: P,
}

impl<P> TrustedWorkerRequest<P> {
    /// Consume the envelope into its core-authenticated authority and payload.
    #[must_use]
    pub fn into_parts(self) -> (String, Caveats, AxisEnforcement, P) {
        (self.nonce, self.caveats, self.strength_floor, self.payload)
    }

    /// Whether the envelope uses the protocol version understood by this core.
    #[must_use]
    pub fn has_supported_version(&self) -> bool {
        self.version == TRUSTED_WORKER_PROTOCOL_VERSION
    }
}

/// Encode the child-to-supervisor hello that carries a fresh challenge.
#[must_use]
pub fn encode_trusted_worker_hello(child_pid: u32, challenge: [u8; 32]) -> [u8; 44] {
    let mut frame = [0_u8; TRUSTED_WORKER_HELLO_LEN];
    frame[..8].copy_from_slice(&TRUSTED_WORKER_HELLO_MAGIC);
    frame[8..12].copy_from_slice(&child_pid.to_le_bytes());
    frame[12..].copy_from_slice(&challenge);
    frame
}

/// Decode and validate a child-to-supervisor hello.
pub fn decode_trusted_worker_hello(frame: &[u8]) -> Result<(u32, [u8; 32]), String> {
    if frame.len() != TRUSTED_WORKER_HELLO_LEN || frame[..8] != TRUSTED_WORKER_HELLO_MAGIC {
        return Err("bad trusted-worker hello framing".to_string());
    }
    let pid = u32::from_le_bytes(
        frame[8..12]
            .try_into()
            .map_err(|_| "bad trusted-worker PID field")?,
    );
    let challenge = frame[12..]
        .try_into()
        .map_err(|_| "bad trusted-worker challenge field")?;
    Ok((pid, challenge))
}

/// Encode a supervisor-to-worker header binding challenge, body length, and
/// content digest.
pub fn encode_trusted_worker_frame_header(
    challenge: [u8; 32],
    digest: [u8; 32],
    body_len: usize,
) -> Result<[u8; TRUSTED_WORKER_FRAME_HEADER_LEN], String> {
    let body_len = u32::try_from(body_len)
        .map_err(|_| "trusted-worker request length does not fit its frame".to_string())?;
    let mut frame = [0_u8; TRUSTED_WORKER_FRAME_HEADER_LEN];
    frame[..8].copy_from_slice(&TRUSTED_WORKER_FRAME_MAGIC);
    frame[8..12].copy_from_slice(&body_len.to_le_bytes());
    frame[12..44].copy_from_slice(&challenge);
    frame[44..].copy_from_slice(&digest);
    Ok(frame)
}

/// Decode a supervisor-to-worker frame header.
pub fn decode_trusted_worker_frame_header(
    frame: &[u8],
) -> Result<(usize, [u8; 32], [u8; 32]), String> {
    if frame.len() != TRUSTED_WORKER_FRAME_HEADER_LEN || frame[..8] != TRUSTED_WORKER_FRAME_MAGIC {
        return Err("bad trusted-worker response framing".to_string());
    }
    let body_len = u32::from_le_bytes(
        frame[8..12]
            .try_into()
            .map_err(|_| "bad trusted-worker length field")?,
    ) as usize;
    if body_len > TRUSTED_WORKER_MAX_BODY {
        return Err("trusted-worker request exceeds its 1 MiB cap".to_string());
    }
    let challenge = frame[12..44]
        .try_into()
        .map_err(|_| "bad trusted-worker challenge field")?;
    let digest = frame[44..]
        .try_into()
        .map_err(|_| "bad trusted-worker digest field")?;
    Ok((body_len, challenge, digest))
}

/// Content digest binding one trusted-worker request to its fresh challenge.
#[must_use]
pub fn trusted_worker_frame_digest(challenge: &[u8; 32], body: &[u8]) -> [u8; 32] {
    let mut framed =
        Vec::with_capacity(TRUSTED_WORKER_DIGEST_DOMAIN.len() + challenge.len() + body.len());
    framed.extend_from_slice(TRUSTED_WORKER_DIGEST_DOMAIN);
    framed.extend_from_slice(challenge);
    framed.extend_from_slice(body);
    Fingerprint::of_bytes(&framed).0
}

/// Deserialize a verified trusted-worker request body.
pub fn decode_trusted_worker_request<P: DeserializeOwned>(
    body: &[u8],
) -> Result<TrustedWorkerRequest<P>, String> {
    serde_json::from_slice(body).map_err(|error| format!("invalid trusted-worker request: {error}"))
}

/// Builder for a subprocess confined by a [`ToolContext`].
///
/// Like [`std::process::Command`], but: the environment starts **empty** (only
/// vars added with [`ConfinedCommand::env`] reach the child), and
/// [`ConfinedCommand::spawn`] admission-checks `exec`, applies the OS sandbox,
/// and fails closed when a restricted axis cannot meet its required
/// enforcement floor.
#[derive(Debug)]
pub struct ConfinedCommand {
    program: String,
    args: Vec<OsString>,
    envs: Vec<(OsString, OsString)>,
    cwd: Option<PathBuf>,
    stdin: Option<Stdio>,
    stdout: Option<Stdio>,
    stderr: Option<Stdio>,
    /// Put the child in a fresh process group so a supervising caller can
    /// terminate the complete descendant tree at a timeout boundary.
    new_process_group: bool,
    /// The sandbox mechanism config (read/exec allow-lists). Rides the builder —
    /// NOT the `ToolContext`, which carries only authority (I5-B, #144, ADR 0017
    /// D2). Defaults to today's built-in allow-lists.
    sandbox_policy: Arc<SandboxPolicy>,
}

impl ConfinedCommand {
    /// Start building a confined spawn of `program` (no inherited environment).
    pub fn new(program: impl Into<String>) -> Self {
        Self {
            program: program.into(),
            args: Vec::new(),
            envs: Vec::new(),
            cwd: None,
            stdin: None,
            stdout: None,
            stderr: None,
            new_process_group: false,
            sandbox_policy: Arc::new(SandboxPolicy::default()),
        }
    }

    /// Set the sandbox mechanism policy (read/exec allow-lists, ABI floors) the
    /// OS backend will enforce. The default is today's built-in allow-lists.
    #[must_use]
    pub fn sandbox_policy(mut self, policy: Arc<SandboxPolicy>) -> Self {
        self.sandbox_policy = policy;
        self
    }

    /// Append a single argument.
    #[must_use]
    pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
        self.args.push(arg.as_ref().to_os_string());
        self
    }

    /// Append several arguments.
    #[must_use]
    pub fn args<I, S>(mut self, args: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: AsRef<OsStr>,
    {
        self.args
            .extend(args.into_iter().map(|a| a.as_ref().to_os_string()));
        self
    }

    /// Grant one environment variable to the child. This is the **only** way an
    /// env var reaches the child — there is no ambient inheritance.
    #[must_use]
    pub fn env(mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> Self {
        self.envs
            .push((key.as_ref().to_os_string(), val.as_ref().to_os_string()));
        self
    }

    /// Set the child's working directory.
    #[must_use]
    pub fn current_dir(mut self, dir: impl AsRef<Path>) -> Self {
        self.cwd = Some(dir.as_ref().to_path_buf());
        self
    }

    /// Configure the child's stdin (e.g. [`Stdio::piped`] for an MCP server).
    #[must_use]
    pub fn stdin(mut self, cfg: Stdio) -> Self {
        self.stdin = Some(cfg);
        self
    }

    /// Configure the child's stdout.
    #[must_use]
    pub fn stdout(mut self, cfg: Stdio) -> Self {
        self.stdout = Some(cfg);
        self
    }

    /// Configure the child's stderr.
    #[must_use]
    pub fn stderr(mut self, cfg: Stdio) -> Self {
        self.stderr = Some(cfg);
        self
    }

    /// Start the child as leader of a fresh process group.
    ///
    /// This is used by trusted worker supervisors that must terminate the
    /// worker and every descendant together. It is currently effective on
    /// Unix; other platforms retain their native child-process behavior.
    #[must_use]
    pub fn new_process_group(mut self) -> Self {
        self.new_process_group = true;
        self
    }

    /// Admission-check, confine, and spawn the child.
    ///
    /// Order: (1) `cx.check_exec(program)` — deny before doing anything; (2)
    /// derive the backend's per-axis enforcement and refuse if a restricted
    /// axis cannot meet its floor; (3) apply the selected thread- or
    /// wrapper-based sandbox, then spawn inside that boundary.
    pub fn spawn(self, cx: &ToolContext) -> ToolResult<ConfinedChild> {
        let effective = cx.caveats().clone();
        self.spawn_with_effective(cx, effective)
    }

    /// The spawn body, parameterized over the **effective** caveats the OS
    /// sandbox confines to (#257). `spawn` passes the context's caveats
    /// verbatim; the egress-proxy path (`spawn_tokio` under a proxied-net
    /// grant) passes [`crate::loopback_fenced_caveats`] — same fs/exec axes,
    /// `net` swapped for the loopback fence. The exec admission-check always
    /// runs against the REAL context (the fence never widens or narrows exec).
    fn spawn_with_effective(
        self,
        cx: &ToolContext,
        effective: Caveats,
    ) -> ToolResult<ConfinedChild> {
        self.spawn_authorized(cx, effective, SpawnAuthority::ModelSelected)
    }

    /// Shared spawn funnel for model-selected programs and fixed internal
    /// workers. The latter skips only the model-facing executable admission
    /// check; its executable and entrypoint are fixed by [`SandboxedWorker`].
    fn spawn_authorized(
        self,
        cx: &ToolContext,
        effective: Caveats,
        authority: SpawnAuthority,
    ) -> ToolResult<ConfinedChild> {
        // (1) Admission: model-selected programs must be in the exec grant.
        // A trusted worker transition is not model-selected; its fixed program
        // is added only to the mechanism policy below.
        if authority == SpawnAuthority::ModelSelected {
            cx.check_exec(&self.program)?;
        }

        let sandbox = best_available_sandbox(&self.sandbox_policy);
        let kind = sandbox.kind();
        // The kind that actually GOVERNS this spawn: the backend's kind only when
        // it will actually confine something (fs or net restricted), else `None`.
        // The fail-closed
        // check is decided against THIS, not the raw probe, so the check and the
        // routing cannot disagree (the adversarial-review fix: a raw
        // `enforcement_report` claim of fs→Kernel for a backend that is not
        // actually applied would otherwise pass a run the path executes
        // unconfined). Also the honest kind reported on the child (I9 / ADR 0006 D3).
        let reported_kind = effective_sandbox_kind(kind, &effective);

        // (2) Fail closed: a restricted axis the governing backend cannot enforce
        // at the principal's strength floor is a grant we'd be lying about.
        if confinement_unenforceable(reported_kind, &effective, cx.strength_floor()) {
            return Err(ToolError::denied(format!(
                "refusing to spawn {:?}: a restricted filesystem/exec/net axis cannot be \
                 enforced on a subprocess at the required strength floor ({:?}) by the \
                 governing sandbox ({:?})",
                self.program,
                cx.strength_floor(),
                reported_kind
            )));
        }

        // For a wrapper-based backend (Seatbelt/AppContainer) this is the argv
        // prefix that confines the child; empty for thread-confining backends
        // (Landlock, via `apply`) and Noop. Computed here so a fail-closed wrapper
        // error aborts *before* we spawn the thread.
        // A fixed worker executable is an internal transition, not authority
        // delegated to the model. Allowlist-based kernel exec policies need its
        // exact path to launch it; AppContainer must instead preserve exec
        // deny-all so its launcher applies the child-process block. Neither
        // changes the reported/effective authority.
        let mechanism_effective = if authority == SpawnAuthority::TrustedWorker {
            trusted_worker_mechanism_caveats(kind, &effective, &self.program)?
        } else {
            effective.clone()
        };
        let prefix = sandbox.command_prefix(&mechanism_effective)?;

        // (3) Apply the sandbox on a throwaway thread, then spawn on it so the
        //     child inherits the OS confinement — the per-thread, fork/exec-
        //     inherited Landlock domain or the selected process wrapper.
        let Self {
            program,
            args,
            envs,
            cwd,
            stdin,
            stdout,
            stderr,
            new_process_group,
            // Already consumed above into `sandbox` via `best_available_sandbox`.
            sandbox_policy: _,
        } = self;

        let spawned = std::thread::spawn(move || -> ToolResult<Child> {
            // Thread-confining backends (Landlock): apply the sandbox on this
            // throwaway thread before the spawn so the child inherits the
            // Landlock domain. `apply` is fail-closed: if the kernel did not
            // actually enforce, it returns Err and we never spawn.
            //
            // Wrapper-based backends (Seatbelt, AppContainer): confinement is
            // achieved by the `command_prefix` wrapper — no per-thread state is
            // involved, and calling `apply` would be wrong (AppContainer fails
            // closed; Seatbelt is a no-op). Skip `apply` when the prefix is
            // non-empty.
            if prefix.is_empty() {
                sandbox.apply(&mechanism_effective)?;
            }

            // Wrap the child in the backend's command prefix when it confines via
            // a wrapper (Seatbelt, AppContainer); otherwise spawn the program directly.
            let (spawn_program, spawn_args) = wrap_argv(&prefix, &program, &args);
            let mut cmd = Command::new(&spawn_program);
            cmd.args(&spawn_args);
            cmd.env_clear(); // no ambient environment crosses the boundary …
            for (k, v) in &envs {
                cmd.env(k, v); // … only the explicitly-granted vars.
            }
            if let Some(dir) = &cwd {
                cmd.current_dir(dir);
            }
            if let Some(cfg) = stdin {
                cmd.stdin(cfg);
            }
            if let Some(cfg) = stdout {
                cmd.stdout(cfg);
            }
            if let Some(cfg) = stderr {
                cmd.stderr(cfg);
            }
            #[cfg(unix)]
            if new_process_group {
                use std::os::unix::process::CommandExt;
                cmd.process_group(0);
            }
            #[cfg(not(unix))]
            let _ = new_process_group;
            cmd.spawn().map_err(ToolError::from)
        })
        .join()
        .map_err(|_| ToolError::denied("confined-spawn thread panicked before exec"))?;

        Ok(ConfinedChild {
            child: spawned?,
            sandbox_kind: reported_kind,
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpawnAuthority {
    ModelSelected,
    TrustedWorker,
}

/// A closed set of internal worker entrypoints. The caller cannot supply
/// arbitrary arguments: each kind maps to a fixed private protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustedWorkerKind {
    /// The carried Brush shell worker.
    Brush,
}

impl TrustedWorkerKind {
    #[cfg(any(target_os = "linux", target_os = "macos"))]
    fn args(self) -> [&'static str; 2] {
        match self {
            Self::Brush => ["--agent-bridle-worker", "brush"],
        }
    }
}

/// Builder for a fixed Agent Bridle worker born under the ordinary confinement
/// funnel. Unlike [`ConfinedCommand`], the executable is mechanism
/// configuration chosen by the trusted embedder and the entrypoint arguments
/// are fixed by [`TrustedWorkerKind`]; model-authored arguments never reach the
/// spawn boundary.
#[derive(Debug, Clone)]
pub struct SandboxedWorker {
    #[cfg(any(target_os = "linux", target_os = "macos"))]
    kind: TrustedWorkerKind,
    sandbox_policy: Arc<SandboxPolicy>,
}

impl SandboxedWorker {
    /// Configure the carried Brush worker at this process's fixed executable.
    ///
    /// The executable is intentionally not caller-selectable: trusted-worker
    /// admission bypasses the model-facing exec check, so accepting an arbitrary
    /// path here would turn the worker API into a generic confused deputy.
    #[must_use]
    pub fn brush() -> Self {
        Self {
            #[cfg(any(target_os = "linux", target_os = "macos"))]
            kind: TrustedWorkerKind::Brush,
            sandbox_policy: Arc::new(SandboxPolicy::default()),
        }
    }

    /// Set the sandbox mechanism policy used by the shared spawn funnel.
    #[must_use]
    pub fn sandbox_policy(mut self, policy: Arc<SandboxPolicy>) -> Self {
        self.sandbox_policy = policy;
        self
    }

    /// Spawn the fixed worker with empty ambient environment, piped output, and
    /// a private authenticated-control transport in place of ordinary stdin.
    ///
    /// `nonce` binds the worker request carried over stdin to this launch. The
    /// worker is a fresh process-group leader on Unix so its supervisor can
    /// terminate the complete process tree on timeout. Other targets retain
    /// their native child-process behavior. The process-wide unbridled state is
    /// derived inside core; a caller cannot opt a single worker out of
    /// confinement.
    pub fn spawn(
        self,
        cx: &ToolContext,
        nonce: &str,
        cwd: &Path,
    ) -> ToolResult<SandboxedWorkerChild> {
        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
        {
            let _ = (self, cx, nonce, cwd);
            Err(ToolError::denied(
                "refusing the Brush worker: this platform has no authenticated \
                 private-control transport",
            ))
        }
        #[cfg(any(target_os = "linux", target_os = "macos"))]
        {
            self.spawn_supported(cx, nonce, cwd)
        }
    }

    #[cfg(any(target_os = "linux", target_os = "macos"))]
    fn spawn_supported(
        self,
        cx: &ToolContext,
        nonce: &str,
        cwd: &Path,
    ) -> ToolResult<SandboxedWorkerChild> {
        cx.check_path_read(cwd)?;
        let request_caveats = cx.caveats().clone();
        let request_strength_floor = cx.strength_floor();
        let executable = std::env::current_exe()
            .and_then(std::fs::canonicalize)
            .map_err(|error| ToolError::denied(format!("worker executable is invalid: {error}")))?;
        let executable = executable.to_string_lossy().into_owned();
        let [flag, kind] = self.kind.args();
        #[cfg(any(target_os = "linux", target_os = "macos"))]
        let (control, child_control) = std::os::unix::net::UnixStream::pair().map_err(|error| {
            ToolError::denied(format!("create worker control channel: {error}"))
        })?;

        let command = ConfinedCommand::new(executable)
            .args([flag, kind])
            .env("AGENT_BRIDLE_WORKER_NONCE", nonce)
            .current_dir(cwd)
            .stdin(Stdio::from(std::os::fd::OwnedFd::from(child_control)))
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .new_process_group()
            .sandbox_policy(self.sandbox_policy);

        let confined = if crate::is_unbridled() {
            command.spawn_authorized(cx, Caveats::top(), SpawnAuthority::TrustedWorker)
        } else {
            let effective = cx.caveats().clone();
            let available = best_available_sandbox(&command.sandbox_policy).kind();
            let reported = effective_sandbox_kind(available, &effective);
            #[cfg(not(target_os = "linux"))]
            let _ = reported;
            #[cfg(target_os = "linux")]
            if reported == SandboxKind::Landlock
                && crate::sandbox::restricts_fs(&effective)
                && !linux_user_namespaces_hardened()
            {
                return Err(ToolError::denied(
                    "refusing the Brush worker: Landlock filesystem confinement \
                     is not a complete boundary while unprivileged user namespaces \
                     remain available; disable them or add the namespace syscall backstop",
                ));
            }
            command.spawn_authorized(cx, effective, SpawnAuthority::TrustedWorker)
        }?;
        Ok(SandboxedWorkerChild {
            child: confined.child,
            sandbox_kind: confined.sandbox_kind,
            control: Some(TrustedWorkerControl {
                stream: control,
                nonce: nonce.to_string(),
                caveats: request_caveats,
                strength_floor: request_strength_floor,
            }),
        })
    }
}

/// Build the mechanism-only caveats for a trusted worker transition.
///
/// Landlock, Seatbelt, and the identity-closing stronger tiers need the fixed
/// worker executable in their kernel execute allow-list so the boundary can
/// launch it. AppContainer is different: its launcher creates the worker as the
/// initial confined process, and `exec: Only([])` must remain empty so
/// `--no-child-process` is attached to that worker. Adding the worker path there
/// would silently turn deny-all into a non-empty allow-list, disable the kernel
/// child-process mitigation, and leave an `exec → Kernel` report overclaiming.
///
/// This changes mechanism configuration only; it never alters the effective
/// authority carried by `ToolContext` or the enforcement report.
fn trusted_worker_mechanism_caveats(
    kind: SandboxKind,
    effective: &Caveats,
    program: &str,
) -> ToolResult<Caveats> {
    match kind {
        SandboxKind::Landlock
        | SandboxKind::Seatbelt
        | SandboxKind::MinimalRootfs
        | SandboxKind::MicroVm => caveats_with_trusted_program(effective, program),
        SandboxKind::AppContainer | SandboxKind::None => Ok(effective.clone()),
    }
}

/// Add the exact trusted worker executable to an execute allow-list used by a
/// mechanism that must authorize the initial worker launch.
fn caveats_with_trusted_program(effective: &Caveats, program: &str) -> ToolResult<Caveats> {
    let canonical = Path::new(program)
        .canonicalize()
        .map_err(|error| ToolError::denied(format!("cannot resolve trusted worker: {error}")))?
        .to_string_lossy()
        .into_owned();
    let mut mechanism = effective.clone();
    if let crate::Scope::Only(programs) = &mut mechanism.exec {
        programs.insert(canonical);
    }
    Ok(mechanism)
}

#[cfg(target_os = "linux")]
fn linux_user_namespaces_hardened() -> bool {
    fn sysctl_is(path: &str, expected: &str) -> bool {
        std::fs::read_to_string(path).is_ok_and(|value| value.trim() == expected)
    }

    sysctl_is("/proc/sys/kernel/unprivileged_userns_clone", "0")
        || sysctl_is("/proc/sys/user/max_user_namespaces", "0")
        || sysctl_is(
            "/proc/sys/kernel/apparmor_restrict_unprivileged_userns",
            "1",
        )
}

/// Spawn `program args` confined by `cx`, with the inherited stdio of the parent.
///
/// The convenience form of [`ConfinedCommand`]: `env_allow` is the child's
/// **entire** environment (nothing else is inherited). For piped stdio (an MCP
/// server), use [`ConfinedCommand`] directly.
pub fn spawn_confined_subprocess(
    program: &str,
    args: &[String],
    cx: &ToolContext,
    env_allow: &[(String, String)],
    cwd: Option<&Path>,
) -> ToolResult<ConfinedChild> {
    let mut cmd = ConfinedCommand::new(program).args(args);
    for (k, v) in env_allow {
        cmd = cmd.env(k, v);
    }
    if let Some(dir) = cwd {
        cmd = cmd.current_dir(dir);
    }
    cmd.spawn(cx)
}

// ── Async-host spawn (tokio pipe handles) ────────────────────────────────────
//
// `spawn` above returns a `std::process::Child` — the caller owns the pipe
// plumbing. An async host (an MCP-server **stdio** transport speaking JSON-RPC
// over the child's stdin/stdout) needs those pipes as tokio-native, reactor-
// registered handles, and it needs the child reaped when the transport drops.
// `spawn_tokio` is that async-facing sibling: the confinement is **identical**
// (it calls `spawn`, so the admission-check / OS-sandbox / env-scrub are the
// same audited path — the boundary is unchanged), only the returned handle
// types differ. Unix-only and gated on `spawn-tokio`, so core stays tokio-free
// by default (the confinement primitives themselves have no async dependency).
#[cfg(all(unix, feature = "spawn-tokio"))]
pub use tokio_spawn::ConfinedTokioChild;

#[cfg(all(unix, feature = "spawn-tokio"))]
mod tokio_spawn {
    use super::{ConfinedChild, ConfinedCommand, SandboxKind, ToolContext, ToolResult};
    use crate::net_proxy::ProxyHandle;
    use crate::{egress_proxy_plan, ToolError};
    use std::os::fd::OwnedFd;
    use std::process::Child;
    use tokio::net::unix::pipe;

    /// A confined child whose stdio is exposed as **tokio-native** pipe handles,
    /// for an async host (e.g. an MCP-server stdio transport). The async-facing
    /// sibling of [`ConfinedChild`](super::ConfinedChild): the confinement is
    /// identical (produced by [`ConfinedCommand::spawn`]), only the pipe types
    /// differ.
    ///
    /// **Kill-on-drop.** Dropping this SIGKILLs the child and reaps it on a
    /// detached thread — restoring the guarantee a host loses by moving off
    /// `tokio::process::Command::kill_on_drop(true)` onto the std child
    /// underneath (tokio's runtime reaper only tracks *its own* children, so the
    /// std child would otherwise linger as a zombie). Take the pipe ends with
    /// the `take_*` accessors; the child stays owned here so this value's
    /// lifetime governs the process.
    #[derive(Debug)]
    pub struct ConfinedTokioChild {
        /// The OS-level sandbox actually applied to the child — the honest record
        /// (mirrors [`ConfinedChild::sandbox_kind`](super::ConfinedChild)).
        pub sandbox_kind: SandboxKind,
        stdin: Option<pipe::Sender>,
        stdout: Option<pipe::Receiver>,
        stderr: Option<pipe::Receiver>,
        /// `Some` until dropped; owned so kill-on-drop governs the process.
        child: Option<Child>,
        /// The live egress proxy fencing this child's net (#257) — `Some` iff the
        /// grant was a general remote-host allow-list AND the loopback kernel
        /// fence engaged. Owned here so the proxy's lifetime brackets the
        /// child's: it is torn down after the child is killed on drop.
        proxy: Option<ProxyHandle>,
    }

    impl ConfinedTokioChild {
        /// Take the child's stdin pipe (writer). `None` if stdin was not
        /// [`piped`](std::process::Stdio::piped) or was already taken.
        pub fn take_stdin(&mut self) -> Option<pipe::Sender> {
            self.stdin.take()
        }

        /// Take the child's stdout pipe (reader). `None` if stdout was not piped
        /// or was already taken.
        pub fn take_stdout(&mut self) -> Option<pipe::Receiver> {
            self.stdout.take()
        }

        /// Take the child's stderr pipe (reader). `None` if stderr was not piped
        /// or was already taken.
        pub fn take_stderr(&mut self) -> Option<pipe::Receiver> {
            self.stderr.take()
        }

        /// Whether this child's egress is fenced through the loopback proxy
        /// (#257): kernel-fenced to loopback, per-host allow-list enforced by
        /// the proxy it is pointed at via `*_PROXY` env.
        pub fn egress_proxied(&self) -> bool {
            self.proxy.is_some()
        }

        /// The off-allow-list hosts the child tried to reach through the proxy
        /// (#196) — each was refused with 403. Empty when no proxy is in force
        /// or nothing was refused. The exfil-attempt signal a host surfaces as
        /// structured `net` denials.
        pub fn refused_hosts(&self) -> Vec<String> {
            self.proxy
                .as_ref()
                .map(ProxyHandle::refused_hosts)
                .unwrap_or_default()
        }
    }

    impl Drop for ConfinedTokioChild {
        fn drop(&mut self) {
            // Reinstate kill-on-drop. `spawn_tokio` hands back a std child, which
            // tokio's runtime reaper does NOT track — so kill it and `wait` on a
            // detached thread to avoid a zombie without blocking this (possibly
            // async) drop.
            if let Some(mut child) = self.child.take() {
                let _ = child.kill();
                std::thread::spawn(move || {
                    let _ = child.wait();
                });
            }
        }
    }

    impl ConfinedCommand {
        /// Admission-check, confine, and spawn the child — like
        /// [`spawn`](ConfinedCommand::spawn), but the stdio pipes are returned as
        /// **tokio-native** handles wrapped in a kill-on-drop
        /// [`ConfinedTokioChild`], for an async host (an MCP-server stdio
        /// transport).
        ///
        /// The confinement is exactly `spawn`'s (this delegates to it): the
        /// `exec` admission-check, the fail-closed refusal when a restricted fs
        /// axis cannot be kernel-enforced, the OS sandbox, and the env scrub all
        /// happen there. This method only converts the piped std handles into
        /// tokio pipe ends.
        ///
        /// Must be called from within a tokio runtime — the pipe handles register
        /// with the reactor. Unix-only; gated on the `spawn-tokio` feature.
        pub fn spawn_tokio(mut self, cx: &ToolContext) -> ToolResult<ConfinedTokioChild> {
            // #257 (Part A — Leg 4): under a general remote-host `net` grant,
            // fence the child's egress. `egress_proxy_plan` is the ONE shared
            // decision (also the shell engine's): engage only when the loopback
            // kernel fence is actually emittable on this host — a proxy a rogue
            // child can walk around is not confinement, so on fence-less hosts
            // (e.g. Landlock, which cannot address-fence) the wiring stays
            // INERT and the spawn proceeds exactly as before (net advisory,
            // ADR 0015 posture).
            let mut proxy = None;
            let mut effective = cx.caveats().clone();
            if let Some((hosts, fenced)) = egress_proxy_plan(&effective, &self.sandbox_policy) {
                // Fail-closed: the grant calls for a fence + proxy; a proxy
                // that cannot bind must refuse the spawn, never run unfenced.
                let handle = crate::net_proxy::start_for_hosts(hosts).map_err(|e| {
                    ToolError::Exec(std::io::Error::other(format!(
                        "refusing to spawn {:?}: the egress proxy could not bind \
                         loopback ({e})",
                        self.program
                    )))
                })?;
                // Point the child at the proxy through the explicit env
                // grants (the only channel across the boundary).
                for (k, v) in handle.proxy_env() {
                    self = self.env(k, v);
                }
                proxy = Some(handle);
                effective = fenced;
            }

            let ConfinedChild {
                mut child,
                sandbox_kind,
            } = self.spawn_with_effective(cx, effective)?;

            // Convert each *piped* std handle into a tokio pipe end.
            // `pipe::{Sender,Receiver}::from_owned_fd` set O_NONBLOCK and register
            // the fd with the reactor. The `OwnedFd` conversion moves ownership
            // out of the std `Child`, so each fd is closed exactly once (the tokio
            // end owns it; `Child` no longer does after `take`). A handle that was
            // not piped stays `None`. `?` maps the io error via `ToolError::from`.
            let stdin = child
                .stdin
                .take()
                .map(|h| pipe::Sender::from_owned_fd(OwnedFd::from(h)))
                .transpose()?;
            let stdout = child
                .stdout
                .take()
                .map(|h| pipe::Receiver::from_owned_fd(OwnedFd::from(h)))
                .transpose()?;
            let stderr = child
                .stderr
                .take()
                .map(|h| pipe::Receiver::from_owned_fd(OwnedFd::from(h)))
                .transpose()?;

            Ok(ConfinedTokioChild {
                sandbox_kind,
                stdin,
                stdout,
                stderr,
                child: Some(child),
                proxy,
            })
        }
    }
}

/// Prepend a backend command prefix (Seatbelt's `sandbox-exec -p <profile>`) to
/// a `(program, args)`, yielding the argv to actually spawn. An empty prefix is
/// the identity — thread-confining (Landlock) and Noop backends spawn the
/// program directly. Under Seatbelt the program should be an absolute path
/// (the environment is scrubbed, so `sandbox-exec` cannot resolve a bare name
/// via `PATH`).
fn wrap_argv(prefix: &[String], program: &str, args: &[OsString]) -> (OsString, Vec<OsString>) {
    if prefix.is_empty() {
        return (OsString::from(program), args.to_vec());
    }
    let mut argv: Vec<OsString> = prefix[1..].iter().map(OsString::from).collect();
    argv.push(OsString::from(program));
    argv.extend(args.iter().cloned());
    (OsString::from(&prefix[0]), argv)
}

/// Would confining this child be a *lie*? Decided against the **real** backend
/// `kind` (the probe the spawn actually confines through — not a stale gate
/// stamp; ADR 0012 D4) and the principal's `floor`.
///
/// Two parts:
/// 1. **The filesystem floor (always).** `fs_read` and `fs_write` *are*
///    kernel-enforceable (Landlock/Seatbelt/AppContainer); a restricted fs axis
///    the active backend cannot kernel-confine is a grant we cannot honor, so we
///    refuse regardless of strength. This keeps the ADR 0003 stub floor for
///    `fs_write` **and** extends it to `fs_read` — closing the spawn-boundary
///    fail-open ADR 0012 D4 found (a restricted `fs_read` was run unconfined
///    under `None` because the old check looked at `fs_write` only).
/// 2. **The strength floor (`exec`/`net`).** Coverage varies by backend and
///    scope, so those axes refuse when the principal's `floor` demands more than
///    the real report delivers (`fence_strength(report) < floor`). With the
///    default floor ([`AxisEnforcement::Advisory`]), an honestly reported weaker
///    axis may run; a strong principal (`floor = Kernel`) fails closed whenever
///    the active backend cannot kernel-confine it (ADR 0012 D3/D10).
#[must_use]
pub fn confinement_unenforceable(
    kind: SandboxKind,
    caveats: &Caveats,
    floor: AxisEnforcement,
) -> bool {
    let report = enforcement_report(caveats, kind);
    let below_kernel = |e: Option<AxisEnforcement>| e.is_some_and(|e| e != AxisEnforcement::Kernel);
    // (1) Filesystem axes: kernel-enforceable, so a restricted-but-not-kernel fs
    // axis is always unenforceable.
    if below_kernel(report.fs_write) || below_kernel(report.fs_read) {
        return true;
    }
    // (2) exec/net: refuse only when the strength floor is not met by reality.
    fence_strength(&report).is_some_and(|s| s < floor)
}

// Async-path proof for `spawn_tokio`: the child's stdio survives the std→tokio
// pipe conversion (a JSON-RPC line round-trips), and kill-on-drop actually kills
// the child. Real-subprocess tests, matching this module's convention (the
// landlock/seatbelt child proofs above also spawn real programs).
#[cfg(all(unix, feature = "spawn-tokio", test))]
mod tokio_spawn_tests {
    use super::*;
    use crate::{Gate, Tool};
    use std::time::Duration;
    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};

    fn ctx(granted: Caveats) -> ToolContext {
        struct AnyTool;
        #[async_trait::async_trait]
        impl Tool for AnyTool {
            fn name(&self) -> &str {
                "any"
            }
            fn schema(&self) -> serde_json::Value {
                serde_json::json!({})
            }
            async fn invoke(
                &self,
                _a: serde_json::Value,
                _c: &ToolContext,
            ) -> ToolResult<serde_json::Value> {
                Ok(serde_json::Value::Null)
            }
        }
        Gate::new(0)
            .authorize(&AnyTool, &granted)
            .expect("authorize")
    }

    fn find_cat() -> Option<&'static str> {
        ["/usr/bin/cat", "/bin/cat"]
            .into_iter()
            .find(|p| Path::new(p).exists())
    }

    /// The MCP-transport use case: a newline-delimited JSON-RPC line written to
    /// the child's tokio stdin comes back on its tokio stdout (`cat` echoes),
    /// proving the std→tokio pipe conversion preserves a working duplex stream.
    #[tokio::test]
    async fn json_line_round_trips_over_tokio_pipes() {
        let Some(cat) = find_cat() else {
            eprintln!("skipping: no cat(1) found");
            return;
        };
        let cx = ctx(Caveats {
            exec: Scope::only(["cat".to_string()]),
            ..Caveats::top()
        });
        let mut child = ConfinedCommand::new(cat)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .spawn_tokio(&cx)
            .expect("spawn_tokio cat");

        let mut stdin = child.take_stdin().expect("stdin piped");
        let stdout = child.take_stdout().expect("stdout piped");
        assert!(child.take_stdin().is_none(), "stdin taken once");

        let msg = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
        stdin.write_all(msg.as_bytes()).await.expect("write");
        stdin.write_all(b"\n").await.expect("write nl");
        stdin.flush().await.expect("flush");

        let mut lines = BufReader::new(stdout).lines();
        let got = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
            .await
            .expect("recv did not time out")
            .expect("recv ok");
        assert_eq!(got.as_deref(), Some(msg));
    }

    /// Kill-on-drop: dropping the [`ConfinedTokioChild`] SIGKILLs the child, which
    /// closes its stdout write end — so the retained reader reaches EOF. `stdin`
    /// is held so `cat` cannot exit on its own from a stdin EOF; the only thing
    /// that ends it is the drop.
    #[tokio::test]
    async fn dropping_the_guard_kills_the_child() {
        let Some(cat) = find_cat() else {
            eprintln!("skipping: no cat(1) found");
            return;
        };
        let cx = ctx(Caveats {
            exec: Scope::only(["cat".to_string()]),
            ..Caveats::top()
        });
        let mut child = ConfinedCommand::new(cat)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .spawn_tokio(&cx)
            .expect("spawn_tokio cat");

        let stdout = child.take_stdout().expect("stdout piped");
        // Hold stdin so `cat` does not exit from a stdin EOF — isolate the kill.
        let _stdin = child.take_stdin().expect("stdin piped");
        drop(child);

        let mut lines = BufReader::new(stdout).lines();
        let eof = tokio::time::timeout(Duration::from_secs(5), lines.next_line())
            .await
            .expect("EOF did not time out")
            .expect("read ok");
        assert_eq!(
            eof, None,
            "kill-on-drop must terminate the child and close its stdout (EOF)"
        );
    }

    // ── #257: spawn_tokio's egress-proxy wiring ─────────────────────────────

    /// Where the loopback fence is NOT emittable (any host whose backend does
    /// not engage for the fenced caveats — e.g. Linux/Landlock, which cannot
    /// address-fence), a remote-host `net` grant spawns with NO proxy: inert,
    /// advisory-net, exactly the pre-#257 behavior (the ADR 0015 posture —
    /// never a proxy the child can walk around).
    #[tokio::test]
    async fn remote_net_grant_without_fence_backend_spawns_inert() {
        let caveats = Caveats {
            exec: Scope::only(["true".to_string()]),
            net: Scope::only(["api.example.com".to_string()]),
            ..Caveats::top()
        };
        // Only meaningful where the fence would NOT engage; on a Seatbelt host
        // this test's premise doesn't hold, so skip there.
        let plan_engages = crate::egress_proxy_plan(
            &caveats,
            &std::sync::Arc::new(crate::SandboxPolicy::default()),
        )
        .is_some();
        if plan_engages {
            eprintln!("skipping: this host CAN emit the loopback fence (engage path)");
            return;
        }
        let cx = ctx(caveats);
        let child = ConfinedCommand::new("true")
            .spawn_tokio(&cx)
            .expect("inert path spawns as before");
        assert!(!child.egress_proxied(), "no fence backend → no proxy");
        assert!(child.refused_hosts().is_empty());
        // Reap deterministically (kill-on-drop covers it regardless).
        drop(child);
    }

    /// The engage path — INTEGRATION tier (real Seatbelt + real subprocess +
    /// the loopback proxy), so `#[ignore]`d out of the per-PR unit run; the
    /// deterministic engage proof lives in `net_proxy::tests` (the proxy 403s +
    /// records an off-list host with no subprocess) and the inert case above.
    /// Run on macOS with `--ignored`.
    ///
    /// Under a remote-host grant on a fence-capable host, a spawned `curl`
    /// (exec-scoped to itself — no shell re-exec) inherits the granted
    /// `*_PROXY` env and its CONNECT to an off-allow-list host is refused by
    /// the proxy (recorded in `refused_hosts()`) BEFORE any real dial — so this
    /// needs no network. Proves the full spawn_tokio ∘ fence ∘ proxy compose.
    #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
    #[tokio::test]
    #[ignore = "integration: real Seatbelt fence + curl subprocess + loopback proxy"]
    async fn remote_net_grant_with_fence_spawns_proxied_and_refuses_off_list() {
        if !crate::seatbelt_is_supported() {
            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
            return;
        }
        let curl = "/usr/bin/curl"; // always present on macOS; no shell re-exec
        let caveats = Caveats {
            exec: Scope::only([curl.to_string()]),
            net: Scope::only(["api.example.com".to_string()]),
            ..Caveats::top()
        };
        let cx = ctx(caveats);
        // curl honors the lowercase `https_proxy` the proxy env grant sets; the
        // off-list CONNECT is refused at the allow-list (403) before any dial.
        let child = ConfinedCommand::new(curl)
            .arg("-s")
            .arg("-m")
            .arg("5")
            .arg("https://evil.example.net/")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn_tokio(&cx)
            .expect("proxied spawn");
        assert!(child.egress_proxied(), "fence host → proxy must engage");

        // Reap via the kill-on-drop guard after curl exits; the proxy records
        // the refusal synchronously as it serves the CONNECT.
        let _ = tokio::time::timeout(Duration::from_secs(10), async {
            tokio::time::sleep(Duration::from_secs(1)).await;
        })
        .await;
        assert!(
            child
                .refused_hosts()
                .contains(&"evil.example.net".to_string()),
            "the off-allow-list host must be refused and recorded: {:?}",
            child.refused_hosts()
        );
    }
}

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

    /// Mint a `ToolContext` the only legitimate way — through the gate.
    fn ctx(granted: Caveats) -> ToolContext {
        struct AnyTool;
        #[async_trait::async_trait]
        impl Tool for AnyTool {
            fn name(&self) -> &str {
                "any"
            }
            fn schema(&self) -> serde_json::Value {
                serde_json::json!({})
            }
            async fn invoke(
                &self,
                _args: serde_json::Value,
                _cx: &ToolContext,
            ) -> ToolResult<serde_json::Value> {
                Ok(serde_json::Value::Null)
            }
        }
        Gate::new(0)
            .authorize(&AnyTool, &granted)
            .expect("authorize")
    }

    /// Core freezes authority at worker spawn and exposes only a one-shot
    /// payload sender. Payload fields that merely *look* authority-bearing do
    /// not replace the captured caveats, and a second send is structurally
    /// refused.
    #[cfg(any(target_os = "linux", target_os = "macos"))]
    #[test]
    fn trusted_worker_control_freezes_authority_and_is_take_once() {
        let true_program = ["/usr/bin/true", "/bin/true"]
            .into_iter()
            .find(|path| Path::new(path).exists())
            .expect("true executable");
        let process = Command::new(true_program)
            .spawn()
            .expect("spawn PID holder");
        let child_pid = process.id();
        let (client, mut server) =
            std::os::unix::net::UnixStream::pair().expect("private socketpair");
        let (seen_tx, seen_rx) = std::sync::mpsc::channel();

        let peer = std::thread::spawn(move || {
            let mut bootstrap = [0_u8; TRUSTED_WORKER_BOOTSTRAP.len()];
            server.read_exact(&mut bootstrap).expect("read bootstrap");
            assert_eq!(bootstrap, TRUSTED_WORKER_BOOTSTRAP);
            let challenge = [0x5a; 32];
            server
                .write_all(&encode_trusted_worker_hello(child_pid, challenge))
                .and_then(|()| server.flush())
                .expect("write hello");

            let mut header = [0_u8; TRUSTED_WORKER_FRAME_HEADER_LEN];
            server.read_exact(&mut header).expect("read header");
            let (body_len, echoed, digest) =
                decode_trusted_worker_frame_header(&header).expect("decode header");
            assert_eq!(echoed, challenge);
            let mut body = vec![0_u8; body_len];
            server.read_exact(&mut body).expect("read body");
            assert_eq!(digest, trusted_worker_frame_digest(&challenge, &body));
            let request: TrustedWorkerRequest<serde_json::Value> =
                decode_trusted_worker_request(&body).expect("decode request");
            seen_tx.send(request.into_parts()).expect("report request");
            server
                .write_all(&TRUSTED_WORKER_ACK)
                .and_then(|()| server.flush())
                .expect("write ACK");
        });

        let frozen = Caveats {
            exec: Scope::only(["echo".to_string()]),
            ..Caveats::top()
        };
        let control = TrustedWorkerControl {
            stream: client,
            nonce: "core-owned-nonce".to_string(),
            caveats: frozen.clone(),
            strength_floor: AxisEnforcement::Advisory,
        };
        let mut worker = SandboxedWorkerChild {
            child: process,
            sandbox_kind: SandboxKind::None,
            control: Some(control),
        };
        let forged_payload = serde_json::json!({
            "cmd": "echo ok",
            "caveats": Caveats::top(),
            "strength_floor": AxisEnforcement::Kernel,
        });
        worker
            .send_payload(&forged_payload, Duration::from_secs(5))
            .expect("first payload");

        let (nonce, authority, floor, payload) = seen_rx.recv().expect("receive decoded request");
        assert_eq!(nonce, "core-owned-nonce");
        assert_eq!(authority, frozen, "payload must not replace frozen caveats");
        assert_eq!(floor, AxisEnforcement::Advisory);
        assert_eq!(payload, forged_payload);
        assert!(
            matches!(
                worker.send_payload(&serde_json::json!({}), Duration::from_secs(1)),
                Err(ToolError::Denied { .. })
            ),
            "the private control endpoint must be take-once"
        );

        peer.join().expect("join fake worker");
        let _ = worker.child.wait();
    }

    #[test]
    fn exec_outside_scope_is_denied_before_any_spawn() {
        let cx = ctx(Caveats {
            exec: Scope::only(["echo".to_string()]),
            ..Caveats::top()
        });
        let res = ConfinedCommand::new("rm").arg("-rf").spawn(&cx);
        assert!(matches!(res, Err(ToolError::Denied { .. })));
    }

    #[test]
    fn unenforceable_predicate_fs_axes_always_strength_floor_for_exec() {
        use AxisEnforcement::{Advisory, Kernel};
        let fs_write = Caveats {
            fs_write: Scope::only(["/tmp/x".to_string()]),
            ..Caveats::top()
        };
        let fs_read = Caveats {
            fs_read: Scope::only(["/tmp/x".to_string()]),
            ..Caveats::top()
        };
        let exec = Caveats {
            exec: Scope::only(["echo".to_string()]),
            ..Caveats::top()
        };

        // (1) FS floor — always, regardless of strength: a restricted fs axis with
        // no OS sandbox is unenforceable, for BOTH fs_write and fs_read (the
        // latter is the ADR 0012 D4 spawn-boundary fail-open this closes).
        assert!(confinement_unenforceable(
            SandboxKind::None,
            &fs_write,
            Advisory
        ));
        assert!(confinement_unenforceable(
            SandboxKind::None,
            &fs_read,
            Advisory
        ));
        // The kernel can enforce the fs axes => fine.
        assert!(!confinement_unenforceable(
            SandboxKind::Landlock,
            &fs_write,
            Advisory
        ));
        assert!(!confinement_unenforceable(
            SandboxKind::Landlock,
            &fs_read,
            Advisory
        ));

        // (2) exec is not kernel-enforceable: the default (Advisory) floor permits
        // it; a strong (Kernel) floor fails closed (the opt-in un-stub posture).
        assert!(!confinement_unenforceable(
            SandboxKind::None,
            &exec,
            Advisory
        ));
        assert!(confinement_unenforceable(SandboxKind::None, &exec, Kernel));

        // Unrestricted grant => nothing to enforce, even under a Kernel floor.
        assert!(!confinement_unenforceable(
            SandboxKind::None,
            &Caveats::top(),
            Kernel
        ));
    }

    /// AppContainer's wired ACL narrowing means fs-only caveats engage the
    /// launcher; `--fs-read`/`--fs-write` grant its SID the requested workspace
    /// paths over the container's default deny of user directories.
    #[test]
    fn fs_restricted_under_appcontainer_engages_the_launcher() {
        let fs = Caveats {
            fs_write: Scope::only(["/tmp/x".to_string()]),
            ..Caveats::top()
        };
        let governing = effective_sandbox_kind(SandboxKind::AppContainer, &fs);
        assert_eq!(
            governing,
            SandboxKind::AppContainer,
            "fs-only must engage AppContainer (ACL narrowing wired, #51)"
        );
        // fs_write is Kernel: DACL grants + AppContainer default deny-user-dirs (#51).
        let report = enforcement_report(&fs, governing);
        assert_eq!(report.fs_write, Some(AxisEnforcement::Kernel));
        // With AppContainer engaged and fs Kernel, confinement is enforceable.
        assert!(
            !confinement_unenforceable(governing, &fs, AxisEnforcement::Advisory),
            "fs-restricted AppContainer is enforceable (launcher wired)"
        );
    }

    /// exec_fully_denied engages the AppContainer backend: governing == AppContainer,
    /// and the enforcement report marks exec → Kernel (#123).
    #[test]
    fn exec_deny_all_under_appcontainer_is_kernel() {
        let exec_denied = Caveats {
            exec: Scope::only([] as [String; 0]),
            ..Caveats::top()
        };
        let governing = effective_sandbox_kind(SandboxKind::AppContainer, &exec_denied);
        assert_eq!(
            governing,
            SandboxKind::AppContainer,
            "exec deny-all must engage AppContainer"
        );
        // With an AppContainer backend and exec fully denied, the axis is kernel-enforced.
        assert!(
            !confinement_unenforceable(governing, &exec_denied, AxisEnforcement::Advisory),
            "exec deny-all under AppContainer is enforceable (kernel-level block)"
        );
        let report = enforcement_report(&exec_denied, governing);
        assert_eq!(
            report.exec,
            Some(AxisEnforcement::Kernel),
            "exec deny-all must be Kernel under AppContainer"
        );
    }

    /// Trusted-worker launch configuration must not erase AppContainer's
    /// deny-all signal. The AppContainer launcher starts the worker itself, then
    /// `--no-child-process` confines what that worker may spawn. This is pure and
    /// host-independent so Linux/macOS CI protects the Windows policy routing.
    #[test]
    fn trusted_worker_preserves_appcontainer_exec_deny_all() {
        let exec_denied = Caveats {
            exec: Scope::only([] as [String; 0]),
            ..Caveats::top()
        };

        let mechanism = trusted_worker_mechanism_caveats(
            SandboxKind::AppContainer,
            &exec_denied,
            "this-path-is-not-used-by-appcontainer",
        )
        .expect("AppContainer mechanism caveats");

        assert!(
            crate::sandbox::exec_fully_denied(&mechanism),
            "the launcher must still select --no-child-process"
        );
        assert_eq!(
            effective_sandbox_kind(SandboxKind::AppContainer, &mechanism),
            SandboxKind::AppContainer,
            "deny-all must still engage the AppContainer boundary"
        );
        assert_eq!(
            enforcement_report(&mechanism, SandboxKind::AppContainer).exec,
            Some(AxisEnforcement::Kernel),
            "the preserved mechanism matches the reported kernel guarantee"
        );
    }

    /// Backends whose wrapper/domain must execute the trusted worker still get
    /// its exact path as mechanism-only authority.
    #[test]
    fn trusted_worker_keeps_exec_allowance_for_allowlist_backends() {
        let exec_denied = Caveats {
            exec: Scope::only([] as [String; 0]),
            ..Caveats::top()
        };
        let current = std::env::current_exe()
            .expect("current executable")
            .canonicalize()
            .expect("canonical current executable")
            .to_string_lossy()
            .into_owned();

        for kind in [SandboxKind::Landlock, SandboxKind::Seatbelt] {
            let mechanism = trusted_worker_mechanism_caveats(kind, &exec_denied, &current)
                .expect("allowlist mechanism caveats");
            assert!(
                matches!(&mechanism.exec, Scope::Only(programs) if programs.contains(&current)),
                "{kind:?} must authorize the fixed worker executable"
            );
        }
    }

    /// Builds with **no** available OS sandbox: a restrictive `fs_write` must be
    /// refused rather than spawned unconfined. Gated off where a backend can
    /// actually enforce (Linux+Landlock, macOS+Seatbelt, Windows+AppContainer) —
    /// there the spawn is confined (or fails-closed on missing launcher), not
    /// silently unconfined, so this particular assertion does not apply.
    #[cfg(not(any(
        all(target_os = "linux", feature = "linux-landlock"),
        all(target_os = "macos", feature = "macos-seatbelt"),
        all(target_os = "windows", feature = "windows-appcontainer")
    )))]
    #[test]
    fn restrictive_write_refused_when_no_sandbox_available() {
        let cx = ctx(Caveats {
            exec: Scope::All,
            fs_write: Scope::only(["/tmp/allowed".to_string()]),
            ..Caveats::top()
        });
        let res = ConfinedCommand::new("true").spawn(&cx);
        assert!(
            matches!(res, Err(ToolError::Denied { .. })),
            "must fail closed when confinement is requested but unenforceable"
        );
    }

    /// The environment is scrubbed: only granted vars reach the child, nothing
    /// ambient (e.g. the parent's `HOME`) leaks. Uses a piped stdout to read the
    /// child's view of its own environment.
    #[cfg(unix)]
    #[test]
    fn environment_is_scrubbed_to_the_granted_allow_list() {
        let env_bin = ["/usr/bin/env", "/bin/env"]
            .into_iter()
            .find(|p| Path::new(p).exists());
        let Some(env_bin) = env_bin else {
            eprintln!("skipping env-scrub test: no env(1) found");
            return;
        };
        // fs_write unrestricted (env(1) writes only to its stdout pipe, not the
        // filesystem), exec pinned to env.
        let cx = ctx(Caveats {
            exec: Scope::only(["env".to_string()]),
            ..Caveats::top()
        });
        let spawned = ConfinedCommand::new(env_bin)
            .env("ALLOWED", "yes")
            .stdout(Stdio::piped())
            .spawn(&cx)
            .expect("spawn env");
        let out = spawned.child.wait_with_output().expect("wait");
        let text = String::from_utf8_lossy(&out.stdout);
        assert!(text.contains("ALLOWED=yes"), "granted var must be present");
        assert!(
            !text.contains("HOME="),
            "ambient parent env must NOT leak into the child: {text:?}"
        );
    }
}

// Kernel-enforcement proof: the *spawned child* (not just the parent thread)
// inherits the Landlock `fs_write` domain. Only meaningful on Linux with the
// feature and a capable kernel.
#[cfg(all(target_os = "linux", feature = "linux-landlock", test))]
mod landlock_child_tests {
    use super::*;
    use crate::{landlock_is_supported, Gate, Tool};
    use std::fs;
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicU64, Ordering};

    fn ctx(granted: Caveats) -> ToolContext {
        struct AnyTool;
        #[async_trait::async_trait]
        impl Tool for AnyTool {
            fn name(&self) -> &str {
                "any"
            }
            fn schema(&self) -> serde_json::Value {
                serde_json::json!({})
            }
            async fn invoke(
                &self,
                _a: serde_json::Value,
                _c: &ToolContext,
            ) -> ToolResult<serde_json::Value> {
                Ok(serde_json::Value::Null)
            }
        }
        Gate::new(0)
            .authorize(&AnyTool, &granted)
            .expect("authorize")
    }

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

    #[test]
    fn child_inherits_fs_write_domain_out_of_scope_denied_in_scope_allowed() {
        if !landlock_is_supported() {
            eprintln!("skipping: kernel lacks Landlock");
            return;
        }
        let touch = ["/usr/bin/touch", "/bin/touch"]
            .into_iter()
            .find(|p| std::path::Path::new(p).exists());
        let Some(touch) = touch else {
            eprintln!("skipping: no touch(1) found");
            return;
        };

        let allowed = unique_dir("allowed");
        let forbidden = unique_dir("forbidden");
        let cx = ctx(Caveats {
            exec: Scope::only(["touch".to_string()]),
            fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
            ..Caveats::top()
        });

        // Out of scope: the child's own write is kernel-denied → non-zero exit.
        let mut out = ConfinedCommand::new(touch)
            .arg(forbidden.join("escape.txt"))
            .spawn(&cx)
            .expect("spawn");
        assert_eq!(out.sandbox_kind, SandboxKind::Landlock);
        let status = out.child.wait().expect("wait");
        assert!(
            !status.success(),
            "child write outside fs_write must be kernel-denied"
        );
        assert!(!forbidden.join("escape.txt").exists());

        // In scope: the child write succeeds.
        let mut ok = ConfinedCommand::new(touch)
            .arg(allowed.join("ok.txt"))
            .spawn(&cx)
            .expect("spawn");
        assert!(ok.child.wait().expect("wait").success());
        assert!(allowed.join("ok.txt").exists());

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

    /// #144 (I5-B): `ConfinedCommand::sandbox_policy` is honored — a child spawned
    /// with a widened `base_read_paths` can read a file outside `fs_read` scope
    /// that the default policy denies. Proves the builder threads the policy into
    /// `best_available_sandbox` (mechanism rides the builder, not `ToolContext`).
    #[test]
    fn confined_command_honors_sandbox_policy_base_read() {
        if !landlock_is_supported() {
            eprintln!("skipping: kernel lacks Landlock");
            return;
        }
        let cat = ["/usr/bin/cat", "/bin/cat"]
            .into_iter()
            .find(|p| std::path::Path::new(p).exists());
        let Some(cat) = cat else {
            eprintln!("skipping: no cat(1) found");
            return;
        };

        let allowed = unique_dir("cfg-allowed");
        let extra = unique_dir("cfg-extra");
        fs::write(extra.join("data.txt"), b"configured").unwrap();
        let cx = ctx(Caveats {
            exec: Scope::only(["cat".to_string()]),
            fs_read: Scope::only([allowed.to_string_lossy().into_owned()]),
            ..Caveats::top()
        });

        // Control: default policy → the child cannot read the out-of-scope file.
        let mut denied = ConfinedCommand::new(cat)
            .arg(extra.join("data.txt"))
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn(&cx)
            .expect("spawn");
        assert!(
            !denied.child.wait().expect("wait").success(),
            "default base read must deny the child reading the out-of-scope file"
        );

        // Widened policy: add `extra` to base_read_paths → the child reads it.
        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 mut ok = ConfinedCommand::new(cat)
            .arg(extra.join("data.txt"))
            .sandbox_policy(policy)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn(&cx)
            .expect("spawn");
        assert!(
            ok.child.wait().expect("wait").success(),
            "a config-widened base_read_paths must let the child read the extra file"
        );

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

// Kernel-enforcement proof for macOS: the *spawned child* (not just the parent)
// is confined by the Seatbelt `sandbox-exec` wrapper that `ConfinedCommand`
// applies — the spawn.rs analog of the Landlock child proof above.
#[cfg(all(target_os = "macos", feature = "macos-seatbelt", test))]
mod seatbelt_child_tests {
    use super::*;
    use crate::{seatbelt_is_supported, Gate, Tool};
    use std::fs;
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicU64, Ordering};

    fn ctx(granted: Caveats) -> ToolContext {
        struct AnyTool;
        #[async_trait::async_trait]
        impl Tool for AnyTool {
            fn name(&self) -> &str {
                "any"
            }
            fn schema(&self) -> serde_json::Value {
                serde_json::json!({})
            }
            async fn invoke(
                &self,
                _a: serde_json::Value,
                _c: &ToolContext,
            ) -> ToolResult<serde_json::Value> {
                Ok(serde_json::Value::Null)
            }
        }
        Gate::new(0)
            .authorize(&AnyTool, &granted)
            .expect("authorize")
    }

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

    #[test]
    fn child_inherits_fs_write_domain_out_of_scope_denied_in_scope_allowed() {
        if !seatbelt_is_supported() {
            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
            return;
        }
        let allowed = unique_dir("allowed");
        let forbidden = unique_dir("forbidden");
        let cx = ctx(Caveats {
            // Absolute program path: the environment is scrubbed, so sandbox-exec
            // cannot resolve a bare name via PATH (see `wrap_argv`).
            exec: Scope::only(["/usr/bin/touch".to_string()]),
            fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
            ..Caveats::top()
        });

        // Out of scope: the child's own write is kernel-denied → non-zero exit.
        let mut out = ConfinedCommand::new("/usr/bin/touch")
            .arg(forbidden.join("escape.txt"))
            .spawn(&cx)
            .expect("spawn");
        assert_eq!(out.sandbox_kind, SandboxKind::Seatbelt);
        let status = out.child.wait().expect("wait");
        assert!(
            !status.success(),
            "child write outside fs_write must be kernel-denied"
        );
        assert!(!forbidden.join("escape.txt").exists());

        // In scope: the child write succeeds.
        let mut ok = ConfinedCommand::new("/usr/bin/touch")
            .arg(allowed.join("ok.txt"))
            .spawn(&cx)
            .expect("spawn");
        assert!(ok.child.wait().expect("wait").success());
        assert!(allowed.join("ok.txt").exists());

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

    /// Honesty (I9): a fully permissive grant confines *nothing*, so the Seatbelt
    /// wrapper applies nothing and the child must be reported `None`, never the raw
    /// backend kind. This is the regression for the original overclaim where
    /// `sandbox_kind` was the backend kind regardless of whether anything was
    /// confined.
    #[test]
    fn top_grant_confines_nothing_reports_none() {
        if !seatbelt_is_supported() {
            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
            return;
        }
        let cx = ctx(Caveats::top());
        let child = ConfinedCommand::new("/usr/bin/true")
            .spawn(&cx)
            .expect("spawn");
        assert_eq!(
            child.sandbox_kind,
            SandboxKind::None,
            "nothing restricted => nothing confined => None, not the raw backend kind"
        );
    }

    /// A restricted `exec` axis engages Seatbelt **even when both fs axes are
    /// `All`**: `process-exec*` kernel-confines the exec axis (ADR 0014), so
    /// reporting `Seatbelt` is honest, not an overclaim — the inverse of the
    /// `top_grant…` guard above. Before ADR 0014 this same grant reported `None`
    /// (the exec axis was left ambient).
    #[test]
    fn restricted_exec_engages_seatbelt() {
        if !seatbelt_is_supported() {
            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
            return;
        }
        // exec restricted, both fs axes `All` — a grant a host might give an MCP
        // server: confine *what may run*, leave the filesystem ambient.
        let cx = ctx(Caveats {
            exec: Scope::only(["/usr/bin/true".to_string()]),
            ..Caveats::top()
        });
        let child = ConfinedCommand::new("/usr/bin/true")
            .spawn(&cx)
            .expect("spawn");
        assert_eq!(
            child.sandbox_kind,
            SandboxKind::Seatbelt,
            "a restricted exec axis is kernel-confined by process-exec* (ADR 0014)"
        );
    }
}