newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
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
//! Hidden tool-call routing — facade **P4** (§4 of
//! `docs/ocap/permissions-facade-design.md`).
//!
//! Faithful single-tool shell calls can use the governed built-in directly:
//! `cat X` → `read_file`, `ls` → `list_dir`, and supported build commands →
//! `build_exec`. Each route retains the destination tool's authority checks.
//! Other calls use ordinary exec under the existing OCAP boundary.
//!
//! Native Git commands always use exec. Translating them into a smaller Git
//! interface changes the requested flags, revisions, or output shape; the
//! installed Git executable is responsible for its own command semantics.
//!
//! [`SHELL_ROUTES`] records which shell programs route. Classification also
//! checks argument semantics and resolves any leading `cd` against the
//! caller's workspace and read scope. A command that cannot be translated
//! faithfully stays on the normal exec path.

use serde_json::{json, Value};
use std::path::Path;

/// What to do with a shell command the model passed to `run_command`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RouteDecision {
    /// Silently route to this governed built-in with these translated args. The
    /// built-in applies the same authority checks — routing is not a
    /// bypass.
    Route { tool: &'static str, args: Value },
    /// Leave the command on the normal exec path (the confined shell / #263
    /// permission gate). Read-only reaches we cannot faithfully represent, and
    /// every state-modifying or unknown command, land here.
    Exec,
}

/// A whole-command shell reach that maps cleanly onto a governed built-in.
/// Pure DATA (three-Cs): a new reach is one slice entry.
#[derive(Debug)]
struct ShellRoute {
    /// The shell program the model typed (the leading token).
    program: &'static str,
    /// The governed built-in it routes to.
    tool: &'static str,
}

/// The shell reaches that route to a governed built-in — pure DATA.
///
/// `cat`→`read_file`, `ls`→`list_dir`, `find`→the embedded `find` tool,
/// `rm`/`unlink`→`delete_file`. The per-tool argument translation (how the
/// command's operands become the built-in's `{path}` / `{name}` / `{type}`) is
/// the pure rule in [`build_shell_route`]; *which* programs route is this slice.
const SHELL_ROUTES: &[ShellRoute] = &[
    ShellRoute {
        program: "cat",
        tool: "read_file",
    },
    ShellRoute {
        program: "ls",
        tool: "list_dir",
    },
    ShellRoute {
        program: "find",
        tool: "find",
    },
    ShellRoute {
        program: "rm",
        tool: "delete_file",
    },
    ShellRoute {
        program: "unlink",
        tool: "delete_file",
    },
];

/// `cargo` subcommands that route to the confined build lane (`build_exec`,
/// `tools.rs`) — pure DATA. Deliberately excludes `run`/`install`/`publish`
/// and anything else that changes what's installed or reaches the network;
/// those stay on the exec path.
const CARGO_BUILD_SUBCOMMANDS: &[&str] = &["build", "check", "test", "clippy"];

/// Is `program` a build tool this repo recognises — the single table both
/// [`RouteTable::classify`]'s clean-argv route AND (F20) the confined shell's
/// per-call wall clock read, so a compound build command (`cargo test …; echo
/// …`) that CANNOT route still gets classified as a build reach by the same
/// rule, instead of a second hand-maintained list.
pub(crate) fn is_build_tool_program(program: &str) -> bool {
    program == "cargo" || program == "just"
}

/// Classify build work from the shell inspector's argv without rewriting it.
/// Probe/help invocations do not lend unrelated shell stages a build budget.
pub(crate) fn is_build_work(program: &str, argv: &[String]) -> bool {
    let name = std::path::Path::new(program)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or(program);
    let rest = argv.get(1..).unwrap_or_default();
    if rest
        .iter()
        .any(|arg| matches!(arg.as_str(), "--version" | "-V" | "--help" | "-h"))
    {
        return false;
    }
    match name {
        "cargo" => {
            let mut words = rest.iter().map(String::as_str);
            while let Some(word) = words.next() {
                match word {
                    word if is_toolchain_selector(word) => {}
                    "-q" | "--quiet" | "-v" | "-vv" | "--verbose" | "--offline" | "--locked"
                    | "--frozen" => {}
                    "--color" | "--config" | "-C" | "-Z" => {
                        if words.next().is_none() {
                            return false;
                        }
                    }
                    word if word.starts_with("--color=") || word.starts_with("--config=") => {}
                    sub => return CARGO_BUILD_SUBCOMMANDS.contains(&sub),
                }
            }
            false
        }
        // A recipe can take arguments or run from an explicitly named justfile.
        // Introspection modes do not execute that recipe.
        "just" => !rest.iter().any(|arg| {
            matches!(
                arg.as_str(),
                "--list"
                    | "-l"
                    | "--summary"
                    | "--show"
                    | "--dump"
                    | "--evaluate"
                    | "--fmt"
                    | "--completion"
                    | "--completions"
            )
        }),
        "make" => true,
        _ => false,
    }
}

/// Shell control / redirection / substitution metacharacters. A command
/// containing any of these is **compound** (`cat f | grep x`, `cat a && cat b`,
/// `cat $(…)`, a redirect) — its semantics cannot be reproduced by a single
/// built-in call, so it is never routed; the confined shell gates each spawn.
/// Mirrors `tools::exec_floor_permits`'s conservative refusal. Globbing
/// (`* ? [ ]`) is handled per-program in [`build_read_route`], not here, because
/// `find -name '*.rs'` globs *inside* the tool.
const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];

/// Glob metacharacters in a bare path operand. A `cat *.txt` / `ls a?` needs
/// the *shell* to expand the glob into filenames — a built-in receiving the
/// literal glob would misbehave — so such a command gates instead of routing.
const GLOB: &[char] = &['*', '?', '[', ']'];

/// Tokens the SHELL would transform before a program ever sees them — a
/// quote, an escape, a `~` expansion, or a glob (`GLOB` above). The build
/// route runs the model's argv **literally** (no shell in between), so any
/// operand carrying one of these routes with the WRONG argv: `cargo test
/// "a b"` would see two literal tokens `"a` and `b"` instead of one quoted
/// string, `--manifest-path ~/x` would see a literal `~` instead of $HOME,
/// `cargo build --bin *` would see a literal `*` instead of the shell's
/// expansion. #2533 round 2: refuse to route rather than run a silently
/// different command with `ok: true`.
const BUILD_UNSAFE: &[char] = &['"', '\'', '\\', '~', '*', '?', '[', ']'];

/// The route/gate table — pure DATA, read by [`RouteTable::classify`].
#[derive(Debug, Clone)]
pub(crate) struct RouteTable {
    shell_routes: &'static [ShellRoute],
}

impl RouteTable {
    /// The built-in table of shell calls that have faithful translations.
    #[must_use]
    pub(crate) fn builtin() -> Self {
        Self {
            shell_routes: SHELL_ROUTES,
        }
    }

    /// Classify a complete call without discarding argument semantics.
    /// `workspace` is the session's workspace root; `read_scope` is
    /// the call's fs-read fence (F24/PR1's `cd`/`cwd` fold, below, needs
    /// both — the caller's own values, never read from the process).
    #[must_use]
    pub(crate) fn classify_call(
        &self,
        call: &Value,
        workspace: &Path,
        read_scope: &crate::caveats::Scope<String>,
    ) -> RouteDecision {
        let decision = self.classify(
            call.get("command").and_then(Value::as_str).unwrap_or(""),
            workspace,
            read_scope,
        );
        let RouteDecision::Route { tool, .. } = &decision else {
            return decision;
        };
        // Every route discards the rest of the call object once routed —
        // none has anywhere to put a `timeout` the model also sent. #2551
        // round 2 (the BLOCKER's "same rule" for the `cwd`-field path, and
        // its pre-existing sibling: `{command:"cat f", cwd:"sub"}` used to
        // route and silently drop `cwd`): a `cwd` field is allowed
        // alongside `command` only for `build_exec`, which actually reads
        // it ([`attach_cwd`]). Other routes require bare `command`.
        let extra_keys_allowed: &[&str] = if *tool == "build_exec" {
            &["command", "cwd"]
        } else {
            &["command"]
        };
        let keys_ok = call.as_object().is_some_and(|obj| {
            obj.keys()
                .all(|key| extra_keys_allowed.contains(&key.as_str()))
        });
        if !keys_ok {
            return RouteDecision::Exec;
        }
        // A leading `cd` INSIDE `command` already resolved a `cwd` above —
        // that is the question `cd` itself answers, so a `cwd` FIELD
        // alongside it is never silently combined into a second, different
        // directory. #2551 round 3: the leading `cd` was folded and
        // resolved against `workspace`, but a real shell resolves a
        // RELATIVE leading `cd` against the call's `cwd` field, not the
        // workspace root — `{command:"cd sub && cargo test", cwd:"other"}`
        // routes to `<root>/sub` while the shell would run in
        // `<root>/other/sub`. Refuse rather than silently building/reading
        // the wrong directory.
        let already_has_cwd =
            matches!(&decision, RouteDecision::Route { args, .. } if args.get("cwd").is_some());
        if already_has_cwd {
            return if call.get("cwd").is_some() {
                RouteDecision::Exec
            } else {
                decision
            };
        }
        // PR1: a model-supplied `cwd` FIELD on an otherwise-bare call is the
        // SAME question a leading `cd` asks — resolved the SAME way
        // ([`attach_cwd`]'s build-only rule applies here too).
        // A call with no `cwd` field at all (the original bare shape) is
        // unaffected: `decision` returned as-is.
        let Some(cwd_field) = call.get("cwd").and_then(Value::as_str) else {
            return decision;
        };
        match resolve_workspace_relative_dir(cwd_field, workspace, read_scope) {
            Some(cwd) => attach_cwd(decision, cwd),
            None => RouteDecision::Exec,
        }
    }

    /// Classify a `run_command` shell-command string. `workspace`/
    /// `read_scope` feed PR1's `cd`-fold (below) — real filesystem reads
    /// (`std::fs::canonicalize`), no longer the pure table lookup this was
    /// before #2550/PR1; every other branch stays a data lookup.
    #[must_use]
    pub(crate) fn classify(
        &self,
        command: &str,
        workspace: &Path,
        read_scope: &crate::caveats::Scope<String>,
    ) -> RouteDecision {
        let trimmed = command.trim();
        if trimmed.is_empty() {
            return RouteDecision::Exec;
        }
        // PR1 (r10-r12 evidence, multi-repo-recon rows 1/2/4/6): almost
        // every `run_command` began with `cd <dir> && …` — not only the
        // workspace root (#2550's F24 case) but a SUBDIRECTORY (`cd
        // repoA && cargo test`), which used to stay compound and never
        // route at all, even though `tools/shell.rs`'s `split_leading_cd`
        // ALREADY folds it into a correct `cwd` for the confined shell's
        // own dispatch. Fold it here too, BEFORE any other check (the
        // tail-pipe attempt, the SHELL_META refusal), so `<rest>` is
        // classified exactly as if it had been sent alone from `<dir>`.
        let (effective, cwd) = fold_leading_cd(trimmed, workspace, read_scope);
        // F27 (r14a evidence): a trailing `; echo "EXIT: $?"` (or the same
        // idea with `&&`, `EXIT=`, `exit `, `${PIPESTATUS[0]}`, …) is a
        // no-op once the command routes — the routed result already
        // reports the REAL exit code, never masked by the shell's own `$?`
        // the way `| tail`'s exit code masks a piped build's. Strip it
        // BEFORE the ordinary classification below, same pass as the `cd`
        // fold above, so the two compose.
        let (effective, echo_dropped) = strip_trailing_exit_echo(effective);
        let decision = self.classify_stripped(effective);
        let decision = if echo_dropped {
            mark_echo_dropped(decision)
        } else {
            decision
        };
        match cwd {
            Some(cwd) => attach_cwd(decision, cwd),
            None => decision,
        }
    }

    /// The classification table lookup itself, over an ALREADY-stripped
    /// command (no leading no-op `cd`). Split out of [`Self::classify`] so
    /// the `cd`-prefix handling has exactly one seam to annotate the
    /// result, rather than every early return needing to remember it.
    ///
    /// F28 (#2483 evidence, `newt-eval/NOTES-2483.md`): the FIRST thing
    /// checked, before the pipe/meta/tokenize logic below — a leading
    /// `timeout [OPTIONS]… <DURATION>` wrapper ([`strip_leading_timeout`])
    /// is stripped and the remainder classified through
    /// [`Self::classify_build_shapes`] (the un-timeout-aware body, so a
    /// SECOND leading `timeout` in the remainder — `timeout 1 timeout 2 …`
    /// — is never itself stripped: the "exactly one wrapper" rule). The
    /// wrapper is dropped ONLY when the remainder alone routes to the build
    /// lane (`build_exec`) — a non-routable remainder (`timeout 5 rm -rf
    /// x`) stays `Exec`, same as the bare `rm` case always was.
    fn classify_stripped(&self, command: &str) -> RouteDecision {
        if let Some((timeout_secs, remainder)) = strip_leading_timeout(command) {
            return match self.classify_build_shapes(remainder) {
                RouteDecision::Route {
                    tool: "build_exec",
                    args,
                } => mark_timeout_secs(
                    RouteDecision::Route {
                        tool: "build_exec",
                        args,
                    },
                    timeout_secs,
                ),
                _ => RouteDecision::Exec,
            };
        }
        self.classify_build_shapes(command)
    }

    /// The pipe/meta/tokenize table lookup, unaware of a leading `timeout`
    /// wrapper — [`Self::classify_stripped`]'s ONLY caller, so a leading
    /// `timeout` in the remainder it is handed (the two-wrapper case) is
    /// never stripped a second time.
    fn classify_build_shapes(&self, command: &str) -> RouteDecision {
        // F23 / #2524 "tail-pipe-routes": recognise EXACTLY `<clean build
        // argv> [2>&1] | tail -N` (or `head -N`) BEFORE the blanket
        // compound-command refusal below — the model pipes ONLY to cut
        // output (see `build_piped_to_trim_route`'s doc), and a masked exit
        // code from `| tail` is the exact hazard `note_verified_pass`'s doc
        // comment (mod.rs) names as why an un-routed `run_command` pass
        // can't be trusted. Only short-circuits on an actual match; any
        // other pipe shape (multiple pipes, `tail -f`, a redirect before the
        // pipe, a grep, …) falls through unchanged to the refusal below.
        if command.contains('|') {
            if let route @ RouteDecision::Route { .. } = build_piped_to_trim_route(command) {
                return route;
            }
        }
        // Compound / redirected / substituted commands never route (see
        // SHELL_META): a single built-in cannot reproduce a pipe/chain/redirect.
        if command.contains(SHELL_META) {
            return RouteDecision::Exec;
        }
        let mut tokens = command.split_ascii_whitespace();
        let Some(program) = tokens.next() else {
            return RouteDecision::Exec;
        };

        // A recognised build/test invocation routes to the confined build
        // lane — the model's literal argv runs verbatim (see
        // `build_lane_route`'s doc comment): F11's fix for the model that
        // never calls `lifecycle` and instead times out under
        // `run_command`'s 60s wall.
        if is_build_tool_program(program) {
            let rest: Vec<&str> = tokens.collect();
            return build_lane_route(program, &rest);
        }

        // Whole-command reaches that map onto governed built-ins.
        let Some(route) = self.shell_routes.iter().find(|r| r.program == program) else {
            return RouteDecision::Exec;
        };
        let rest: Vec<&str> = tokens.collect();
        build_shell_route(route.tool, &rest)
    }
}

/// Resolve `dir` (a `cd` argument or a `cwd` field — the SAME question
/// either way) against `workspace`: the path `workspace`/`read_scope` grant
/// a real `cd <dir>` would land in. Returns `Some(<relative path>)` (`.`
/// for `workspace` itself) or `None` — never a different directory than a
/// real shell would reach, and never one outside this call's authority.
///
/// `<dir>` must first be a plain, unquoted, non-glob, metacharacter-free,
/// whitespace-free token (checked against the SAME [`SHELL_META`]/[`GLOB`]/
/// [`BUILD_UNSAFE`] tables the rest of this module already refuses on, plus
/// no internal whitespace — `cd /a b` is TWO arguments to a real `cd` and
/// fails there, #2550 round 2) — `cd -`, `cd ~`, a quoted or globbed dir
/// never qualifies. #2551 round 2: a `..` component in `<dir>` ALSO refuses
/// outright, checked on the un-normalized token (never re-added by
/// `#2550`'s original reasoning: `std::fs::canonicalize` resolves
/// PHYSICALLY, following every symlink to its real target, while a real
/// shell's `cd` defaults to LOGICAL (`-L`) — `cd link/..` lands wherever
/// `link` pointed in bash, not one physical level up from where it
/// resolves. Refusing any `..` sidesteps that divergence entirely rather
/// than trying to emulate it.
///
/// Then PR1's real check, replacing #2550's lexical "does `<dir>` normalize
/// to the workspace root" with the actual question: does `<dir>`
/// canonicalize (through any symlink) to a real DIRECTORY inside both the
/// canonical workspace AND this call's fs-read fence
/// ([`crate::caveats::permits_path`], the SAME containment check the
/// interactive tool gate and the headless coder apply path both use)? A
/// missing directory, a file, an out-of-workspace absolute path, and a
/// symlink that resolves outside all fail identically to how a real `cd`
/// (or a real out-of-scope read) would refuse — this is the question `cd`
/// itself answers, not a parallel heuristic.
///
/// #2551 round 2 should-fix: the fence is checked on BOTH the lexical join
/// (`workspace.join(dir)`, matching how `read_file`'s own fs gate checks a
/// path — see `tools.rs`; a symlinked WORKSPACE root cannot itself mismatch
/// a fence granted by its original string) AND the canonical path (a
/// symlink INSIDE a granted root that points to a real directory OUTSIDE
/// the fence — `sub` granted, `sub/link` → `<root>/other` — used to pass
/// the lexical check alone and route with a `cwd` the fence never granted).
fn resolve_workspace_relative_dir(
    dir: &str,
    workspace: &Path,
    read_scope: &crate::caveats::Scope<String>,
) -> Option<String> {
    if dir.is_empty()
        || dir == "-"
        || dir.starts_with('~')
        || dir.contains(SHELL_META)
        || dir.contains(GLOB)
        || dir.contains(BUILD_UNSAFE)
        || dir.contains(char::is_whitespace)
        || Path::new(dir)
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
    {
        return None;
    }
    let lexical = workspace.join(dir);
    if !crate::caveats::permits_path(read_scope, &lexical.to_string_lossy()) {
        return None;
    }
    let canonical_workspace = workspace.canonicalize().ok()?;
    let canonical_dir = lexical.canonicalize().ok()?;
    if !canonical_dir.is_dir() || !canonical_dir.starts_with(&canonical_workspace) {
        return None;
    }
    // #2551 round 3 nit: the fence was granted against the (possibly
    // symlinked) WORKSPACE string, e.g. macOS `/tmp/ws` for a canonical
    // `/private/tmp/ws`. Checking `canonical_dir` against the un-canonicalized
    // `read_scope` then fails for every `<dir>`, including `.` — canonicalize
    // the scope's own roots the same way before checking the canonical side,
    // so a symlinked workspace root still folds `cd`s.
    if !crate::caveats::permits_path(
        &canonicalize_scope(read_scope),
        &canonical_dir.to_string_lossy(),
    ) {
        return None;
    }
    let relative = canonical_dir.strip_prefix(&canonical_workspace).ok()?;
    Some(if relative.as_os_str().is_empty() {
        ".".to_string()
    } else {
        relative.to_string_lossy().into_owned()
    })
}

/// Canonicalize each root in a read-fence scope, for comparing against an
/// already-canonicalized candidate path. A root that fails to canonicalize
/// (does not exist, dangling symlink) is kept as-is — it simply will not
/// match a canonical candidate, which is fail-closed, not a widening.
fn canonicalize_scope(scope: &crate::caveats::Scope<String>) -> crate::caveats::Scope<String> {
    match scope {
        crate::caveats::Scope::All => crate::caveats::Scope::All,
        crate::caveats::Scope::Only(set) => crate::caveats::Scope::only(set.iter().map(|root| {
            std::fs::canonicalize(root)
                .ok()
                .map(|p| p.to_string_lossy().into_owned())
                .unwrap_or_else(|| root.clone())
        })),
    }
}

/// Insert a resolved `cwd` into a route's args — the one seam both
/// [`RouteTable::classify`]'s leading-`cd` fold and [`RouteTable::
/// classify_call`]'s `cwd`-field fold use, so a routed call's `cwd` is
/// attached identically regardless of which shape asked for it.
///
/// #2551 round 2 BLOCKER: `cwd` is honoured only by `build_exec`
/// (`run_confined_build_lane`) —
/// `read_file`/`list_dir`/`find`/`delete_file` all join `workspace + path`
/// and never look at `args["cwd"]`. Attaching it to any OTHER route would
/// leave the built-in silently acting on `workspace` while the routed note
/// (and the model) believed it ran in the folded directory — measured: `cd
/// sub && rm x` would have deleted `<root>/x`, not `<root>/sub/x`. So a
/// resolved `cwd != "."` on any other route refuses (`Exec`) instead —
/// `cwd == "."` (the workspace root itself) is harmless for any tool, since
/// that IS where they already run.
fn attach_cwd(decision: RouteDecision, cwd: String) -> RouteDecision {
    let RouteDecision::Route { tool, mut args } = decision else {
        return decision;
    };
    if tool != "build_exec" {
        return if cwd == "." {
            RouteDecision::Route { tool, args }
        } else {
            RouteDecision::Exec
        };
    }
    if let Some(obj) = args.as_object_mut() {
        obj.insert("cwd".to_string(), Value::String(cwd));
    }
    RouteDecision::Route { tool, args }
}

/// Split a leading `cd <dir> && <rest>` or `cd <dir>; <rest>` — exactly ONE
/// `cd`, at the FIRST `&&` or `;` (whichever comes first): `cd a && cd b &&
/// x` yields `dir="a"`, `rest="cd b && x"`, and `rest` still contains `&&`
/// so [`RouteTable::classify_stripped`]'s ordinary compound-command refusal
/// catches the second `cd` — no separate "two `cd`s" check needed. `cd
/// <dir>` with no `&&`/`;` at all (nothing to run) and any other leading
/// token return `None` — command untouched.
fn split_leading_cd(command: &str) -> Option<(&str, &str)> {
    let after = command.strip_prefix("cd ")?;
    let amp = after.find("&&");
    let semi = after.find(';');
    let (dir, rest) = match (amp, semi) {
        (Some(a), Some(s)) if s < a => (&after[..s], &after[s + 1..]),
        (Some(a), _) => (&after[..a], &after[a + 2..]),
        (None, Some(s)) => (&after[..s], &after[s + 1..]),
        (None, None) => return None,
    };
    Some((dir.trim(), rest.trim_start()))
}

/// Fold a leading `cd <dir> && `/`cd <dir>; ` off `command` when `<dir>`
/// resolves inside the workspace and fence ([`resolve_workspace_relative_dir`]).
/// Returns `(effective_command, Some(relative_dir))` when folded,
/// `(command, None)` otherwise — subsumes #2550's workspace-root case
/// (`<dir>` resolves to `.`) under the same mechanism, so `cd <root> &&
/// cargo test` still routes, just via this path now.
fn fold_leading_cd<'a>(
    command: &'a str,
    workspace: &Path,
    read_scope: &crate::caveats::Scope<String>,
) -> (&'a str, Option<String>) {
    let Some((dir, rest)) = split_leading_cd(command) else {
        return (command, None);
    };
    match resolve_workspace_relative_dir(dir, workspace, read_scope) {
        Some(cwd) => (rest, Some(cwd)),
        None => (command, None),
    }
}

/// F27 (r14a evidence, `newt-eval/NOTES-2483.md`): strip a single trailing
/// `; echo <arg>` / `&& echo <arg>` whose argument merely reports `$?` (or
/// `${PIPESTATUS[0]}`) — a routed call already reports its REAL exit code,
/// so the echo adds nothing and its only effect was to keep the whole
/// command compound (`;`/`&&` is [`SHELL_META`]), forcing it to run
/// un-routed in the confined shell where `$?` after `cmd; echo …$?…` is
/// always 0 (the shell's own exit code), silently masking a real failure.
/// Also strips a trailing `2>&1` off what remains, mirroring
/// [`build_piped_to_trim_route`]'s own local strip — the r14a shape pairs
/// the two (`cargo test 2>&1; echo "EXIT: $?"`).
///
/// Returns `(command, false)` unchanged unless the echo's argument is
/// PROVABLY inert once dropped ([`is_inert_exit_status_arg`]) — an argument
/// with a command substitution or a redirect could have a real side effect
/// that must not silently vanish, and a bare `||` (not `&&`/`;`) or a second
/// `;`/`&&` surviving in what would become the build half both fall through
/// unchanged, refused downstream by the ordinary [`SHELL_META`] check
/// exactly as before this function existed. `(effective, true)` on a strip —
/// the `bool` lets [`RouteTable::classify`] flag the routed call
/// ([`mark_echo_dropped`]) so the rendered note can say so (#2554 round 2,
/// mirroring #2549's trim note and #2551's `cd`/`cwd` clause).
///
/// #2554 round 2 should-fix: also refuses when the KEPT half contains a
/// quote character. `split_trailing_echo`'s rightmost-separator search has
/// no notion of quoting, so a `;`/`&&` INSIDE a quoted argument can become
/// the "separator" (`rm "x; echo "$?""` — one `rm` of a file literally named
/// `x; echo "$?"` in the shell) and the kept half is then not provably the
/// command a real shell would run. The r14a evidence never quotes the build
/// half, so refusing any quote there costs nothing real.
fn strip_trailing_exit_echo(command: &str) -> (&str, bool) {
    let Some((build_part, echo_arg)) = split_trailing_echo(command) else {
        return (command, false);
    };
    if build_part.contains(['\'', '"']) {
        return (command, false);
    }
    if !is_inert_exit_status_arg(echo_arg) {
        return (command, false);
    }
    let build_part = build_part.trim();
    let build_part = build_part
        .strip_suffix("2>&1")
        .map_or(build_part, str::trim);
    (build_part, true)
}

/// Split `command` on its RIGHTMOST `&&` or `;`, only when the tail (after
/// trimming) is `echo <arg>` with a non-empty `<arg>`. `None` when there is
/// no trailing `echo` at all (including when the only separator is `||`,
/// which this never matches).
fn split_trailing_echo(command: &str) -> Option<(&str, &str)> {
    let amp = command.rfind("&&").map(|i| (i, i + 2));
    let semi = command.rfind(';').map(|i| (i, i + 1));
    let (sep_start, tail_start) = match (amp, semi) {
        (Some(a), Some(s)) => {
            if a.0 > s.0 {
                a
            } else {
                s
            }
        }
        (Some(a), None) => a,
        (None, Some(s)) => s,
        (None, None) => return None,
    };
    let build_part = &command[..sep_start];
    let echo_arg = command[tail_start..].trim_start().strip_prefix("echo ")?;
    let echo_arg = echo_arg.trim();
    if echo_arg.is_empty() {
        return None;
    }
    Some((build_part, echo_arg))
}

/// Is `arg` (the raw text after `echo `, still possibly quoted) safe to
/// drop entirely? `echo` itself has no side effect beyond stdout, so any
/// number of inert words is fine — the actual hazard is an argument that
/// hides a REAL side effect: a command substitution (`` ` `` / `$(`), a
/// redirect, a further `;`/`&&`/`|` smuggled inside, or — #2554 round 2
/// should-fix, the sharpest hole — a NEWLINE, which a real shell always
/// treats as starting a brand-new command, never more `echo` argument text.
/// Requires at least one reference to `$?` or `${PIPESTATUS[0]}` (otherwise
/// this isn't an exit-status echo at all — `echo "$HOME"` must stay
/// compound) and, once every such reference is removed, nothing from
/// [`SHELL_META`] (which already lists `\n`) or a bare `\r` may remain.
fn is_inert_exit_status_arg(arg: &str) -> bool {
    // Checked on the RAW argument, before any quote-unwrapping or `$?`
    // scrubbing: `echo EXIT=$?<LF>git add -A` scrubs to `<LF>git add -A`,
    // and a check that ran only on the scrubbed text could still miss a
    // newline hiding a real command — reject it outright here instead.
    if arg.contains(['\n', '\r']) {
        return false;
    }
    let inner = if let Some(unquoted) = arg.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
        // #2554 round 2 should-fix: a stray inner `"` (`"$?""` unwraps to
        // `$?"`) is not this shape either — refuse rather than guess which
        // half of a broken quote pairing the shell would have honoured.
        if unquoted.contains('"') {
            return false;
        }
        unquoted
    } else if arg.contains(['"', '\'']) {
        // An unmatched or single quote isn't this shape — refuse rather
        // than guess at shell quoting rules.
        return false;
    } else {
        arg
    };
    if !inner.contains("$?") && !inner.contains("${PIPESTATUS[0]}") {
        return false;
    }
    let scrubbed = inner.replace("${PIPESTATUS[0]}", "").replace("$?", "");
    !scrubbed
        .chars()
        .any(|c| SHELL_META.contains(&c) || c == '\r')
}

/// Flag a routed `build_exec` call as having had a trailing exit-code `echo`
/// dropped — read by `tools.rs`'s note so the model is told the `EXIT: N`
/// line it asked for was replaced by the lane's own (real) exit code,
/// rather than simply vanishing. A no-op for any other route: the flag only
/// has a rendered meaning on the build lane.
fn mark_echo_dropped(decision: RouteDecision) -> RouteDecision {
    match decision {
        RouteDecision::Route {
            tool: "build_exec",
            mut args,
        } => {
            if let Some(obj) = args.as_object_mut() {
                obj.insert("echo_dropped".to_string(), Value::Bool(true));
            }
            RouteDecision::Route {
                tool: "build_exec",
                args,
            }
        }
        other => other,
    }
}

/// F28 (#2483 evidence, `newt-eval/NOTES-2483.md` F28/F30): the model's own
/// natural instinct for a slow build was `timeout 300 cargo build -p
/// newt-core` — `timeout` classifies as an interpreter and raised a
/// high-danger permission prompt (denied outright headless), even though
/// the build lane already enforces its own wall
/// (`LIFECYCLE_BUILD_TIMEOUT` in `tools/shell.rs`, 30 min) — the
/// wrapper adds nothing and only blocks a routable build from routing.
///
/// Strips exactly ONE leading `timeout [OPTIONS]… <DURATION>` and returns
/// the rest of the command, trimmed. Recognises `-s SIG`/`--signal=SIG`,
/// `-k DUR`/`--kill-after=DUR`, `--preserve-status`, `--foreground` (any
/// order, zero or more), then exactly one plain `<DURATION>` token — digits
/// only, with an optional single trailing `s`/`m`/`h`/`d` suffix. `None`
/// when the leading token isn't `timeout`, an option isn't recognised, or
/// what follows the options isn't a plain duration (a quoted string, a `$`
/// variable, an expression) — the command is left untouched, refused
/// downstream exactly as before this function existed. Whether the
/// REMAINDER is itself routable (and so whether the strip actually applies)
/// is [`RouteTable::classify_stripped`]'s call, not this function's — this
/// is pure lexical stripping only.
fn strip_leading_timeout(command: &str) -> Option<(u64, &str)> {
    let after = command.strip_prefix("timeout")?;
    let mut rest = after.strip_prefix(char::is_whitespace)?.trim_start();
    loop {
        let (token, tail) = split_first_token(rest)?;
        match consume_timeout_option(token, tail) {
            Some(next_rest) => rest = next_rest.trim_start(),
            None => break,
        }
    }
    let (duration, tail) = split_first_token(rest)?;
    if !is_plain_duration(duration) {
        return None;
    }
    let remainder = tail.trim_start();
    if remainder.is_empty() {
        return None;
    }
    Some((duration_secs(duration), remainder))
}

/// Parse a value [`is_plain_duration`] already validated (digits, optional
/// trailing `s`/`m`/`h`/`d`) into whole seconds — GNU `timeout`'s own units.
/// No suffix means seconds, `timeout`'s own default.
fn duration_secs(token: &str) -> u64 {
    let (digits, multiplier) = match token.chars().last() {
        Some('s') => (&token[..token.len() - 1], 1),
        Some('m') => (&token[..token.len() - 1], 60),
        Some('h') => (&token[..token.len() - 1], 3600),
        Some('d') => (&token[..token.len() - 1], 86400),
        _ => (token, 1),
    };
    digits
        .parse::<u64>()
        .unwrap_or(0)
        .saturating_mul(multiplier)
}

/// Split `s` (already left-trimmed by the caller where it matters) into its
/// first whitespace-delimited token and everything after it. `None` for an
/// empty/all-whitespace `s`.
fn split_first_token(s: &str) -> Option<(&str, &str)> {
    let s = s.trim_start();
    if s.is_empty() {
        return None;
    }
    match s.find(char::is_whitespace) {
        Some(i) => Some((&s[..i], &s[i..])),
        None => Some((s, "")),
    }
}

/// Is `token` one of `timeout`'s recognised OPTIONS, and if so, what remains
/// of the command after consuming it (and its value, for the two flags that
/// take one as a SEPARATE next token)? `None` for anything else, including
/// an option `timeout` supports but this grammar doesn't model (fused short
/// forms like `-sTERM`) — refusing rather than guessing keeps the strip
/// faithful to the exact grammar the brief specifies.
fn consume_timeout_option<'a>(token: &str, tail: &'a str) -> Option<&'a str> {
    match token {
        "--preserve-status" | "--foreground" => Some(tail),
        "-s" => {
            split_first_token(tail).and_then(|(value, rest)| is_plain_signal(value).then_some(rest))
        }
        "-k" => split_first_token(tail)
            .and_then(|(value, rest)| is_plain_duration(value).then_some(rest)),
        _ if token.starts_with("--signal=") => {
            is_plain_signal(&token["--signal=".len()..]).then_some(tail)
        }
        _ if token.starts_with("--kill-after=") => {
            is_plain_duration(&token["--kill-after=".len()..]).then_some(tail)
        }
        _ => None,
    }
}

/// Is `token` a plain `timeout` `-s`/`--signal` SIGNAL — letters and digits
/// only (`TERM`, `SIGKILL`, `9`)? No metacharacter can hide in a value this
/// narrow, so no separate refusal is needed downstream (Blocker 1, PR-F28
/// review): a metacharacter here — `$(...)`, `` ` ``, `;`, `|`, `>`, `&`, a
/// quote — used to be accepted verbatim and silently vanish once the
/// `timeout` prefix was stripped, discarding a command substitution the real
/// shell would have run.
fn is_plain_signal(token: &str) -> bool {
    !token.is_empty() && token.bytes().all(|b| b.is_ascii_alphanumeric())
}

/// Is `token` a plain `timeout` DURATION — digits only, with an optional
/// single trailing `s`/`m`/`h`/`d` suffix? Never a quoted string, a `$`
/// variable, or an expression: those need a real shell to resolve, so the
/// command they'd appear in stays compound/`Exec`.
fn is_plain_duration(token: &str) -> bool {
    let digits = match token.chars().last() {
        Some('s' | 'm' | 'h' | 'd') => &token[..token.len() - 1],
        _ => token,
    };
    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
}

/// Carry a routed `build_exec` call's leading `timeout N …` duration as
/// `timeout_secs` — read by `tools.rs`'s `build_exec` arm, which applies
/// `min(timeout_secs, LIFECYCLE_BUILD_TIMEOUT)` as the lane's ACTUAL wall,
/// rather than round 1's `timeout_dropped: true` flag, which discarded the
/// model's own (possibly shorter) bound entirely (PR-F28 review, Blocker 2).
/// Mirrors [`mark_echo_dropped`]'s shape.
fn mark_timeout_secs(decision: RouteDecision, timeout_secs: u64) -> RouteDecision {
    match decision {
        RouteDecision::Route {
            tool: "build_exec",
            mut args,
        } => {
            if let Some(obj) = args.as_object_mut() {
                obj.insert(
                    "timeout_secs".to_string(),
                    Value::Number(timeout_secs.into()),
                );
            }
            RouteDecision::Route {
                tool: "build_exec",
                args,
            }
        }
        other => other,
    }
}

/// Recognise EXACTLY `<clean build argv> [2>&1] | tail -N` (or `tail -n N` /
/// `head -N` / `head -n N`) and nothing wider (F23 / #2524
/// "tail-pipe-routes"). Measured (2488-r9): the model's own natural call to
/// verify a build was `cargo … 2>&1 | tail -40` — piping ONLY to cut a long
/// build's output, not to chain semantics onto it. Because the pipe made it
/// compound, it refused to route (`SHELL_META`) and ran in the confined
/// shell instead, where `| tail` masks cargo's real exit code — exactly the
/// hazard `note_verified_pass`'s doc comment (`mod.rs`) names as why a
/// piped `run_command` pass can never count as verified. Route the build
/// argv the model actually wrote (`build_lane_route`, unchanged, still
/// refuses an unsafe operand or a non-build-tool program) and apply the
/// trim to the OUTPUT on the harness side instead, so the exit code the
/// harness sees is cargo's, never `tail`'s.
///
/// Returns `RouteDecision::Exec` for anything this exact shape does not
/// cover: more than one pipe, a pipe to anything but bare `tail`/`head`
/// with a positive line count, `tail -f` / `head -c`, or a redirect/other
/// metacharacter surviving in the build half (checked via the existing
/// [`SHELL_META`] table — reused, not duplicated) — those fall through to
/// the ordinary compound-command refusal in [`RouteTable::classify`].
fn build_piped_to_trim_route(command: &str) -> RouteDecision {
    let Some((build_part, trim_part)) = split_single_pipe(command) else {
        return RouteDecision::Exec;
    };
    let build_part = build_part.trim();
    let build_part = build_part
        .strip_suffix("2>&1")
        .map_or(build_part, str::trim);
    // Any OTHER shell metacharacter surviving in the build half (a redirect
    // BEFORE the pipe, a second chain, …) refuses — same table the blanket
    // compound-command check uses, so a redirect stays refused exactly as
    // today.
    if build_part.contains(SHELL_META) {
        return RouteDecision::Exec;
    }
    let Some(trim) = parse_trim_spec(trim_part.trim()) else {
        return RouteDecision::Exec;
    };
    let mut tokens = build_part.split_ascii_whitespace();
    let Some(program) = tokens.next() else {
        return RouteDecision::Exec;
    };
    if !is_build_tool_program(program) {
        return RouteDecision::Exec;
    }
    let rest: Vec<&str> = tokens.collect();
    match build_lane_route(program, &rest) {
        RouteDecision::Route {
            tool: "build_exec",
            mut args,
        } => {
            args["trim"] = trim;
            RouteDecision::Route {
                tool: "build_exec",
                args,
            }
        }
        // `build_lane_route` itself refused (unsafe operand, unrecognised
        // subcommand, …) — the same refusal applies with or without the
        // trailing pipe.
        other => other,
    }
}

/// Split `command` on exactly ONE `|`. `None` for zero pipes or more than
/// one — the brief's own "multiple pipes … do NOT route" refusal, checked
/// here rather than downstream so a `| tail -40 | head` never even reaches
/// [`parse_trim_spec`].
fn split_single_pipe(command: &str) -> Option<(&str, &str)> {
    let mut parts = command.split('|');
    let build_part = parts.next()?;
    let trim_part = parts.next()?;
    if parts.next().is_some() {
        return None;
    }
    Some((build_part, trim_part))
}

/// `tail -N` / `tail -n N` / `head -N` / `head -n N` — a bare positive
/// integer line count and nothing else. `tail -f` (follow), `head -c`
/// (bytes), extra operands, or a missing/zero/non-numeric count are all
/// refused — this is the ONE place that decides the trim shape is safe to
/// apply, so it stays conservative rather than guessing at intent.
pub(super) fn parse_trim_spec(spec: &str) -> Option<Value> {
    let tokens: Vec<&str> = spec.split_ascii_whitespace().collect();
    let mode = match tokens.first().copied() {
        Some("tail") => "tail",
        Some("head") => "head",
        _ => return None,
    };
    let n_str = match &tokens[1..] {
        [flag] if flag.len() > 1 && flag.starts_with('-') => &flag[1..],
        ["-n", n] => *n,
        _ => return None,
    };
    // PR #2549 round 2: `u32::from_str` tolerates a leading `+` (unsigned
    // parsers reject `-`, not `+`), so `tail -n +40` / `head -n +40` — GNU's
    // "start AT line 40", a real and DIFFERENT flag shape from "last/first
    // 40 lines" — parsed as if it meant `-n 40`, showing the wrong output
    // slice under a false "as `| tail -40` asked" provenance claim.
    // `head -n +N` isn't even documented by GNU head, so treating it as `-n
    // N` had no basis either way. Check the STRING, not `parse`'s leniency:
    // every byte must be an ASCII digit — refuses `+40`, a stray `-` inside
    // the `-n` form, and any other non-digit that `u32::from_str` might
    // someday tolerate.
    if n_str.is_empty() || !n_str.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    let n: u32 = n_str.parse().ok()?;
    if n == 0 {
        return None;
    }
    Some(json!({ "mode": mode, "n": n }))
}

/// A `run_command` reach that maps onto the confined **build lane**
/// (`build_exec` in `tools.rs`, sharing `run_confined_build_lane` with
/// `lifecycle action=build`) rather than a governed read built-in. Unlike
/// every other route in this table, the routed call runs the model's
/// **literal argv verbatim** — never a re-resolved phase command — so no
/// operand (`-p x`, a test filter, …) is ever silently dropped. A `cwd`
/// (from a leading `cd` or a `cwd` field) is resolved and attached by
/// [`RouteTable::classify`]/[`RouteTable::classify_call`] (PR1); `timeout`
/// still has nowhere to go, so a call carrying it stays Exec.
fn build_lane_route(program: &str, rest: &[&str]) -> RouteDecision {
    // See `BUILD_UNSAFE`: a quoted, escaped, `~`-relative or globbed operand
    // cannot be routed faithfully (there is no shell downstream to expand
    // it), so gate the whole call to exec rather than run a mangled argv.
    if rest.iter().any(|tok| tok.contains(BUILD_UNSAFE)) {
        return RouteDecision::Exec;
    }
    match program {
        "cargo" => cargo_build_route(rest),
        "just" => just_build_route(rest),
        _ => RouteDecision::Exec,
    }
}

/// `cargo build|check|test|clippy [args…]`. `--config`/`-Z…` change what
/// cargo does (config override / unstable flags outside the calibrated
/// fence) and never route; translation must not widen or drop arguments.
fn cargo_build_route(rest: &[&str]) -> RouteDecision {
    // #2524 follow-up (F23 evidence): 2488-r9's own command was `cargo
    // +stable test …` — cargo's leading `+toolchain` selector, ONE token,
    // before the subcommand. Accept it and KEEP it in the routed argv (the
    // build lane must run the SAME toolchain the model asked for, never a
    // silently different default one). A `+` token that isn't a valid
    // selector (`is_toolchain_selector`) refuses the whole call rather than
    // stripping it and running something else.
    let (toolchain, rest) = match rest.split_first() {
        Some((first, tail)) if first.starts_with('+') => {
            if !is_toolchain_selector(first) {
                return RouteDecision::Exec;
            }
            (Some(*first), tail)
        }
        _ => (None, rest),
    };
    let Some((sub, rest)) = rest.split_first() else {
        return RouteDecision::Exec;
    };
    if !CARGO_BUILD_SUBCOMMANDS.contains(sub) {
        return RouteDecision::Exec;
    }
    if rest
        .iter()
        .any(|tok| *tok == "--config" || tok.starts_with("--config=") || tok.starts_with("-Z"))
    {
        return RouteDecision::Exec;
    }
    let mut argv = vec!["cargo".to_string()];
    argv.extend(toolchain.map(str::to_string));
    argv.push((*sub).to_string());
    argv.extend(rest.iter().map(|t| (*t).to_string()));
    RouteDecision::Route {
        tool: "build_exec",
        args: json!({ "argv": argv }),
    }
}

/// Is `token` a valid cargo/rustup toolchain selector (`+stable`,
/// `+nightly`, `+1.85.0`, `+nightly-2026-09-01`)? The name after `+` is
/// restricted to `[A-Za-z0-9._-]+` — rustup toolchain names are a channel,
/// an optional date, and an optional target triple, dot/dash-separated;
/// nothing in that alphabet is a shell metacharacter, so this can never
/// smuggle one past `BUILD_UNSAFE`/`SHELL_META`. A bare `+`, an empty name,
/// or anything else refuses — see [`cargo_build_route`], which refuses the
/// WHOLE call on a `false` here rather than silently dropping the token.
///
/// `pub(crate)` (#2524 follow-up / #2548 interaction): `mod.rs`'s
/// `is_progress_verification` reads `routed argv[1]` as the gate
/// subcommand — for a routed `cargo +stable test`, that slot is `+stable`,
/// not `test`, so a genuine pass would silently never count without also
/// skipping the selector there. ONE rule, shared, not re-derived.
pub(crate) fn is_toolchain_selector(token: &str) -> bool {
    match token.strip_prefix('+') {
        Some(name) if !name.is_empty() => name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
        _ => false,
    }
}

/// `just <recipe>` — a single non-flag recipe name, no other operands.
/// Whether a justfile actually exists is a filesystem fact this pure
/// classifier cannot see; the dispatch site (`tools.rs`'s `build_exec` arm)
/// checks it before running and refuses if there is none.
fn just_build_route(rest: &[&str]) -> RouteDecision {
    match rest {
        [recipe] if !recipe.starts_with('-') => RouteDecision::Route {
            tool: "build_exec",
            args: json!({ "argv": ["just", recipe] }),
        },
        _ => RouteDecision::Exec,
    }
}

/// Translate a whole-command shell reach's operands into the governed built-in's
/// argument shape — the pure per-tool rule. Returns [`RouteDecision::Exec`]
/// whenever the shape is ambiguous (so a routed call is always faithful to the
/// command the model typed).
fn build_shell_route(tool: &'static str, rest: &[&str]) -> RouteDecision {
    match tool {
        // read_file reads exactly ONE file. Zero or many operands, or a glob
        // (which the shell would expand to a set), are ambiguous → gate.
        "read_file" => match operands(rest).as_slice() {
            [path] if !path.contains(GLOB) => RouteDecision::Route {
                tool,
                args: json!({ "path": path }),
            },
            _ => RouteDecision::Exec,
        },
        // list_dir lists ONE directory; a bare `ls` is the workspace (".").
        "list_dir" => match operands(rest).as_slice() {
            [] => RouteDecision::Route {
                tool,
                args: json!({ "path": "." }),
            },
            [path] if !path.contains(GLOB) => RouteDecision::Route {
                tool,
                args: json!({ "path": path }),
            },
            _ => RouteDecision::Exec,
        },
        "find" => build_find_route(rest),
        "delete_file" => build_delete_route(rest),
        // Unreachable for SHELL_ROUTES, but keep the rule total.
        _ => RouteDecision::Exec,
    }
}

/// The non-flag operands of a command's argument list (tokens not starting with
/// `-`). Flags like `cat -n` / `ls -la` are dropped — the built-in renders the
/// content/listing regardless.
fn operands<'a>(rest: &[&'a str]) -> Vec<&'a str> {
    rest.iter()
        .copied()
        .filter(|t| !t.starts_with('-'))
        .collect()
}

/// Translate `rm [-f] <path>` / `unlink <path>` into `delete_file`.
/// Recursive/tree deletes, multiple operands, globs, and unknown flags stay on
/// the exec path under its command grants and native filesystem confinement.
fn build_delete_route(rest: &[&str]) -> RouteDecision {
    let mut operands = Vec::new();
    let mut end_of_flags = false;
    for token in rest {
        if !end_of_flags && *token == "--" {
            end_of_flags = true;
            continue;
        }
        if !end_of_flags && token.starts_with('-') {
            if token.chars().skip(1).all(|c| c == 'f') {
                continue;
            }
            return RouteDecision::Exec;
        }
        operands.push(*token);
    }
    match operands.as_slice() {
        [path] if !path.contains(GLOB) => RouteDecision::Route {
            tool: "delete_file",
            args: json!({ "path": path }),
        },
        _ => RouteDecision::Exec,
    }
}

/// Translate a `find [path] [-name PAT] [-type f|d]` reach into the embedded
/// `find` tool's args. Any predicate the embedded tool does not model (e.g.
/// `-newer`, `-exec`) gates, so a routed `find` never silently drops a filter
/// that would change the result set.
fn build_find_route(rest: &[&str]) -> RouteDecision {
    let mut path = ".";
    let mut name: Option<&str> = None;
    let mut type_filter: Option<&str> = None;

    // A leading non-flag token is the search root.
    let mut i = 0;
    if let Some(first) = rest.first() {
        if !first.starts_with('-') {
            path = first;
            i = 1;
        }
    }
    while i < rest.len() {
        match rest[i] {
            "-name" | "-iname" => match rest.get(i + 1).copied() {
                Some(v) => {
                    name = Some(strip_quotes(v));
                    i += 2;
                }
                None => return RouteDecision::Exec,
            },
            "-type" => match rest.get(i + 1).copied() {
                Some(v @ ("f" | "d")) => {
                    type_filter = Some(v);
                    i += 2;
                }
                _ => return RouteDecision::Exec,
            },
            // An unmodeled predicate → let the shell's `find` handle it.
            _ => return RouteDecision::Exec,
        }
    }
    let mut args = serde_json::Map::new();
    args.insert("path".into(), json!(path));
    if let Some(n) = name {
        args.insert("name".into(), json!(n));
    }
    if let Some(t) = type_filter {
        args.insert("type".into(), json!(t));
    }
    RouteDecision::Route {
        tool: "find",
        args: Value::Object(args),
    }
}

/// Strip a single pair of matching surrounding quotes (`'…'` or `"…"`) from a
/// token, so a shell-quoted `-name '*.rs'` yields the bare `*.rs` glob the
/// embedded `find` tool expects.
fn strip_quotes(token: &str) -> &str {
    let bytes = token.as_bytes();
    if bytes.len() >= 2
        && (bytes[0] == b'\'' || bytes[0] == b'"')
        && bytes[bytes.len() - 1] == bytes[0]
    {
        &token[1..token.len() - 1]
    } else {
        token
    }
}

/// The audit line for a routing decision — **pure** and testable, the single
/// source of truth for the §4.4 audit log (`tools::execute_tool` emits this
/// via `tracing::debug!` on every silent rewrite). Returns `None` for an
/// [`RouteDecision::Exec`] (nothing was rewritten, nothing to log).
#[must_use]
pub(crate) fn audit_line(original: &str, decision: &RouteDecision) -> Option<String> {
    match decision {
        RouteDecision::Route { tool, args } => Some(format!(
            "facade P4 routing: rewrote `{original}` → {tool} {args}"
        )),
        RouteDecision::Exec => None,
    }
}

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

    /// A workspace no test command's `cd <dir> &&` prefix (if any) could
    /// ever resolve against (it does not exist on disk), so PR1's `cd`-fold
    /// never fires for a test that doesn't ask for it explicitly (via
    /// [`classify_at`]/[`CdFixture`]) — and for a command with no leading
    /// `cd` at all, `fold_leading_cd` never touches the filesystem, so a
    /// nonexistent path costs nothing.
    fn no_cd_match() -> &'static Path {
        Path::new("/never-matches-a-test-cwd")
    }

    fn classify(cmd: &str) -> RouteDecision {
        RouteTable::builtin().classify(cmd, no_cd_match(), &crate::caveats::Scope::All)
    }

    fn classify_at(
        cmd: &str,
        workspace: &Path,
        read_scope: &crate::caveats::Scope<String>,
    ) -> RouteDecision {
        RouteTable::builtin().classify(cmd, workspace, read_scope)
    }

    /// A real workspace tree for PR1's `cd`-fold tests: `root/`, `root/sub/`
    /// (a real subdirectory to `cd` into), and `root/file.txt` (exists, but
    /// is not a directory). The brief's own boundary needs a REAL
    /// filesystem — `std::fs::canonicalize` is the question a real `cd`
    /// answers, and no mock stands in for it.
    struct CdFixture {
        _dir: tempfile::TempDir,
        root: std::path::PathBuf,
        #[allow(dead_code)]
        sub: std::path::PathBuf,
    }

    impl CdFixture {
        fn new() -> Self {
            let dir = tempfile::TempDir::new().expect("tempdir");
            // Canonicalize up front: a platform tempdir may itself be
            // reached through a symlink (macOS: `/tmp` → `/private/tmp`),
            // and every assertion below must compare against the SAME
            // canonical form `resolve_workspace_relative_dir` produces.
            let root = dir.path().canonicalize().expect("canonicalize tempdir");
            let sub = root.join("sub");
            std::fs::create_dir(&sub).expect("mkdir sub");
            std::fs::write(root.join("file.txt"), b"not a directory").expect("write file");
            // #2551 round 2 BLOCKER fixture: `x`/`f` at BOTH the root and
            // `sub/`, distinct content, so a test that reads/deletes the
            // WRONG one is provably wrong rather than accidentally right.
            std::fs::write(root.join("x"), b"root x").expect("write root x");
            std::fs::write(root.join("f"), b"root f").expect("write root f");
            std::fs::write(sub.join("x"), b"sub x").expect("write sub x");
            std::fs::write(sub.join("f"), b"sub f").expect("write sub f");
            Self {
                _dir: dir,
                root,
                sub,
            }
        }

        fn read_scope(&self) -> crate::caveats::Scope<String> {
            crate::caveats::Scope::only([self.root.to_string_lossy().into_owned()])
        }
    }

    /// TDD: `cat <path>` is a silent Rewrite to the governed `read_file`
    /// built-in (the routing promotion). Revert the promotion and this is red.
    #[test]
    fn cat_routes_to_read_file() {
        assert_eq!(
            classify("cat src/main.rs"),
            RouteDecision::Route {
                tool: "read_file",
                args: json!({ "path": "src/main.rs" }),
            }
        );
        // A leading flag is dropped; the single operand still routes.
        assert_eq!(
            classify("cat -n src/main.rs"),
            RouteDecision::Route {
                tool: "read_file",
                args: json!({ "path": "src/main.rs" }),
            }
        );
    }

    #[test]
    fn native_git_commands_keep_flags_revisions_pathspecs_and_cwd() {
        let fx = CdFixture::new();
        let table = RouteTable::builtin();
        for command in [
            "git status",
            "git status --porcelain=v1 --untracked-files=all",
            "git log -5 A..B -- src/lib.rs docs/",
            "git log HEAD~1...HEAD -- 'src/a b.rs'",
            "git diff --cached --stat HEAD~1 -- src/lib.rs",
            "git diff --word-diff A B -- '*.rs'",
            "git branch",
            "git branch --all",
            "git branch --remotes",
            "git -C sub status",
            "git --git-dir=sub/.git status",
            "cd sub && git status",
            "cd sub; git log -n 5 -- src/lib.rs",
        ] {
            for extra in [json!({}), json!({"cwd": "sub"}), json!({"timeout": 5})] {
                let mut call = extra;
                call["command"] = json!(command);
                assert_eq!(
                    table.classify_call(&call, &fx.root, &fx.read_scope()),
                    RouteDecision::Exec,
                    "preserve the original shell request: {call}"
                );
            }
        }
    }

    #[test]
    fn read_only_git_uses_native_exec() {
        for cmd in ["git status", "git log", "git diff"] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    #[test]
    fn git_read_commands_preserve_operands() {
        for cmd in [
            "git status -s",
            "git status --porcelain",
            "git diff --cached",
            "git diff --cached --stat",
            "git diff --staged",
            "git log -5",
            "git log -n 5",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    #[test]
    fn git_reads_preserve_revisions_pathspecs_and_stat() {
        for cmd in [
            "git log A..B",
            "git log HEAD~3",
            "git log -5 A..B -- src/lib.rs",
            "git log -- src/lib.rs docs/",
            "git diff HEAD~1",
            "git diff A B",
            "git diff A..B",
            "git diff --stat",
            "git diff --stat HEAD~1 -- src/lib.rs",
            "git log HEAD~1...HEAD",
            "git log src/lib.rs",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    /// Presentation options also belong to native Git, including shapes
    /// the specialized interface never represented.
    #[test]
    fn git_presentation_options_stay_on_native_exec() {
        assert_eq!(classify("git log --oneline"), RouteDecision::Exec);
        assert_eq!(classify("git log -p"), RouteDecision::Exec);
        assert_eq!(classify("git log -p A..B"), RouteDecision::Exec);
        assert_eq!(classify("git diff --word-diff"), RouteDecision::Exec);
        assert_eq!(classify("git diff --name-only"), RouteDecision::Exec);
        assert_eq!(classify("git diff A B C"), RouteDecision::Exec);
        assert_eq!(classify("git log --all"), RouteDecision::Exec);
        assert_eq!(classify("git diff --numstat"), RouteDecision::Exec);
    }

    #[test]
    fn branch_listings_use_native_exec() {
        for cmd in [
            "git branch",
            "git branch --list",
            "git branch -a",
            "git branch --all",
            "git branch --list --all",
            "git branch -a --list",
            "git branch -r",
            "git branch --remotes",
            "git branch --list -r",
            "git branch --remotes --list",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    #[test]
    fn unrouted_calls_preserve_extra_arguments() {
        let table = RouteTable::builtin();
        for command in ["git branch", "git branch --all", "git branch --remotes"] {
            assert_eq!(
                table.classify_call(
                    &json!({"command": command}),
                    no_cd_match(),
                    &crate::caveats::Scope::All
                ),
                classify(command)
            );
            // Call-level fields stay with the original command too.
            for extra in [json!({"cwd": "elsewhere"}), json!({"timeout": 5})] {
                let mut call = extra;
                call["command"] = json!(command);
                assert_eq!(
                    table.classify_call(&call, no_cd_match(), &crate::caveats::Scope::All),
                    RouteDecision::Exec,
                    "{call}"
                );
            }
        }
        // A non-build route cannot honor `cwd`; keep the entire call on
        // exec rather than silently discarding the requested directory.
        for command in ["git status", "cat file", "ls"] {
            assert_eq!(
                table.classify_call(
                    &json!({"command": command, "cwd": "elsewhere"}),
                    no_cd_match(),
                    &crate::caveats::Scope::All
                ),
                RouteDecision::Exec,
                "{command}"
            );
        }
    }

    #[test]
    fn branch_mutations_and_unrepresented_filters_never_become_listing() {
        for cmd in [
            "git branch topic",
            "git branch topic HEAD",
            "git branch -d topic",
            "git branch -D topic",
            "git branch -m old new",
            "git branch -M topic",
            "git branch -c old new",
            "git branch -C topic",
            "git branch --set-upstream-to=origin/main",
            "git branch --unset-upstream",
            "git branch --track topic origin/main",
            "git branch --edit-description",
            "git branch --list topic",
            "git branch --list 'feat/*'",
            "git branch --merged",
            "git branch --no-merged main",
            "git branch --contains HEAD",
            "git branch --format=%(refname)",
            "git branch --sort=-committerdate",
            "git branch --show-current",
            "git branch --unknown",
            "git branch -a -r",
            "git branch -ar",
            "git -C other branch",
            "git --git-dir=other branch",
            "git branch -a | wc -l",
            "git branch -a > branches.txt",
            "git branch --all && git branch topic",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    /// Native mutations and unknown subcommands retain the exec gate.
    #[test]
    fn state_modifying_git_gates_as_exec() {
        for cmd in [
            "git add a.txt",
            "git add .",
            "git commit -m x",
            "git push",
            "git checkout -b feat",
            "git reset --hard",
            "git stash",
            "git show HEAD",
            "git branch topic",
            // a bare / unknown git reach gates.
            "git",
            "git frobnicate",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    /// `ls` routes to `list_dir`; bare `ls` is the workspace root.
    #[test]
    fn ls_routes_to_list_dir() {
        assert_eq!(
            classify("ls"),
            RouteDecision::Route {
                tool: "list_dir",
                args: json!({ "path": "." }),
            }
        );
        assert_eq!(
            classify("ls -la src"),
            RouteDecision::Route {
                tool: "list_dir",
                args: json!({ "path": "src" }),
            }
        );
    }

    /// `find` routes to the embedded `find` tool, translating `-name`/`-type`.
    #[test]
    fn find_routes_with_translated_predicates() {
        assert_eq!(
            classify("find . -name '*.rs' -type f"),
            RouteDecision::Route {
                tool: "find",
                args: json!({ "path": ".", "name": "*.rs", "type": "f" }),
            }
        );
        // Bare `find` → workspace root, no filters.
        assert_eq!(
            classify("find"),
            RouteDecision::Route {
                tool: "find",
                args: json!({ "path": "." }),
            }
        );
        // An unmodeled predicate gates rather than silently dropping a filter.
        assert_eq!(classify("find . -newer ref.txt"), RouteDecision::Exec);
    }

    /// #1022: a simple shell-delete instinct routes to the governed fs_write
    /// tool instead of dead-ending on exec/absolute-deny. Recursive or
    /// multi-target deletes retain native shell semantics and authority checks.
    #[test]
    fn simple_delete_routes_to_delete_file() {
        for cmd in [
            "rm src/cockpit.rs",
            "rm -f src/cockpit.rs",
            "unlink src/cockpit.rs",
        ] {
            assert_eq!(
                classify(cmd),
                RouteDecision::Route {
                    tool: "delete_file",
                    args: json!({ "path": "src/cockpit.rs" }),
                },
                "{cmd}"
            );
        }
        for cmd in [
            "rm -rf src",
            "rm -r src",
            "rm a.txt b.txt",
            "rm *.tmp",
            "unlink a.txt b.txt",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    /// Compound / redirected / substituted commands NEVER route — a single
    /// built-in cannot reproduce a pipe/chain/redirect, so they gate and the
    /// confined shell mediates each spawn. This is the routing-is-not-a-bypass
    /// guard at the decision layer.
    #[test]
    fn compound_commands_gate_not_route() {
        for cmd in [
            "cat secret | grep token",
            "cat a && rm -rf b",
            "cat $(echo /etc/passwd)",
            "ls > out.txt",
            "cat a; cat b",
            "cat `whoami`",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    /// A glob operand needs shell expansion → gate (the built-in cannot expand
    /// `*.txt` into a set).
    #[test]
    fn glob_operands_gate() {
        assert_eq!(classify("cat *.txt"), RouteDecision::Exec);
        assert_eq!(classify("ls sub/*"), RouteDecision::Exec);
        // `cat a b` (two files) is ambiguous for the one-file read_file → gate.
        assert_eq!(classify("cat a.txt b.txt"), RouteDecision::Exec);
    }

    /// Non-routable programs (echo, grep, an interpreter) gate — only the
    /// data-table reaches route.
    #[test]
    fn unknown_programs_gate() {
        for cmd in ["echo hi", "grep foo bar", "bash script.sh", ""] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd:?}");
        }
    }

    /// F11: `cargo build|check|test|clippy` routes to `build_exec` carrying
    /// the model's LITERAL argv — never a resolved phase command, so no
    /// operand (`-p newt-core`, a test filter, …) is dropped.
    #[test]
    fn cargo_build_commands_route_with_literal_argv() {
        assert_eq!(
            classify("cargo test -p newt-core"),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({ "argv": ["cargo", "test", "-p", "newt-core"] }),
            }
        );
        for (cmd, argv) in [
            ("cargo build", json!(["cargo", "build"])),
            ("cargo check", json!(["cargo", "check"])),
            (
                "cargo clippy --workspace",
                json!(["cargo", "clippy", "--workspace"]),
            ),
            (
                "cargo test -p x --lib -- --test-threads=1",
                json!([
                    "cargo",
                    "test",
                    "-p",
                    "x",
                    "--lib",
                    "--",
                    "--test-threads=1"
                ]),
            ),
        ] {
            assert_eq!(
                classify(cmd),
                RouteDecision::Route {
                    tool: "build_exec",
                    args: json!({ "argv": argv }),
                },
                "{cmd}"
            );
        }
    }

    /// `just <recipe>` routes the same way; anything more than a bare recipe
    /// name gates rather than guessing.
    #[test]
    fn just_recipe_routes_with_literal_argv() {
        assert_eq!(
            classify("just test"),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({ "argv": ["just", "test"] }),
            }
        );
        for cmd in ["just", "just test extra", "just -n test", "just --list"] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    /// Near-misses that change what runs, or reach outside the calibrated
    /// fence, must NEVER route: `cargo run`/`install`/`publish` execute or
    /// publish something; `--config`/`-Z…` are config overrides / unstable
    /// flags. `cargo test | tail` is compound and already gated by
    /// `SHELL_META`, exercised here for this route specifically.
    #[test]
    fn cargo_near_misses_never_route() {
        for cmd in [
            "cargo run",
            "cargo install ripgrep",
            "cargo publish",
            "cargo test --config net.offline=false",
            "cargo build -Zunstable-options",
            "cargo test | tail",
            "cargo",
            "cargo frobnicate",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    /// #2524 follow-up (F23 evidence): a leading `+toolchain` selector
    /// routes, kept verbatim in the argv, for every valid rustup toolchain
    /// name shape.
    #[test]
    fn cargo_toolchain_selector_routes_and_is_kept_in_the_argv() {
        assert_eq!(
            classify("cargo +stable test"),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({ "argv": ["cargo", "+stable", "test"] }),
            }
        );
        for (cmd, argv) in [
            (
                "cargo +nightly build",
                json!(["cargo", "+nightly", "build"]),
            ),
            ("cargo +1.85.0 check", json!(["cargo", "+1.85.0", "check"])),
            (
                "cargo +nightly-2026-09-01 clippy",
                json!(["cargo", "+nightly-2026-09-01", "clippy"]),
            ),
        ] {
            assert_eq!(
                classify(cmd),
                RouteDecision::Route {
                    tool: "build_exec",
                    args: json!({ "argv": argv }),
                },
                "{cmd}"
            );
        }
    }

    /// An invalid or empty `+` token refuses the WHOLE call — never
    /// silently dropped, never routed with a different toolchain than the
    /// model asked for.
    #[test]
    fn cargo_invalid_toolchain_selector_never_routes() {
        for cmd in [
            "cargo + test",
            // `$` is a SHELL_META character; also caught by the top-level
            // compound-command refusal before this ever reaches
            // `cargo_build_route`, but exercised here for this shape too.
            "cargo +$X test",
            "cargo +stable/../etc test",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    /// A build-lane route drops the rest of the call object (there is
    /// nowhere to put `timeout`), so `classify_call` must refuse to route a
    /// call carrying it. `cwd` is
    /// NOT one of these any more (PR1) — see
    /// [`a_cwd_field_on_a_bare_call_routes_the_same_way`].
    #[test]
    fn build_route_call_requires_bare_call() {
        let table = RouteTable::builtin();
        assert_eq!(
            table.classify_call(
                &json!({"command": "cargo test"}),
                no_cd_match(),
                &crate::caveats::Scope::All
            ),
            classify("cargo test")
        );
        let call = json!({"command": "cargo test", "timeout": 5});
        assert_eq!(
            table.classify_call(&call, no_cd_match(), &crate::caveats::Scope::All),
            RouteDecision::Exec,
            "{call}"
        );
    }

    /// PR1: a model-supplied `cwd` FIELD on an otherwise-bare call is the
    /// SAME question a leading `cd` asks, resolved the SAME way.
    #[test]
    fn a_cwd_field_on_a_bare_call_routes_the_same_way() {
        let fx = CdFixture::new();
        let scope = fx.read_scope();
        let table = RouteTable::builtin();
        assert_eq!(
            table.classify_call(
                &json!({"command": "cargo test", "cwd": "sub"}),
                &fx.root,
                &scope
            ),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({"argv": ["cargo", "test"], "cwd": "sub"}),
            }
        );
        // Still refuses a call carrying anything besides `command`/`cwd`.
        assert_eq!(
            table.classify_call(
                &json!({"command": "cargo test", "cwd": "sub", "timeout": 5}),
                &fx.root,
                &scope
            ),
            RouteDecision::Exec
        );
        // An unresolvable `cwd` field refuses rather than silently dropping
        // it and running in the wrong place.
        assert_eq!(
            table.classify_call(
                &json!({"command": "cargo test", "cwd": "missing"}),
                &fx.root,
                &scope
            ),
            RouteDecision::Exec
        );
    }

    /// TDD: every silent rewrite is logged — `audit_line` is `Some` for a Route
    /// (carrying the original command + the governed built-in) and `None` for
    /// an Exec (nothing rewritten).
    #[test]
    fn audit_line_logs_every_rewrite_and_only_rewrites() {
        let routed = classify("cat foo.txt");
        let line = audit_line("cat foo.txt", &routed).expect("a route is logged");
        assert!(line.contains("cat foo.txt"), "{line}");
        assert!(line.contains("read_file"), "{line}");

        // An Exec decision produces no audit line — nothing was rewritten.
        assert_eq!(audit_line("git add .", &classify("git add .")), None);
    }

    /// F23 / #2524 "tail-pipe-routes" (red first): the LITERAL shape
    /// measured in 2488-r9 — a clean build argv, with its `+stable`
    /// toolchain selector, piped only to cut output — routes, carrying the
    /// build's own argv (toolchain included) plus the trim the pipe asked
    /// for. The `+toolchain` support itself is `is_toolchain_selector`'s.
    #[test]
    fn build_piped_to_tail_or_head_routes_with_the_trim() {
        assert_eq!(
            classify(
                "cargo +stable test -j 4 -p newt-core --test config_publishing_ratchet 2>&1 | tail -40"
            ),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({
                    "argv": ["cargo", "+stable", "test", "-j", "4", "-p", "newt-core",
                              "--test", "config_publishing_ratchet"],
                    "trim": {"mode": "tail", "n": 40},
                }),
            }
        );
        // Every recognised variant: with/without `2>&1`, `-N` and `-n N`,
        // `tail` and `head`, and `just`.
        for (cmd, mode, n) in [
            ("cargo test -p newt-core | tail -40", "tail", 40),
            ("cargo test -p newt-core | tail -n 40", "tail", 40),
            ("cargo build | head -20", "head", 20),
            ("cargo build | head -n 20", "head", 20),
            ("just test 2>&1 | tail -5", "tail", 5),
        ] {
            let RouteDecision::Route { tool, args } = classify(cmd) else {
                panic!("{cmd} must route");
            };
            assert_eq!(tool, "build_exec", "{cmd}");
            assert_eq!(args["trim"]["mode"], mode, "{cmd}");
            assert_eq!(args["trim"]["n"], n, "{cmd}");
        }
    }

    /// Anything wider than the exact recognised shape stays refused — the
    /// SAME `SHELL_META` compound-command refusal every other pipe already
    /// gets, never a new leniency.
    #[test]
    fn build_piped_to_anything_else_never_routes() {
        for cmd in [
            // Multiple pipes.
            "cargo test | tail -40 | head",
            // Follows, doesn't cut — a live stream, not a bounded trim.
            "cargo test | tail -f",
            // Bytes, not lines.
            "cargo build | head -c 10",
            // Not tail/head at all.
            "cargo test | grep FAILED",
            // A redirect BEFORE the pipe: still compound, still refused.
            "cargo test > log.txt | tail -40",
            // Missing, zero, or non-numeric N.
            "cargo test | tail",
            "cargo test | tail -0",
            "cargo test | tail -n 0",
            "cargo test | tail -abc",
            // Extra operands after the count.
            "cargo test | tail -40 extra",
            // Not a recognised build tool program.
            "pytest | tail -40",
            // An operand `build_lane_route` itself would already refuse
            // (near-miss subcommand) — still refused with the pipe.
            "cargo run | tail -40",
            // PR #2549 round 2: GNU's `-n +N` means "start AT line N", a
            // real and DIFFERENT shape from `-n N` ("last/first N lines").
            // `u32::from_str` tolerates a leading `+`, so this must be an
            // explicit string check, not left to `parse`'s leniency.
            "cargo test | tail -n +40",
            "cargo test | head -n +40",
            "cargo test | tail -+40",
            // Two pipes with nothing between them (`||`) splits into THREE
            // parts on `split_single_pipe`'s own code path — refused the
            // same way as any other multi-pipe shape, exercised explicitly.
            "cargo test || tail -40",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    /// PR1 (r10-r12 evidence, multi-repo-recon rows 1/2/4/6, red first):
    /// almost every `run_command` began with `cd <dir> && …` — not only the
    /// workspace root (#2550's F24 case) but a real SUBDIRECTORY (`cd
    /// repoA && cargo test`), which used to stay compound and never route
    /// at all even though `tools/shell.rs`'s `split_leading_cd` already
    /// folds it correctly for the confined shell's own dispatch. Folds now,
    /// with `cwd` attached to the build lane, and composes with #2549's
    /// tail-pipe route. Native Git retains its original shell call.
    // Not on Windows: the cd fold does not resolve there yet (fail-closed to
    // Exec, as before this change). Refusal tests still run on every platform.
    #[cfg(not(windows))]
    #[test]
    fn cd_into_a_real_subdirectory_routes_with_cwd() {
        let fx = CdFixture::new();
        let scope = fx.read_scope();
        assert_eq!(
            classify_at("cd sub && cargo test", &fx.root, &scope),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({"argv": ["cargo", "test"], "cwd": "sub"}),
            }
        );
        // `;` is safe here — unlike #2550's boundary, which excluded it
        // because the strip was a lexical guess. If `std::fs::canonicalize`
        // succeeds, the `cd` succeeds, and the separator never changed
        // that answer.
        assert_eq!(
            classify_at("cd sub; cargo test", &fx.root, &scope),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({"argv": ["cargo", "test"], "cwd": "sub"}),
            }
        );
        let RouteDecision::Route { args, .. } =
            classify_at("cd sub && cargo test | tail -20", &fx.root, &scope)
        else {
            panic!("must route");
        };
        assert_eq!(args["cwd"], "sub");
        assert_eq!(args["trim"], json!({"mode": "tail", "n": 20}));
        // The workspace root itself — #2550's original case — now routes
        // via this SAME mechanism; `cwd` resolves to `.`.
        assert_eq!(
            classify_at(
                &format!("cd {} && cargo test", fx.root.display()),
                &fx.root,
                &scope
            ),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({"argv": ["cargo", "test"], "cwd": "."}),
            }
        );
        assert_eq!(
            classify_at("cd sub && git status", &fx.root, &scope),
            RouteDecision::Exec
        );
    }

    /// Everything the brief named as staying `Exec`: a target that does not
    /// exist, is a file not a directory, is outside the workspace (a `..`
    /// climb), `cd -`/`cd ~`, a quoted/globbed/metacharacter dir, two
    /// `cd`s, unquoted internal whitespace (bash's real `cd` sees TWO
    /// arguments there and fails — `&&` never runs the rest), and a
    /// non-routable `<rest>` even past a genuinely resolvable `cd`.
    #[test]
    fn cd_stays_exec_when_the_target_does_not_resolve_or_is_out_of_authority() {
        let fx = CdFixture::new();
        let scope = fx.read_scope();
        for cmd in [
            "cd missing && cargo test",
            "cd file.txt && cargo test",
            "cd ../outside-the-workspace && cargo test",
            "cd - && cargo test",
            "cd ~ && cargo test",
            "cd \"sub\" && cargo test",
            "cd su* && cargo test",
            "cd sub$X && cargo test",
            // Two `cd`s: `rest` after the first fold is `cd sub && cargo
            // test`, which still contains `&&` — `classify_stripped`'s
            // ordinary compound refusal catches it, no separate check.
            "cd sub && cd sub && cargo test",
            // A resolvable `cd`, but `<rest>` isn't routable — the fold
            // changes what's classified, not whether it routes.
            "cd sub && rm -rf x",
            // Unquoted internal whitespace: the ONLY correctly quoted
            // spelling is already refused by `BUILD_UNSAFE`'s `"`, so
            // without this refusal the broken command would route while
            // the correct one would not.
            "cd su b && cargo test",
        ] {
            assert_eq!(
                classify_at(cmd, &fx.root, &scope),
                RouteDecision::Exec,
                "{cmd}"
            );
        }
    }

    /// A symlink INSIDE the workspace whose target resolves OUTSIDE it
    /// stays `Exec` — `std::fs::canonicalize` follows the link, so the
    /// containment check sees the REAL destination, not the lexical path
    /// the model typed.
    #[cfg(not(windows))]
    #[test]
    fn cd_through_a_symlink_that_escapes_the_workspace_stays_exec() {
        let fx = CdFixture::new();
        let scope = fx.read_scope();
        // `std::env::temp_dir()` is an ANCESTOR of `fx.root` (tempfile
        // creates its dirs under it), so it is guaranteed to exist and to
        // be outside `fx.root` specifically.
        let outside = std::env::temp_dir();
        std::os::unix::fs::symlink(&outside, fx.root.join("escape")).expect("symlink");
        assert_eq!(
            classify_at("cd escape && cargo test", &fx.root, &scope),
            RouteDecision::Exec
        );
    }

    /// A resolvable directory OUTSIDE this call's fs-read fence (even
    /// though it is genuinely inside the workspace on disk) stays `Exec` —
    /// the fence, not just the workspace boundary, decides authority.
    #[test]
    fn cd_into_a_real_subdirectory_outside_the_fence_stays_exec() {
        let fx = CdFixture::new();
        // A fence that grants only a DIFFERENT root — `sub` is a real
        // directory inside the workspace, but outside THIS call's fence.
        let elsewhere = tempfile::TempDir::new().expect("tempdir");
        let scope = crate::caveats::Scope::only([elsewhere
            .path()
            .canonicalize()
            .expect("canonicalize")
            .to_string_lossy()
            .into_owned()]);
        assert_eq!(
            classify_at("cd sub && cargo test", &fx.root, &scope),
            RouteDecision::Exec
        );
    }

    /// #2551 round 2 BLOCKER (red first, real tempdir with `sub/x`/`sub/f`
    /// AND `x`/`f` at the root — distinct content, so reading/deleting the
    /// WRONG one is provably wrong): `cwd` is honoured only by `build_exec`
    /// (`run_confined_build_lane` takes it as a real parameter) —
    /// `read_file`/`list_dir`/`delete_file` all join
    /// `workspace + path` and never look at `args["cwd"]`. Attaching `cwd`
    /// to those routes anyway would leave `cd sub && rm x` deleting
    /// `<root>/x` instead of `<root>/sub/x` — a wrong-file DELETE gated only
    /// by `fs_write` on the root, not on `sub`. Every non-build route
    /// with a resolved `cwd != "."` now stays `Exec`; `cwd == "."` (the
    /// workspace root itself) is harmless and still routes.
    #[test]
    fn a_resolved_cwd_never_reaches_a_route_that_does_not_honour_it() {
        let fx = CdFixture::new();
        let scope = fx.read_scope();
        for cmd in ["cd sub && rm x", "cd sub && cat f", "cd sub && ls"] {
            assert_eq!(
                classify_at(cmd, &fx.root, &scope),
                RouteDecision::Exec,
                "{cmd}"
            );
        }
        // The pre-existing sibling of the same bug: a `cwd` FIELD (not a
        // leading `cd`) on a non-build route used to route and
        // silently drop it.
        assert_eq!(
            RouteTable::builtin().classify_call(
                &json!({"command": "cat f", "cwd": "sub"}),
                &fx.root,
                &scope
            ),
            RouteDecision::Exec
        );
        // `cwd == "."` (the workspace root itself) is harmless — those
        // tools already run there, so nothing is silently dropped.
        // Not on Windows: the cd fold does not resolve there yet and stays
        // Exec (fail-closed); the refusals above still run on every platform.
        #[cfg(not(windows))]
        assert_eq!(
            classify_at(
                &format!("cd {} && cat f", fx.root.display()),
                &fx.root,
                &scope
            ),
            RouteDecision::Route {
                tool: "read_file",
                args: json!({ "path": "f" }),
            }
        );
    }

    /// #2551 round 2 should-fix (red first): the fence must hold on the
    /// CANONICAL path too, not just the lexical join. A fence granting only
    /// `sub` and a symlink `sub/link` → a REAL sibling directory `other`
    /// (never granted): `workspace.join("sub/link")` lexically starts with
    /// the granted `sub` prefix, but the directory it actually reaches is
    /// outside the fence. Checking only the lexical join would route with
    /// `cwd="other"` — a directory the fence never authorized.
    #[cfg(not(windows))]
    #[test]
    fn a_symlink_whose_real_target_is_outside_the_fence_stays_exec() {
        let fx = CdFixture::new();
        let other = fx.root.join("other");
        std::fs::create_dir(&other).expect("mkdir other");
        std::os::unix::fs::symlink(&other, fx.sub.join("link")).expect("symlink");
        // Grant ONLY `sub` — `other` is genuinely inside the WORKSPACE, but
        // never inside THIS call's fence.
        let scope = crate::caveats::Scope::only([fx.sub.to_string_lossy().into_owned()]);
        assert_eq!(
            classify_at("cd sub/link && cargo test", &fx.root, &scope),
            RouteDecision::Exec
        );
    }

    /// #2551 round 2 should-fix (dropped from #2550 round 2, re-added, red
    /// first): ANY `..` component in `<dir>` refuses outright, even one
    /// that would resolve harmlessly. `std::fs::canonicalize` resolves
    /// PHYSICALLY (follows every symlink to its real target); a real
    /// shell's `cd` defaults to LOGICAL (`-L`) and never re-resolves a
    /// symlink component once it has descended through it. With `link →
    /// <root>/a/b`, `cd link/..` lands at `<root>` in bash (logical: pop
    /// the textual `link` component) but at `<root>/a` if routed
    /// (physical: canonicalize resolves `link` first, then climbs one
    /// REAL level) — two different directories from the same command.
    /// Refusing any `..` sidesteps the divergence entirely rather than
    /// trying to emulate bash's logical resolution.
    #[test]
    fn a_parent_dir_component_in_the_cd_target_always_refuses() {
        let fx = CdFixture::new();
        let scope = fx.read_scope();
        // Even a `..` that would resolve harmlessly (back to a sibling
        // that IS granted) still refuses — the component itself is what's
        // refused, not its eventual resolution.
        assert_eq!(
            classify_at("cd sub/../sub && cargo test", &fx.root, &scope),
            RouteDecision::Exec
        );
        assert_eq!(
            classify_at("cd sub/.. && cargo test", &fx.root, &scope),
            RouteDecision::Exec
        );
    }

    /// #2551 round 3 should-fix: a folded leading `cd` resolves `<dir>`
    /// against `workspace`, but when the call ALSO carries a `cwd` field, a
    /// real shell resolves the relative `cd` against THAT directory, not the
    /// workspace root. `{command:"cd sub && cargo test", cwd:"other"}` used
    /// to route to `<root>/sub` while the shell would run in
    /// `<root>/other/sub` (or fail, if that path does not exist) — a
    /// wrong-directory build that still counted as a pass. Refuse instead of
    /// resolving the fold against the field: simpler, and the review's
    /// evidence never combines the two.
    #[test]
    fn a_cwd_field_alongside_a_folded_leading_cd_refuses() {
        let fx = CdFixture::new();
        let other = fx.root.join("other");
        std::fs::create_dir(&other).expect("mkdir other");
        std::fs::create_dir(other.join("sub")).expect("mkdir other/sub");
        let scope = fx.read_scope();
        let call = json!({ "command": "cd sub && cargo test", "cwd": "other" });
        assert_eq!(
            RouteTable::builtin().classify_call(&call, &fx.root, &scope),
            RouteDecision::Exec
        );
    }

    /// #2551 round 3 nit: the round-2 dual fence check compares a canonical
    /// candidate path against the UN-canonicalized `read_scope` roots, so a
    /// workspace reached through a symlinked path (macOS `/tmp` →
    /// `/private/tmp`) fails the canonical check for every `<dir>`,
    /// including `.` — no `cd` ever routes. Canonicalizing the scope's own
    /// roots before that comparison fixes it. Deliberately does NOT
    /// canonicalize the tempdir path up front (unlike `CdFixture::new`),
    /// so the workspace root passed to `classify_call` is itself the
    /// symlinked string this test is about.
    #[test]
    #[cfg(not(windows))]
    fn a_symlinked_workspace_root_still_folds_a_cd() {
        let real = tempfile::TempDir::new().expect("tempdir");
        let real_root = real.path().canonicalize().expect("canonicalize tempdir");
        std::fs::create_dir(real_root.join("sub")).expect("mkdir sub");
        let parent = real_root.parent().expect("tempdir has a parent");
        let link = parent.join(format!(
            "{}-link",
            real_root.file_name().unwrap().to_string_lossy()
        ));
        std::os::unix::fs::symlink(&real_root, &link).expect("symlink workspace root");
        let scope = crate::caveats::Scope::only([link.to_string_lossy().into_owned()]);
        let call = json!({ "command": "cd sub && cargo test" });
        assert_eq!(
            RouteTable::builtin().classify_call(&call, &link, &scope),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({ "argv": ["cargo", "test"], "cwd": "sub" }),
            }
        );
        std::fs::remove_file(&link).ok();
    }

    /// F27 (r14a evidence): the exact measured shape — a folded leading
    /// `cd`, a build piped through `2>&1`, and a trailing exit-status echo
    /// — routes with `cwd` attached, same as if the echo were never there.
    #[test]
    fn a_folded_cd_with_2_and_1_and_a_trailing_exit_echo_routes_with_cwd() {
        let fx = CdFixture::new();
        let scope = fx.read_scope();
        assert_eq!(
            classify_at(
                "cd sub && cargo test 2>&1; echo \"EXIT: $?\"",
                &fx.root,
                &scope,
            ),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({ "argv": ["cargo", "test"], "cwd": "sub", "echo_dropped": true }),
            }
        );
    }

    /// F27: every echo-suffix variant from the evidence routes, whether the
    /// separator is `;` or `&&`, quoted or bare, `EXIT:`/`EXIT=`/`exit `-
    /// prefixed, or referencing `${PIPESTATUS[0]}` instead of `$?`.
    #[test]
    fn every_trailing_exit_echo_variant_is_stripped_and_routes() {
        for cmd in [
            "cargo test; echo \"EXIT: $?\"",
            "cargo test && echo \"EXIT: $?\"",
            "cargo test; echo \"EXIT=$?\"",
            "cargo test; echo \"exit $?\"",
            "cargo test; echo $?",
            "cargo test; echo \"TEST_EXIT=${PIPESTATUS[0]}\"",
        ] {
            assert_eq!(
                classify(cmd),
                RouteDecision::Route {
                    tool: "build_exec",
                    args: json!({ "argv": ["cargo", "test"], "echo_dropped": true }),
                },
                "{cmd}"
            );
        }
    }

    /// F27 negative cases: anything that is NOT provably an inert
    /// exit-status echo stays compound (`Exec`), exactly as before this
    /// change — an echo of unrelated text or a variable, a command
    /// substitution, a redirect, `||` (not `&&`/`;`), and two trailing
    /// echoes (the first one is not the one that gets dropped, and it
    /// keeps its own `;` in what remains).
    #[test]
    fn a_trailing_echo_that_is_not_provably_inert_stays_compound() {
        for cmd in [
            "cargo test; echo \"$HOME\"",
            "cargo test; echo $(date)",
            "cargo test; echo x > f",
            "cargo test || echo \"$?\"",
            "cargo test; echo a; echo \"$?\"",
        ] {
            assert_eq!(classify(cmd), RouteDecision::Exec, "{cmd}");
        }
    }

    /// F27: routing decides nothing about the build's own outcome — a
    /// failing build still routes with the echo stripped, and it is the
    /// confined build lane (never the shell's own masked `$?`) that reports
    /// the real failure. See `disable_ocap_tests.rs` for the end-to-end
    /// dispatch confirmation that the reported exit code is the REAL one.
    #[test]
    fn a_trailing_exit_echo_on_a_command_that_would_fail_still_routes() {
        assert_eq!(
            classify("cargo test --this-flag-does-not-exist; echo \"EXIT: $?\""),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({
                    "argv": ["cargo", "test", "--this-flag-does-not-exist"],
                    "echo_dropped": true,
                }),
            }
        );
    }

    /// #2554 round 2 should-fix (red first): a newline inside the echo tail
    /// must never be treated as more argument text — `cargo test; echo
    /// EXIT=$?<LF>touch marker` used to scrub to `<LF>touch marker`, see no
    /// forbidden character in a hand-rolled table that omitted `\n`, and
    /// silently DROP `touch marker` while routing the build. `\n` is
    /// checked directly now, before any scrubbing.
    #[test]
    fn a_newline_in_the_echo_tail_refuses_the_whole_strip() {
        assert_eq!(
            classify("cargo test; echo EXIT=$?\ntouch marker"),
            RouteDecision::Exec
        );
    }

    /// #2554 round 2 should-fix (red first): the rightmost-`;`/`&&` search
    /// has no notion of quoting, so a separator INSIDE a quoted argument
    /// could become the split point. `rm "x; echo "$?""` is ONE `rm` of a
    /// file literally named `x; echo "$?"` in a real shell; splitting it
    /// used to route `rm "x` as `delete_file { path: "\"x" }` — a different
    /// file than the shell would ever touch. A quote anywhere in the kept
    /// half now refuses the whole strip.
    #[test]
    fn a_quote_inside_the_kept_half_refuses_the_whole_strip() {
        assert_eq!(classify("rm \"x; echo \"$?\"\""), RouteDecision::Exec);
    }

    /// F28 (#2483 evidence, F28/F30): the model's own instinct — `timeout
    /// 300 cargo build -p newt-core` — routes with the wrapper stripped and
    /// the literal cargo argv preserved, flagged so the note can say why no
    /// `timeout` wall applies.
    #[test]
    fn a_leading_timeout_wrapper_is_stripped_and_the_build_routes() {
        assert_eq!(
            classify("timeout 300 cargo build -p newt-core"),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({
                    "argv": ["cargo", "build", "-p", "newt-core"],
                    "timeout_secs": 300,
                }),
            }
        );
    }

    /// F28: composes with the `cd` fold (#2551) — both the folded `cwd` AND
    /// the dropped `timeout` are attached to the same routed call.
    #[test]
    fn a_leading_timeout_wrapper_composes_with_the_cd_fold() {
        let fx = CdFixture::new();
        let scope = fx.read_scope();
        assert_eq!(
            classify_at(
                "cd sub && timeout 300 cargo build -p newt-core",
                &fx.root,
                &scope,
            ),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({
                    "argv": ["cargo", "build", "-p", "newt-core"],
                    "cwd": "sub",
                    "timeout_secs": 300,
                }),
            }
        );
    }

    /// F28: composes with the tail-pipe trim (#2524/#2549) — the trim is
    /// applied AND the `timeout` wrapper is dropped.
    #[test]
    fn a_leading_timeout_wrapper_composes_with_the_tail_trim() {
        assert_eq!(
            classify("timeout 300 cargo build 2>&1 | tail -20"),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({
                    "argv": ["cargo", "build"],
                    "trim": { "mode": "tail", "n": 20 },
                    "timeout_secs": 300,
                }),
            }
        );
    }

    /// F28: composes with the trailing exit-echo strip (#2554) — the echo is
    /// dropped AND the `timeout` wrapper is dropped.
    #[test]
    fn a_leading_timeout_wrapper_composes_with_the_exit_echo_strip() {
        assert_eq!(
            classify("timeout 300 cargo build -p newt-core; echo \"EXIT: $?\""),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({
                    "argv": ["cargo", "build", "-p", "newt-core"],
                    "echo_dropped": true,
                    "timeout_secs": 300,
                }),
            }
        );
    }

    /// F28 negative: the command after the wrapper is NOT itself routable
    /// (`rm` never routes to the build lane) — the whole thing stays `Exec`,
    /// refused downstream exactly as today, never a "half-stripped" route.
    #[test]
    fn a_timeout_wrapper_around_a_non_routable_command_stays_exec() {
        assert_eq!(classify("timeout 5 rm -rf x"), RouteDecision::Exec);
    }

    /// F28 negative: a shell-variable duration (`$T`) is not a plain token —
    /// the whole command stays compound/`Exec` rather than guessing at what
    /// the shell would have expanded it to.
    #[test]
    fn a_timeout_wrapper_with_a_shell_variable_duration_stays_exec() {
        assert_eq!(classify("timeout $T cargo test"), RouteDecision::Exec);
    }

    /// F28 negative: two stacked `timeout` wrappers never recursively strip
    /// — only exactly one leading wrapper is ever removed.
    #[test]
    fn two_stacked_timeout_wrappers_never_recursively_strip() {
        assert_eq!(
            classify("timeout 1 timeout 2 cargo test"),
            RouteDecision::Exec
        );
    }

    /// PR-F28 review, Blocker 1 (red first): `-s`'s value used to be
    /// accepted as ANY next token, so a command substitution hiding in it
    /// silently vanished once the `timeout` prefix was stripped — the shell
    /// WOULD have run `touch pwned`. The whole strip must refuse instead of
    /// discarding text the model asked the shell to execute.
    #[test]
    fn a_metacharacter_in_the_signal_value_refuses_the_whole_strip() {
        assert_eq!(
            classify("timeout -s $(touch${IFS}pwned) 5 cargo test"),
            RouteDecision::Exec
        );
    }

    /// PR-F28 review, Blocker 1 (red first): same hole via `--kill-after=`'s
    /// value — a `;`-chained command hiding after the `=` used to be
    /// accepted and dropped along with the rest of the wrapper.
    #[test]
    fn a_metacharacter_in_the_kill_after_value_refuses_the_whole_strip() {
        assert_eq!(
            classify("timeout --kill-after=1;touch${IFS}x 5 cargo test"),
            RouteDecision::Exec
        );
    }

    /// PR-F28 review, Blocker 1 non-regression: value validation must not
    /// over-refuse a plain signal name — `-s KILL` still strips and routes.
    #[test]
    fn a_plain_signal_value_still_strips_and_routes() {
        assert_eq!(
            classify("timeout -s KILL 5 cargo test"),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({
                    "argv": ["cargo", "test"],
                    "timeout_secs": 5,
                }),
            }
        );
    }

    /// PR-F28 review, Blocker 1 non-regression: a plain `--signal=`/`-k`
    /// combination (long-form signal, short-form kill-after) still strips
    /// and routes.
    #[test]
    fn a_plain_signal_and_kill_after_value_still_strips_and_routes() {
        assert_eq!(
            classify("timeout --signal=TERM -k 5 60 cargo test"),
            RouteDecision::Route {
                tool: "build_exec",
                args: json!({
                    "argv": ["cargo", "test"],
                    "timeout_secs": 60,
                }),
            }
        );
    }
}