mahbot 0.7.3

An autonomous agentic engineering system that manages software development through role separation, subagents, and deterministic diagnostics.
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
//! Windows (`cmd.exe`) platform layer of the read-only shell guard.
//!
//! Active only for [`super::ShellPlatform::Windows`]: there the command string is
//! executed by `cmd.exe /C` while the guard's rules are unix-shaped — a word
//! containing `\` is classified unprovable and a leading `/` token is a path
//! operand rather than a switch. The layer closes both directions: the
//! destructive cmd.exe verbs are refused and the legitimate Windows spellings
//! (`%TEMP%\scratch`, `> NUL`, `del/q`) are admitted; the null device and the
//! temp roots are dispatched by platform in the shared file (`is_null_target`,
//! `writes_outside_temp`). The unix verdicts and their tests are untouched: the
//! shared dispatch calls this layer once per command string ([`check_line`],
//! which scans the whole tree and checks the platform itself) and, behind its
//! own platform branch, for every command segment ([`check_segment`]).
//!
//! `grep_engine/windows.rs` is the sibling cmd.exe reader — the grep
//! interception's own, with its own fail-closed policy — so a cmd.exe rule
//! change belongs in both: its verb reading (the tables below on this side,
//! `grep_verb` on the other's) and the `%` expansion, which the two sides read
//! on purpose by different rules ([`expand_percent_vars`] here against the
//! engine's coarse `grep_engine::windows::has_percent_expansion`).
//!
//! # Refused
//!
//! [`DENIED_VERBS`], [`TEMP_GATED_VERBS`], [`CWD_DESTINATION_VERBS`],
//! [`TEMP_GATED_DESTINATION_VERBS`] (with [`COPY_DENIED_SWITCHES`]),
//! [`LIST_ONLY_VERBS`], [`QUERY_VERBS`], [`CLOCK_VERBS`] with an operand, a drive
//! switch with an operand, a switch glued to its operand by cmd's parameter
//! delimiter ([`glued_delimiter_switch`]), and the `set` forms [`check_set`]
//! names. Each table is authoritative on its own category and states its own
//! rule; so does every predicate below. A command string the bash grammar cannot
//! parse at all is refused by the shared parse before this layer sees a segment —
//! cmd-only shapes (`if exist x ( … )`, cmd's `for %i in (…) do …` loops) are
//! refused that way, since no cmd.exe parser is added.
//!
//! # Matching
//!
//! Verb matching is case-insensitive, extension-insensitive, aware of
//! path-qualified spellings and of cmd's glued switch ([`verb_key`],
//! [`split_glued_switch`]): `C:\Windows\System32\FORMAT.COM`, `"C:\Program
//! Files\x\del.exe"`, `format` and `del/q` name the same verb as their plain
//! spellings — a word carrying a path separator names a program only through a
//! known executable extension.
//!
//! A path word is read only where cmd.exe and the bash parse the guard reads
//! agree on the operand; a disagreement the layer models is refused
//! fail-closed:
//!
//! - whitespace — literal or arriving from a `%VAR%`, since cmd expands before
//!   it tokenises — is one operand only when double-quoted. cmd quotes with `"`
//!   alone, so a single-quoted OPERAND is ordinary text and is refused; a VERB
//!   word is judged under the literal name the shared balanced-quote strip reads
//!   on both platforms ([`verb_key`]) — `'del' C:\ws\x.txt` is refused while
//!   `'del' "%TEMP%\x.txt"` stays a temp write, so the quotes hide nothing;
//! - an unquoted `,`/`=`/`;` is an argument delimiter to cmd.exe (several of its
//!   internal commands split their ARGUMENTS at it), while `&`/`|`/`<`/`>` are its
//!   separators and redirects — a spelling the bash parse reads as one word only
//!   through a `\` escape, where cmd.exe still splits or redirects the operand.
//!   Behind a switch the same delimiter is refused outright
//!   ([`glued_delimiter_switch`]): cmd.exe splits the token there and hands the
//!   text behind it to the verb as another argument, so the token is either a
//!   switch the gate drops or one fused word it cannot parse — never the operand
//!   cmd would hand over;
//! - `*`/`?`/`~` (a glob the program reading the path expands), `^` (cmd's
//!   escape, which hides a character the layer must see) and `!` (the interpreter
//!   this shell starts has delayed expansion off, but `setlocal
//!   enabledelayedexpansion` inside a command, or a nested interpreter the command
//!   starts, is not covered) anywhere in the expanded text;
//! - every relative spelling: no relative path OPERAND is provable (see
//!   below), so the temp gate accepts absolute paths only.
//!
//! A `;`-split line is refused whole, which is not a path-word rule: cmd.exe
//! keeps one line, and several of its internal commands split their ARGUMENTS at
//! the `;`, so the fragment the bash parse reads as a second command would become
//! an extra operand of the first (`del %TEMP%\a;C:\ws\x.exe`). cmd.exe has no
//! comment syntax either, so a `#` is an ordinary character to it: `echo hi # &
//! del C:\ws\x.txt` is a comment for the bash parse and two commands for cmd.
//! A heredoc is refused outright: this platform has no text block at all, so a
//! `<<` is two input redirections to it, and the body's later lines would be
//! commands of their own here — lines this guard never read as commands.
//! All three are checked once, over the whole tree ([`check_line`]) — each is a
//! property of the LINE rather than of a nesting position, so all three also hold
//! inside a substituted command, a parenthesised group, a function body, a case
//! branch and a pipeline member. They are also the one rule BOTH modes decide: the
//! read-only guard reaches them inside its own walk, and the unrestricted mode —
//! which has no walk to reach them through — through
//! [`super::check_line_divergences`], so both modes refuse the same lines in the
//! same words. That is why their message opens with the shared [`REFUSAL_FRAME`]
//! rather than the guard's own mode-naming one.
//!
//! The line breaks of a command text are read by the shell itself
//! ([`crate::tools::shell::windows_line`]) before this guard is consulted: in
//! read-only mode — the mode this guard is the reader of — a break this platform's
//! reader and the execution would read differently (an odd run of `\` before one, a
//! break inside a quoted word, a caret over a character this guard reads as an
//! operator) is refused whole there, because this guard reads the ORIGINAL text
//! through the bash grammar while the execution reads what actually runs, and a shape
//! the two read differently must never run. A break with nothing after it is the one
//! such shape this reading does not refuse: nothing but the platform's own separators
//! follows it, so the text is run as the line before the break — the reading a command
//! with no break at all has always had here. Two breaks are left differing on purpose:
//! this one, which neither reader can find a command in, and a caret-continued one,
//! whose reading is this platform's own.
//!
//! # Accepted limits (settled, not chased)
//!
//! Each is recorded as a limit rather than chased. Where the cmd.exe side of one
//! could not be measured on this host it is argued (see `# Unverifiable from this
//! host`):
//!
//! - wrappers and other interpreters are NOT chased, and on Windows they are
//!   idiomatic: `cmd /c …` (the shell itself — the Windows counterpart of the
//!   unix limit for a command handed to `sh -c`), `powershell`/`pwsh`, `start`,
//!   `call`, `mshta`, `rundll32`, `forfiles`, `wmic`, and the scripting and
//!   execution hosts `cscript`/`wscript`. (`forfiles` is what the sanitation
//!   temp-cleanup task uses to report the newest file in a tree, so refusing it
//!   would break a documented read-only workflow.)
//! - a VERB word spelled through cmd's escape (`d^el`, `^del`) or its variable
//!   syntax (`%DEL%`, `!DEL!`), and a decorated or trailing-dot name (`del.`,
//!   `format.com.`), match no table and pass. An OPERAND carrying the same
//!   characters is refused instead, the unexpanded `%…%` form below excepted.
//! - a word that escapes a separator or a redirect (`dir \& del C:\ws\x.exe`,
//!   `whoami \> C:\ws\out.txt`) is admitted unless it is a path OPERAND of a
//!   verb the layer reads: cmd.exe reads the backslash as an ordinary character
//!   and then splits or redirects at the character behind it, while the bash
//!   parse keeps one word.
//! - a command on no table at all passes — the same "not a complete account"
//!   contract the unix tables have. The writers among them are the sharp edge:
//!   `certutil` and `sort` are named by no rule here, and neither is a command
//!   that picks its own destination when only a source is given (`makecab
//!   C:\ws\x.txt` defaults its output into cmd's own directory, the unmodelled
//!   one the paragraph after this list describes). The layer models the mutator
//!   families it knows and discloses the rest.
//! - a path-qualified spelling of a known verb written with forward slashes and
//!   no executable extension reaches no Windows table: [`verb_key`] reads a
//!   separator-carrying word as a program only through a known executable
//!   extension, and the bash parse keeps the whole word literal, so the shared
//!   dispatch judges the basename cmd.exe would resolve instead
//!   ([`dispatch_word`]) — deliberately, since the same fallback is what keeps a
//!   `/usr/bin/rm`-shaped word a verb on unix. A name the shared tables carry is
//!   therefore still refused (`C:/Windows/System32/rm C:\ws\x.txt`), while a
//!   Windows-only one passes (`C:/Windows/System32/del C:\ws\x.txt`, and likewise
//!   `erase`, `rd`, `md`, `move`, `copy`, `xcopy`, `ren`, `replace`, `mklink` and
//!   even the denied `format`). The same spelling with backslashes is the shared
//!   classifier's unprovable word and is refused.
//! - an OPERAND carrying a `%…%` whose name is not `[A-Za-z0-9_]` (`del
//!   "%TEMP%\%中%\x.txt"`) is read as literal text: the model follows no such
//!   variable, yet cmd resolves one of that shape, so a defined name would put
//!   the write outside temp at runtime.
//!
//! cmd resolves a relative operand against its own directory, which is not
//! modelled — and that directory is not reliably outside the temp roots, because
//! the temp cleaner runs its shell in a workspace built at the private temp
//! root. Writes under temp therefore name it absolutely: a literal path, or
//! `%TMP%`/`%TEMP%`, the variables the shells are handed (from the same single
//! source as the accepted roots). A temp path that carries whitespace must be
//! quoted, exactly as cmd requires. A daemon temp path that itself carries one
//! of the refused characters (`~`, `!`, `^` — an 8.3 short-name component, say)
//! makes even the literal spelling unprovable, which is the accepted
//! over-rejection direction, but it is the one rule here that can deny a
//! legitimate temp write. The cd family itself is allowed and not
//! modelled further: it writes nothing, a target fused with a separator
//! (`cd..\..`, `cd\Users`) is an unprovable word and is refused, and the spaced
//! spelling (`cd ..`) works.
//!
//! Only the temp variables are modelled, and they resolve against the very
//! bindings the shells are handed. `set` rebinding one of them is refused: the
//! runtime `%TEMP%` would then name a location the model still reads as the
//! daemon temp root. Any other `NAME=value` (through `set` or the shell's own
//! syntax, which cmd.exe cannot run) is not tracked, so a later `%NAME%` operand
//! is unresolvable and refused. The bindings the shared unix assignment path
//! records are not consulted: cmd.exe cannot execute `NAME=value` at all, so
//! resolving through one would approve a path the runtime never builds.
//!
//! `robocopy`/`xcopy` are gated on the destination only, so a tree copy INTO
//! temp from anywhere is allowed and `/MIR`'s and `/PURGE`'s deletions inside
//! that destination are accepted as part of the same grant;
//! [`COPY_DENIED_SWITCHES`] is the known set of copy switches that reach beyond
//! that grant, and a switch outside it keeps the destination-only grant. A copy
//! with a single path argument is refused: cmd's destination is optional and
//! then defaults to cmd's own directory, which read-only mode never proved is
//! the accepted location (see [`destination_under_temp`]). `move` and `replace`
//! have the same optional destination and are refused the same way when they name
//! fewer than two paths ([`CWD_DESTINATION_VERBS`]). A switch-SHAPED
//! argument carrying a path signal (`/ws/b.txt`) is read as an operand — cmd.exe
//! accepts `/` as a path separator — and must satisfy the gate like any other
//! path; one carrying no path signal (`/s`, `/ws`) stays a switch and is
//! dropped from the gate. A switch-shaped token carrying cmd's parameter
//! delimiter is refused before any of that ([`glued_delimiter_switch`]).
//! `ren`'s second operand is a bare name that cmd anchors on the FIRST operand's
//! directory, so the temp grant here is effectively unreachable: the
//! fully-qualified spelling the gate accepts (`ren %TEMP%\a.txt %TEMP%\b.txt`) is
//! one cmd refuses to run, while the spelling cmd runs (`ren %TEMP%\a.txt b.txt`)
//! is refused as a workspace write.
//! The shared tables are matched case-insensitively on Windows (`TAR`,
//! `shutdown.exe` and `Git push` all dispatch), while the unix verdicts stay
//! case-sensitive: a destructive verb written in another case is not recognised
//! there, which is a settled limit of the shared tables rather than a gap this
//! layer's fold closes.
//!
//! # Unverifiable from this host
//!
//! No Windows host or CI is available to this crate, so cmd.exe's own behaviour
//! is argued rather than measured: how it splits and quotes arguments (a `\`
//! before a space is ordinary text for cmd but an escape for the bash parse the
//! guard reads), its parameter delimiters and separators, its glued switches,
//! its device names, its delayed `!VAR!`/caret escapes and its case-insensitive
//! verb dispatch. Every verdict the layer reaches rests on that reading rather
//! than on anything observed here — the refusals under `# Matching` and the
//! limits under `# Accepted limits` alike. Chiefly argued: that Win32 strips a
//! trailing dot or space off a name component, that cmd.exe resolves a bare
//! `;`-bearing word the way it resolves the fragments the guard splits it into,
//! that a `%…%` outside the name grammar stays literal, that this platform has
//! no text block at all, so a `<<` has none to reach, that an omitted
//! `move`/`replace` destination defaults to cmd's own directory
//! ([`CWD_DESTINATION_VERBS`]), and that `ren`/`mklink` refuse an omitted second
//! operand rather than defaulting it.

use std::borrow::Cow;
use std::path::PathBuf;

use tree_sitter::Node;

use super::super::{REFUSAL_FRAME, framed_refusal};
use super::scan;
use super::{CheckContext, ShellPlatform, rejection_message};

// ── Verdict ──────────────────────────────────────────────────────────────

/// Verdict of the Windows layer for one command segment.
pub(super) enum WinVerdict {
    /// The layer models this verb and permits the invocation: the shared
    /// unix-shaped dispatch must not run for it.
    Allow,
    /// Refused, with the full rejection text (`rejection_message`).
    Refuse(String),
    /// Not a Windows-layer verb: fall through to the shared dispatch.
    Pass,
}

// ── Verb tables ──────────────────────────────────────────────────────────

/// Executable extensions stripped from a verb word before matching.
const VERB_EXTENSIONS: &[&str] = &["exe", "com", "bat", "cmd"];

/// System-level commands refused outright: unlike the file-scoped mutators
/// below there is no temp-scoped grant that would make any of them safe.
#[rustfmt::skip] // one line per family, so each group stays unambiguous
const DENIED_VERBS: &[&str] = &[
    // volumes, partitions, disks, filesystem and boot configuration
    "format", "diskpart", "diskcomp", "diskcopy", "chkdsk", "chkntfs", "fsutil",
    "defrag", "convert", "bcdedit", "bootcfg", "mountvol", "subst", "wbadmin",
    "vssadmin", "label", "diskperf",
    // archive extraction to a destination the layer cannot model
    "expand",
    // system repair, event-log, power and security policy configuration
    "bcdboot", "wevtutil", "secedit", "powercfg", "sfc", "auditpol", "dism",
    "gpupdate", "verifier",
    // permissions, ownership, attributes
    "takeown", "setx",
    // processes and services
    "taskkill", "tskill", "net", "net1",
    // registry and file associations
    "regedit", "regedt32", "regini", "regsvr32", "assoc", "ftype",
    // scheduled tasks, service/package installation, session end
    "at", "instsrv", "logoff", "msiexec", "wusa",
    // network reconfiguration
    "netsh", "route",
];

/// File- and tree-scoped mutators: refused unless every path argument resolves
/// under the accepted temp location — the same single grant the unix
/// `rm`/`rmdir`/`mkdir` gate keeps.
const TEMP_GATED_VERBS: &[&str] = &[
    "del", "erase", "rd", "rmdir", "md", "mkdir", "move", "ren", "rename", "mklink", "replace",
];

/// Temp-gated verbs whose destination cmd.exe may omit, defaulting it to its own
/// process directory — the unmodelled cwd the module doc describes. They need the
/// rule the copy-shaped verbs get from [`destination_under_temp`]: a write whose
/// destination is not proven stays refused. `ren`/`mklink` error out without
/// their second operand, so only `move` and `replace` qualify.
const CWD_DESTINATION_VERBS: &[&str] = &["move", "replace"];

/// Copy-shaped mutators: only the destination (the last path argument) must be
/// under temp — sources are read-only, mirroring the unix `cp` grant.
const TEMP_GATED_DESTINATION_VERBS: &[&str] = &["copy", "xcopy", "robocopy"];

/// The known switches of the copy-shaped verbs that touch something other than
/// the destination: the source tree (`/MOVE`, `/MOV` delete what was copied),
/// the registry (`/REG`), an arbitrary log file (`/LOG:`, `/UNILOG:`), file
/// times (`/TIMFIX`), the never-terminating monitor modes (`/MOT:`, `/MON:`,
/// which re-run the copy until interrupted) and the source files' archive
/// attribute (`xcopy /M`). Refused outright — the monitor modes included
/// although they delete nothing by themselves, because they are not a read-only
/// shape. Not a family-closed list: a switch outside it keeps the
/// destination-only grant.
const COPY_DENIED_SWITCHES: &[&str] = &[
    "move", "mov", "reg", "log", "unilog", "m", "mot", "mon", "timfix",
];

/// Attribute/ACL/encryption tools: the list form and its path operands are
/// read-only, while a switch (`/grant`, `/c`, `/w`) or an attribute operator
/// (`+r`, `-h`) is a mutation.
///
/// The read-only spelling cannot be separated from the mutating one
/// unambiguously — `attrib /s` and `icacls /T` only list, but the same shape
/// carries `attrib +r` and `icacls /grant` — so any switch or operator refuses
/// the tool rather than risk a mutating spelling.
const LIST_ONLY_VERBS: &[&str] = &["attrib", "icacls", "cacls", "cipher", "compact"];

/// Verbs whose read-only spelling is a named subcommand/switch: the FIRST
/// argument must be one of these (quote-stripped, case-folded).
const QUERY_VERBS: &[(&str, &[&str])] = &[
    ("reg", &["query"]),
    ("sc", &["query", "queryex"]),
    ("schtasks", &["/query"]),
];

/// cmd's clock builtins: bare (or `/t`) they only print, an operand sets the
/// machine clock. Matched on the segment's FIRST word rather than on
/// `verb_idx`, because the shared resolver reads a leading `time` as the bash
/// reserved word and lands the index on its operand (`time 12:00`), while
/// cmd.exe runs its builtin on the whole line.
const CLOCK_VERBS: &[&str] = &["time", "date"];

/// cmd.exe's internal commands that no other table covers: `cd`/`chdir` and
/// `pushd`/`popd` (allowed outright by [`check_segment`], whatever is glued to
/// them) and `set`. The rest of the `cmd /?` list either already sits in a
/// [`VERB_TABLES`] entry or has no verdict the glue split could change — `del`,
/// `date` and `assoc` are the table verbs' own spellings.
///
/// The four cwd spellings are one family HERE because the guard only needs to
/// admit them; the engine's own reading of the same family differs by what it
/// does with each — `grep_engine::is_cd_segment` recognises all four so none is
/// left with a stale tracked cwd, while `grep_engine::resolve_cd` tracks
/// `cd`/`chdir`/`pushd` as navigation and refuses `popd` fail-closed, since the
/// directory it returns to lives only in the pushed stack that model does not
/// keep. A change to either side — the family, or what a member of it does to
/// the cwd — is checked against the other.
const INTERNAL_VERBS: &[&str] = &["cd", "chdir", "popd", "pushd", "set"];

// ── Verb key ─────────────────────────────────────────────────────────────

/// Split cmd.exe's glued switch off a raw command word: an internal command
/// takes its switches with no separating space (`del/q C:\ws\x.txt` runs
/// `del /q …`, `rd/s/q X` runs `rd /s /q X`). The split is what makes such a
/// verb readable at all: left whole, the word is a separator-carrying spelling
/// with no known executable extension — not a plain command name — so the
/// mutator behind it would never reach its table. The word is split only when
/// the text before the first `/` names a verb the layer acts on
/// ([`is_glue_splittable`]), so a path-qualified spelling keeps its basename
/// (`subdir/rm.exe` stays the program `rm.exe`, `C:/Windows/format.com` the
/// program `format.com`). An `@` prefix stays on the head — [`verb_key`] strips
/// it.
fn split_glued_switch(word: &str) -> (&str, Vec<&str>) {
    let bare = word.strip_prefix('@').unwrap_or(word);
    let Some((head, _)) = bare.split_once('/') else {
        return (word, Vec::new());
    };
    if !is_glue_splittable(head) {
        return (word, Vec::new());
    }
    let at = word.len() - bare.len() + head.len();
    (&word[..at], switch_cluster(&word[at..]))
}

/// The switch tokens of a glued cluster: cmd starts a new switch at every `/`
/// (`rd/s/q X` is `rd /s /q X`). The split is load-bearing, not cosmetic: a
/// cluster left whole carries a `/`, which [`switch_is_operand`] reads as the
/// path signal of a `/ws/b.txt`-shaped operand — the switches would then have to
/// satisfy the temp gate and every glued invocation would be refused.
fn switch_cluster(glue: &str) -> Vec<&str> {
    let mut switches = Vec::new();
    let mut start = 0;
    for (at, _) in glue.match_indices('/').skip(1) {
        switches.push(&glue[start..at]);
        start = at;
    }
    switches.push(&glue[start..]);
    switches
}

/// The flat verb tables the glue split consults. A table whose verbs may take a
/// glued switch belongs here, or its glued spellings silently miss the split
/// ([`is_glue_splittable`]); [`CWD_DESTINATION_VERBS`] is not listed because its
/// verbs all sit on [`TEMP_GATED_VERBS`] as well, so they are covered through
/// that entry. [`QUERY_VERBS`] pairs a verb with its permitted spellings, so
/// [`is_glue_splittable`] consults it separately.
const VERB_TABLES: &[&[&str]] = &[
    DENIED_VERBS,
    TEMP_GATED_VERBS,
    TEMP_GATED_DESTINATION_VERBS,
    LIST_ONLY_VERBS,
    CLOCK_VERBS,
    INTERNAL_VERBS,
];

/// True when a command word's leading text names a verb this layer acts on, and
/// so may carry a glued switch. Splitting the token is cmd.exe's own behaviour
/// for its internal commands (argued from documented behaviour like every
/// cmd.exe reading here — see `# Unverifiable from this host`); the table verbs
/// that are external programs (`format`, `taskkill`, `xcopy`, …) are split the
/// same way because their glued spelling cannot be argued from this host and
/// over-rejection is the accepted failure direction. Balanced quotes are
/// stripped first, so a quoted head (`"del"/q`) is read as the name cmd.exe
/// resolves.
fn is_glue_splittable(head: &str) -> bool {
    let head = scan::strip_quoted_word(head);
    VERB_TABLES
        .iter()
        .any(|table| table.iter().any(|verb| verb.eq_ignore_ascii_case(head)))
        || QUERY_VERBS
            .iter()
            .any(|(name, _)| name.eq_ignore_ascii_case(head))
}

/// The guard's verb key for a raw command word: the glued switch is split off
/// ([`split_glued_switch`]), balanced outer quotes stripped, cmd's `@` no-echo
/// prefix dropped, the directory prefix dropped on `\` or `/`, a known
/// executable extension removed, ASCII-lowercased
/// (`C:\Windows\System32\FORMAT.COM` → `format`, `"C:\Program Files\x\del.exe"`
/// → `del`, `@del` → `del`, `del/q` → `del`). A word carrying a separator names
/// a program only through a known executable extension; without one it is not a
/// plain command name. `None` when the word is not a plain command name — a
/// `$`/`%`/`!` word (indirection), a glob, a separator-carrying word with no
/// executable extension, or an empty/still-quoted/exotic spelling — so the
/// caller falls through to the shared dispatch, which either refuses the word as
/// unprovable or matches its own tables against the folded basename. It is never
/// this layer's deny list that decides such a word (an accepted limit).
/// Deliberate drift seam: the shared `command_word_basename`/
/// `canonical_command` pair keeps selecting the shell's output profile from the
/// unplatformed spelling, so a later platform-aware change there would make this
/// deny list and the profile dispatch disagree.
pub(super) fn verb_key(word: &str) -> Option<String> {
    verb_key_of(split_glued_switch(word).0)
}

/// The command word the shared dispatch matches its tables against on
/// `platform`.
///
/// On Windows cmd.exe dispatches case-insensitively and ignores the executable
/// extension, so `raw` is read through [`verb_key`] — and, for a word the layer
/// cannot read as a plain command name (a variable, a quoted spelling), through
/// the lowercased `fallback`. On unix `fallback` is returned unchanged: the
/// shared tables stay case-sensitive there.
pub(super) fn dispatch_word(platform: ShellPlatform, raw: &str, fallback: String) -> String {
    if platform == ShellPlatform::Windows {
        verb_key(raw).unwrap_or_else(|| fallback.to_ascii_lowercase())
    } else {
        fallback
    }
}

/// [`verb_key`] for a word already stripped of any glued switch — the head of
/// [`split_glued_switch`].
fn verb_key_of(word: &str) -> Option<String> {
    let w = scan::strip_quoted_word(word);
    let w = w.strip_prefix('@').unwrap_or(w);
    if w.is_empty()
        || w.contains([
            '@', '$', '`', '%', '!', '*', '?', '[', ']', '{', '}', ',', '~', '\'', '"',
        ])
    {
        return None;
    }
    let basename = w
        .rsplit(['\\', '/'])
        .next()
        .expect("rsplit always yields at least one segment");
    if basename.is_empty() || basename.chars().any(char::is_whitespace) {
        return None;
    }
    let (stem, has_extension) = match basename.rsplit_once('.') {
        Some((stem, ext))
            if !stem.is_empty() && VERB_EXTENSIONS.iter().any(|e| e.eq_ignore_ascii_case(ext)) =>
        {
            (stem, true)
        }
        _ => (basename, false),
    };
    // A separator-carrying word only names a program through a known executable
    // extension (`C:\Windows\System32\FORMAT.COM`): without one cmd.exe would
    // apply PATHEXT or fail, and a path word the bash parse split on whitespace
    // can hide the real command in a later fragment — neither is a proof.
    if !has_extension && stem.len() != w.len() {
        return None;
    }
    Some(stem.to_ascii_lowercase())
}

// ── Lexical Windows path model ───────────────────────────────────────────

/// Split a folded Windows path into its prefix (`c:` / `\\server\share` / `""`)
/// and the remaining segment text. A `\\`-leading path with an empty server or
/// share is degenerate and fails (fail-closed).
fn split_prefix(path: &str) -> Option<(String, &str)> {
    if let Some(len) = drive_letter(path) {
        // A drive prefix is two ASCII bytes, so the slice is a char boundary.
        return Some((path[..len].to_ascii_lowercase(), &path[len..]));
    }
    if let Some(stripped) = path.strip_prefix(r"\\") {
        let mut parts = stripped.splitn(3, '\\');
        let server = parts.next().unwrap_or("");
        let share = parts.next().unwrap_or("");
        if server.is_empty() || share.is_empty() {
            return None;
        }
        let end = 2 + server.len() + 1 + share.len();
        return Some((path[..end].to_ascii_lowercase(), &path[end..]));
    }
    Some((String::new(), path))
}

/// The length of a leading drive-letter prefix (`c:`), if any.
fn drive_letter(path: &str) -> Option<usize> {
    let bytes = path.as_bytes();
    (bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()).then_some(2)
}

/// True when a folded path is drive- or UNC-absolute: a drive letter followed by
/// a separator (`c:\x`), or a `\\`-prefixed UNC/device path. A bare `c:x` is
/// drive-*relative* and is not absolute.
fn is_absolute(folded: &str) -> bool {
    if folded.starts_with(r"\\") {
        return true;
    }
    drive_letter(folded).is_some_and(|len| folded.as_bytes().get(len) == Some(&b'\\'))
}

/// Normalize a Windows path to its comparison form (see the module doc):
/// `/` folded to `\`, parts joined by single `\`, `.` dropped, `..` resolved
/// lexically (climbing above the root fails), ASCII-lowercased, no trailing
/// separator, prefix preserved.
fn normalize(path: &str) -> Option<String> {
    let folded = path.replace('/', "\\");
    let (prefix, rest) = split_prefix(&folded)?;
    let mut segments: Vec<String> = Vec::new();
    for segment in rest.split('\\') {
        match segment {
            "" | "." => {}
            ".." => {
                segments.pop()?;
            }
            segment => {
                // Win32 strips trailing dots and spaces off a component, so
                // `C:\Temp\.. \x` reaches outside temp while reading as a
                // literal `.. ` segment. A component the platform would rewrite
                // cannot be compared lexically — fail closed.
                if segment.ends_with(['.', ' ']) {
                    return None;
                }
                segments.push(segment.to_ascii_lowercase());
            }
        }
    }
    let mut out = prefix;
    for segment in &segments {
        out.push('\\');
        out.push_str(segment);
    }
    Some(out)
}

/// The inner text of a balanced pair of surrounding DOUBLE quotes. cmd.exe
/// quotes only with `"`, so a single-quoted word keeps its quotes (and the
/// lexical model then fails closed on it).
fn strip_double_quotes(word: &str) -> Option<&str> {
    word.strip_prefix('"')
        .and_then(|rest| rest.strip_suffix('"'))
        .filter(|inner| !inner.contains('"'))
}

/// The text cmd.exe delivers for one word: balanced double quotes stripped, the
/// word as written otherwise. `None` when the word carries a quote character cmd
/// does not interpret — a single-quoted word is ordinary text whose characters
/// are not the ones the layer must judge, and an unbalanced `"` is not a quoted
/// argument — so every classifier that would read it as a switch or a name fails
/// closed instead.
fn cmd_token(word: &str) -> Option<&str> {
    match strip_double_quotes(word) {
        Some(inner) => Some(inner),
        None if word.contains(['"', '\'']) => None,
        None => Some(word),
    }
}

/// The absolute, unfolded spelling of a path operand: balanced double quotes
/// stripped, `%VAR%` expanded, `/` folded to `\`. `None` for every spelling
/// whose operand the layer cannot prove — the classes and their reasons are in
/// the module doc's `# Matching`.
fn resolve_raw(word: &str, ctx: &CheckContext) -> Option<String> {
    let raw = cmd_token(word)?;
    // `cmd_token` strips only a balanced double-quoted pair, so a delivered text
    // differing from the word is exactly "cmd read this as one quoted token".
    let quoted = raw != word;
    if raw.is_empty() {
        return None;
    }
    let expanded = expand_percent_vars(raw, ctx)?.replace('/', "\\");
    // A glob, cmd's `^` escape and `!`, its delayed expansion: the interpreter this
    // shell starts has delayed expansion off, but `setlocal enabledelayedexpansion`
    // inside a command, or a nested interpreter the command starts, is not covered —
    // and `^` hides the character behind it in any case. None of them is a path the
    // layer can prove.
    if expanded.contains(['*', '?', '~', '^', '!']) {
        return None;
    }
    // cmd expands before it tokenises, so a delimiter or separator arriving from
    // a `%VAR%` value splits the operand exactly as a literal one does; only the
    // double-quoted spelling is one operand for both readers.
    if !quoted
        && (expanded.chars().any(char::is_whitespace)
            || expanded.contains([',', '=', ';', '&', '|', '<', '>']))
    {
        return None;
    }
    is_absolute(&expanded).then_some(expanded)
}

/// [`resolve_raw`] folded to the comparison form of [`normalize`].
fn resolve(word: &str, ctx: &CheckContext) -> Option<String> {
    normalize(&resolve_raw(word, ctx)?)
}

/// True when `path` (already normalized) is at or under one of the context's
/// accepted temp roots.
fn under_roots(path: &str, ctx: &CheckContext) -> bool {
    ctx.temp_roots.iter().any(|root| {
        let Some(root) = normalize(&root.to_string_lossy()) else {
            return false;
        };
        !root.is_empty()
            && (path == root
                || path
                    .strip_prefix(&root)
                    .is_some_and(|rest| rest.starts_with('\\')))
    })
}

/// Compare a `canonicalize` result against the accepted temp roots: the verbatim
/// prefix is dropped and the string goes through [`normalize`] — `canonicalize`
/// returns the on-disk case, so a raw comparison never matches the lowercased
/// roots.
fn canonical_under_roots(canonical: &str, ctx: &CheckContext) -> bool {
    normalize(&strip_verbatim(canonical)).is_some_and(|path| under_roots(&path, ctx))
}

/// Reparse-point (junction/symlink) escape check: the canonical location of
/// `path` — of its nearest existing ancestor when the leaf is still new — must
/// still be under an accepted temp root, the analogue of the unix
/// `is_path_under_temp` parent walk. Nothing this host can resolve (a
/// Windows-shaped path off Windows) leaves the lexical verdict standing.
fn resolves_inside_temp(path: &str, ctx: &CheckContext) -> bool {
    let mut probe = PathBuf::from(path);
    loop {
        if let Ok(canonical) = probe.canonicalize() {
            return canonical_under_roots(&canonical.to_string_lossy(), ctx);
        }
        match probe.parent() {
            // The empty-parent guard is load-bearing: off Windows
            // `Path::new(r"C:\Temp\x").parent()` is `""`, and an empty path
            // canonicalizes to the host cwd — which would reject every
            // Windows temp path in the unit lane.
            Some(parent) if !parent.as_os_str().is_empty() && parent != probe => {
                probe = parent.to_path_buf();
            }
            _ => return true,
        }
    }
}

/// True when the word provably names a location under an accepted temp root.
pub(super) fn under_temp(word: &str, ctx: &CheckContext) -> bool {
    let Some(path) = resolve(word, ctx) else {
        return false;
    };
    under_roots(&path, ctx) && resolves_inside_temp(&path, ctx)
}

/// Drop the verbatim prefix `canonicalize` adds on Windows (`\\?\`, and
/// `\\?\UNC\` for a share), so a canonical path compares against the ordinary
/// spelling of the temp roots.
fn strip_verbatim(canonical: &str) -> String {
    if let Some(rest) = canonical.strip_prefix(r"\\?\UNC\") {
        return format!(r"\\{rest}");
    }
    canonical
        .strip_prefix(r"\\?\")
        .unwrap_or(canonical)
        .to_string()
}

/// Expand cmd.exe's `%NAME%` references against the temp variables the shells
/// are handed — the same single source as the accepted temp roots, so the name
/// the layer resolves and the environment cmd gives the child cannot drift. The
/// name matches case-insensitively; a name the layer does not bind fails closed
/// — an unbound one expands to nothing and the empty expansion is not a provable
/// path, while cmd's own `%USERPROFILE%`/`%CD%` resolve to values this model
/// cannot see. A `%` with no partner, or with a non-name between, stays literal
/// — an argued reading of cmd's expansion, not a measured one (an accepted
/// limit; see the module doc).
///
/// The pairing above is this reader's, and the interception reads the same
/// character with a coarser rule of its own: `grep_engine::windows::has_percent_expansion`
/// refuses any word carrying two `%` at all, whatever sits between them. That is
/// a deliberate superset, not an older draft of this function — a served
/// member's text rides its spec file and never reaches cmd.exe's command line,
/// so the engine only needs to know whether an expansion could change what the
/// program receives, while this reader must decide what the expansion IS (an
/// operand path, or an unprovable one). The two readings answer different
/// questions and are not to be reconciled into one.
fn expand_percent_vars(word: &str, ctx: &CheckContext) -> Option<String> {
    let mut out = String::with_capacity(word.len());
    let mut rest = word;
    while let Some(start) = rest.find('%') {
        let after = &rest[start + 1..];
        let Some(end) = after.find('%') else {
            break; // no partner: the dangling `%` and the tail stay literal
        };
        out.push_str(&rest[..start]);
        let name = &after[..end];
        if is_var_name(name) {
            let value = &ctx
                .temp_vars
                .iter()
                .find(|(key, _)| key.eq_ignore_ascii_case(name))?
                .1;
            out.push_str(value);
        } else {
            let literal_end = start + 1 + end + 1;
            out.push_str(&rest[start..literal_end]);
        }
        rest = &after[end + 1..];
    }
    out.push_str(rest);
    Some(out)
}

/// True when `name` is a `%NAME%` reference cmd.exe would resolve: non-empty
/// ASCII letters, digits and underscores.
fn is_var_name(name: &str) -> bool {
    !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}

/// The refusal for one of the whole-line divergences [`check_line`] decides: the
/// shared frame names the refusal ([`REFUSAL_FRAME`]) rather than a mode, because
/// these three are the one guard rule BOTH modes decide — read-only mode reaches
/// them through the guard's own walk, the unrestricted mode through
/// [`super::check_line_divergences`], where a frame claiming "Read-only mode"
/// would be false. The text after the frame is the shared one
/// ([`framed_refusal`]), so these refusals cannot read differently from the
/// guard's own.
fn refusal(cmd: &str, why: &str, suggestion: &str) -> String {
    framed_refusal(REFUSAL_FRAME, cmd, why, suggestion)
}

/// The whole-line Windows divergences — a comment, an unquoted `;` and a
/// heredoc — checked once over the whole syntax tree, before the walk. Each is a
/// property of the LINE for cmd.exe rather than of a nesting position (see the
/// module doc), so this scan is the layer's only decision point for any of them:
/// a rule hooked into the walker instead would police the positions that walker
/// happens to visit and skip the rest.
pub(super) fn check_line(root: Node, src: &str, platform: ShellPlatform) -> Result<(), String> {
    if platform != ShellPlatform::Windows {
        return Ok(());
    }
    // An explicit stack, not recursion: this walks the raw tree, whose depth is
    // the command string's, and a guard that aborts on a deeply nested command
    // is worse than one that over-rejects it.
    let mut stack = vec![root];
    while let Some(node) = stack.pop() {
        match node.kind() {
            "comment" => {
                return Err(refusal(
                    src,
                    "cmd.exe has no comment syntax — `#` is an ordinary character, so what the \
                     shell parser drops as a comment is an argument or a separator to cmd.exe.",
                    "write only the commands you want to run, and quote a `#` you mean literally \
                     (`echo \"a#b\"`).",
                ));
            }
            "heredoc_redirect" => {
                return Err(refusal(
                    src,
                    "cmd.exe has no `<<` — it reads two input redirections, not a text block, so \
                     the body's later lines would run as commands this guard never read.",
                    "write the command in the plain spelling cmd.exe accepts.",
                ));
            }
            ";" => {
                return Err(refusal(
                    src,
                    "cmd.exe does not split a command on `;` — several of its internal commands \
                     take it as an argument delimiter, so what the shell parser reads as a second \
                     command would be an extra operand of the first.",
                    "write two commands with `&&` (or `&`), which both readers split on, and quote \
                     a `;` you mean literally (`echo \"a;b\"`).",
                ));
            }
            _ => {}
        }
        let mut cursor = node.walk();
        stack.extend(node.children(&mut cursor));
    }
    Ok(())
}

/// One command segment's Windows verdict. `words` are the bash-parsed words;
/// `verb_idx` is the index the shared resolver found for the effective verb
/// (cmd.exe has no pre-verb prefixes, but the shared `time`-prefix handling
/// lands the index on the operand, which is what must be judged). `cmd` is the
/// raw command text used in rejection messages.
#[expect(clippy::too_many_lines)] // one branch per verb table, in refusal order
pub(super) fn check_segment(
    words: &[&str],
    verb_idx: usize,
    cmd: &str,
    ctx: &CheckContext,
) -> WinVerdict {
    // cmd also accepts switches glued to the command name (`del/q`, `time/t`,
    // `set/a`), so the verb word's glue is split off and re-joined to the
    // arguments, where every switch-consuming branch below sees it. The split
    // and the key of the segment's first word are computed once: the clock check
    // below and the main path both read that same word whenever the resolver
    // landed the verb index on it.
    let first = words.first().copied().unwrap_or_default();
    let (first_word, first_glue) = split_glued_switch(first);
    let first_key = verb_key(first_word);
    if let Some(first) = first_key.as_deref()
        && CLOCK_VERBS.contains(&first)
    {
        let args = glued_args(&first_glue, words.get(1..).unwrap_or_default());
        let bare = args
            .iter()
            .all(|arg| cmd_token(arg).is_some_and(|token| token.eq_ignore_ascii_case("/t")));
        return if bare {
            WinVerdict::Allow
        } else {
            WinVerdict::Refuse(rejection_message(
                cmd,
                "`time`/`date` with an operand sets the machine clock.",
                "drop the operand — the bare form (or `/t`) only reports.",
            ))
        };
    }
    let (verb_word, glue, verb) = if verb_idx == 0 {
        (first_word, first_glue, first_key)
    } else {
        let Some(word) = words.get(verb_idx) else {
            return WinVerdict::Pass;
        };
        let (word, glue) = split_glued_switch(word);
        (word, glue, verb_key(word))
    };
    let Some(verb) = verb else {
        return WinVerdict::Pass;
    };
    // The cd family writes nothing, so it is allowed outright — and the layer
    // must own the verdict: no relative operand is provable here
    // ([`resolve_raw`]), so a tracked directory would buy nothing, while the
    // shared dispatch would read `cd C:\ws` as an unknown command word.
    if matches!(verb.as_str(), "cd" | "chdir" | "popd" | "pushd") {
        return WinVerdict::Allow;
    }
    let args = glued_args(&glue, words.get(verb_idx + 1..).unwrap_or_default());
    let args = args.as_ref();

    // A bare drive switch (`D:`) moves the process to that drive: it writes no
    // file and needs no verdict of its own. An operand after it is a different
    // thing — cmd.exe takes none, and reading the rest as words of one command
    // would hide a mutator (`D: del C:\ws\x.txt`) — so it is refused.
    if is_drive_switch(verb_word) {
        return if args.is_empty() {
            WinVerdict::Allow
        } else {
            WinVerdict::Refuse(rejection_message(
                cmd,
                "a drive switch (`D:`) takes no operand in read-only mode.",
                "put the drive switch on its own (`C:` then the command), or name a fully-qualified path.",
            ))
        };
    }

    // `set` is cmd's variable builtin: the layer's view of the temp variables
    // must not drift from the one the children run with, so the shared unix
    // assignment machinery must not see it either.
    if verb == "set" {
        return check_set(args, cmd, ctx);
    }

    if DENIED_VERBS.contains(&verb.as_str()) {
        return WinVerdict::Refuse(rejection_message(
            cmd,
            &format!(
                "`{verb}` is not allowed in read-only mode — it changes system, service, \
                 registry, volume or account state outside any workspace scope."
            ),
            "use a read-only inspection command; this operation has no temp-scoped grant on any platform.",
        ));
    }

    // cmd's parameter delimiter glued behind a switch (`del /f,C:\ws\x.txt`,
    // `xcopy /move=C:\ws\x.txt`), for the verbs this layer acts on
    // ([`is_glue_splittable`]): cmd.exe splits the token there and hands the text
    // behind the delimiter to the verb as another argument. That text is never
    // read — the token stays switch-shaped, and a drive-qualified operand behind
    // the delimiter leaves [`switch_is_operand`] false too — and on the
    // copy-shaped verbs the glued deny-list spelling is invisible twice over,
    // since a copy switch's name stops at `:`/`+` ([`denied_copy_switch`]) and so
    // `/move,` and `/log=` never reached [`COPY_DENIED_SWITCHES`] either, leaving
    // the line inside the destination-only grant. Refused outright.
    if is_glue_splittable(&verb) && args.iter().copied().any(glued_delimiter_switch) {
        return WinVerdict::Refuse(rejection_message(
            cmd,
            &format!(
                "`{verb}` with a switch glued to its operand by cmd.exe's parameter delimiter \
                 (`,` or `=`) is not allowed in read-only mode — cmd.exe hands the text behind the \
                 delimiter to the command as another argument, which this guard cannot read as a \
                 switch or as a path."
            ),
            "separate the switch from its operand with a space (or drop the operand).",
        ));
    }

    // A verb on either mutator table holds the same single temp grant, so the
    // gate is asked with both in hand — a verb added to the narrower
    // [`CWD_DESTINATION_VERBS`] alone must not reach the shared dispatch ungated.
    // The omitted-destination rule stays the narrower table's own.
    let needs_destination = CWD_DESTINATION_VERBS.contains(&verb.as_str());
    let temp_gated = TEMP_GATED_VERBS.contains(&verb.as_str()) || needs_destination;
    if temp_gated {
        let paths = path_args(args);
        // Its own rule and its own why: a segment with no path has nothing for
        // the grant to cover — not the same statement as a path outside temp.
        if paths.is_empty() {
            return WinVerdict::Refuse(rejection_message(
                cmd,
                &format!(
                    "`{verb}` names no path, and read-only mode proves writes only under the \
                     daemon's temp location."
                ),
                super::temp_path_or_alternatives_hint(ctx.platform),
            ));
        }
        if !all_paths_under_temp(&paths, ctx) {
            return WinVerdict::Refuse(rejection_message(
                cmd,
                &format!(
                    "`{verb}` is not allowed outside the temp directory — it deletes, moves \
                     or creates files outside the daemon's temp location."
                ),
                super::temp_path_or_alternatives_hint(ctx.platform),
            ));
        }
        if needs_destination && paths.len() < 2 {
            return WinVerdict::Refuse(rejection_message(
                cmd,
                &format!(
                    "`{verb}` without a destination writes into cmd.exe's own directory — a \
                     location read-only mode never proved is the accepted one."
                ),
                "name both the source and a destination under the daemon temp root the \
                 session's `%TMP%`/`%TEMP%` point at.",
            ));
        }
        return WinVerdict::Allow;
    }

    if TEMP_GATED_DESTINATION_VERBS.contains(&verb.as_str()) {
        if let Some(offending) = args.iter().copied().find(|tok| denied_copy_switch(tok)) {
            return WinVerdict::Refuse(rejection_message(
                cmd,
                &format!(
                    "`{verb} {offending}` is not allowed in read-only mode — that switch \
                     deletes the sources, writes the registry, or logs to an arbitrary file."
                ),
                "drop the switch; only a plain copy into a temp destination is permitted.",
            ));
        }
        let paths = path_args(args);
        return if destination_under_temp(&paths, ctx) {
            WinVerdict::Allow
        } else {
            // This branch spells its own temp route instead of taking the shared
            // `super::temp_path_or_alternatives_hint` the temp-gate branches
            // above use, and the duplication is deliberate: "sources may be read
            // from anywhere" is this family's own half of the grant, which that
            // helper cannot carry, and it is a `&'static str` selector, so
            // composing the two would mean building a string per refusal. An
            // edit to this advice belongs here, not in the helper.
            WinVerdict::Refuse(rejection_message(
                cmd,
                &format!(
                    "`{verb}` is not allowed — only a destination under the daemon temp \
                     location is permitted."
                ),
                "name a destination under the daemon temp root the session's `%TMP%`/`%TEMP%` \
                 point at; sources may be read from anywhere.",
            ))
        };
    }

    if LIST_ONLY_VERBS.contains(&verb.as_str()) {
        return if args
            .iter()
            .any(|tok| is_switch(tok) || tok.starts_with(['+', '-']))
        {
            WinVerdict::Refuse(rejection_message(
                cmd,
                &format!(
                    "`{verb}` is not allowed in read-only mode — its read-only spelling cannot be \
                     told apart from a mutating one, so no switch or attribute operator is \
                     permitted."
                ),
                "drop the switch or attribute operator and run the bare form.",
            ))
        } else {
            WinVerdict::Allow
        };
    }

    if let Some((_, spellings)) = QUERY_VERBS.iter().find(|(name, _)| *name == verb) {
        // The slash is not part of the spelling: `schtasks /query` and the
        // glued `schtasks/query` are the same command.
        if args
            .first()
            .copied()
            .and_then(cmd_token)
            .is_some_and(|tok| {
                spellings.iter().any(|spelling| {
                    spelling
                        .trim_start_matches('/')
                        .eq_ignore_ascii_case(tok.trim_start_matches('/'))
                })
            })
        {
            return WinVerdict::Allow;
        }
        let listed = spellings
            .iter()
            .map(|spelling| format!("`{verb} {spelling}`"))
            .collect::<Vec<_>>()
            .join(", ");
        return WinVerdict::Refuse(rejection_message(
            cmd,
            &format!("`{verb}` is only permitted in its read-only inspection spelling — {listed}."),
            "use the inspection spelling; the mutating forms change system state outside any workspace scope.",
        ));
    }

    WinVerdict::Pass
}

/// `set` is cmd's variable builtin. The value is everything after the first
/// space, so the words are re-joined before splitting, and cmd strips the outer
/// double quotes of the quoted spelling (`set "NAME=value"`) — both spellings
/// bind the same variable. `set` alone lists the environment and `set NAME`
/// queries one variable: both only read, and only the name's shape has to hold.
///
/// A binding is refused when the name is not a plain environment name — a
/// `/a`/`/p` switch form evaluates an expression or reads input, an empty name
/// binds nothing addressable, and cmd rewrites the line before it binds, so
/// `set TEMP^=C:\ws` would bind `TEMP` under the layer's feet — and when it
/// binds something the guard resolves: a temp variable or a `GIT_*` name. The
/// temp-variable policy behind that is in the module doc.
fn check_set(args: &[&str], cmd: &str, ctx: &CheckContext) -> WinVerdict {
    let joined = args.join(" ");
    let Some(joined) = cmd_token(&joined) else {
        return WinVerdict::Refuse(rejection_message(
            cmd,
            "`set` with a quote character cmd.exe does not interpret — the binding cannot be read.",
            "write a plain `set NAME=value` binding, quoting it with double quotes if it carries spaces.",
        ));
    };
    let (name, binding) = match joined.split_once('=') {
        Some((name, _)) => (name, true),
        None if joined.is_empty() => return WinVerdict::Allow,
        None => (joined, false),
    };
    if !is_var_name(name) {
        return WinVerdict::Refuse(rejection_message(
            cmd,
            &format!(
                "`set {joined}` — only a bare name or a `NAME=value` binding is modelled: a switch \
                 form evaluates or reads instead of binding, and cmd rewrites the line before it \
                 binds, so a name outside the plain charset proves nothing."
            ),
            "use a plain `set NAME=value` (letters, digits and underscores in the name), or a bare `set`.",
        ));
    }
    if !binding {
        return WinVerdict::Allow;
    }
    if ctx
        .temp_vars
        .iter()
        .any(|(temp_name, _)| temp_name.eq_ignore_ascii_case(name))
    {
        return WinVerdict::Refuse(rejection_message(
            cmd,
            &format!(
                "`set {name}=…` rebinds a temp variable the guard resolves — the value the \
                 shells are handed — so a rebinding cannot be followed."
            ),
            "leave the temp variables alone; name the location directly (a literal path under the \
             daemon temp root, or `%TMP%`/`%TEMP%`).",
        ));
    }
    // cmd's environment is case-insensitive, so the shared `GIT_*` binding rule
    // applies to the folded name — otherwise `set git_dir=…` slips a transitive
    // git invocation past the exec-vector scan.
    if super::git_env_name_denied(&name.to_ascii_uppercase()) {
        return WinVerdict::Refuse(rejection_message(
            cmd,
            "`set` with a `GIT_*` variable is not allowed in read-only mode — git reads it as an exec vector.",
            "drop the binding; inspect the repository with `git status`/`git log`/`git ls-remote` instead.",
        ));
    }
    WinVerdict::Allow
}

// ── Argument helpers ─────────────────────────────────────────────────────

/// A verb word's glued switches ([`split_glued_switch`]) followed by the segment's
/// own arguments, so a switch-consuming branch reads the glued spelling exactly
/// as the spaced one. Borrowed when there is no glue — the common case, and the
/// reason this does not copy the arguments for every modelled segment.
fn glued_args<'x, 'a>(glue: &'x [&'a str], rest: &'x [&'a str]) -> Cow<'x, [&'a str]> {
    if glue.is_empty() {
        return Cow::Borrowed(rest);
    }
    Cow::Owned(glue.iter().copied().chain(rest.iter().copied()).collect())
}

/// True when a token is a cmd.exe switch or a unix-style flag — never a path
/// argument. A token cmd would not deliver as written (a single-quoted word) is
/// an operand, so it must satisfy the temp gate like any other.
fn is_switch(tok: &str) -> bool {
    cmd_token(tok).is_some_and(|tok| tok.starts_with('/') || tok.starts_with('-'))
}

/// A bare cmd.exe drive switch (`D:`), which moves the process cwd to that
/// drive's own current directory — a location the layer does not model.
fn is_drive_switch(word: &str) -> bool {
    cmd_token(word).is_some_and(|word| {
        word.len() == 2
            && word.ends_with(':')
            && word.starts_with(|c: char| c.is_ascii_alphabetic())
    })
}

/// True when a copy-shaped verb's switch is one of the refused
/// [`COPY_DENIED_SWITCHES`]: delivered as cmd delivers it, the token must start
/// with `/` or `-` and its name is the text up to the first `:` or `+` — so
/// `/LOG:C:\ws\run.log`, `/LOG+:x` and `-move` are all caught. A token cmd would
/// not deliver as written (a single-quoted word) is not a switch and is read as
/// an operand instead; these verbs gate their destination only, so such a token
/// goes unjudged unless it is the last path argument.
fn denied_copy_switch(token: &str) -> bool {
    let Some(tok) = cmd_token(token) else {
        return false;
    };
    let Some(name) = tok.strip_prefix(['/', '-']) else {
        return false;
    };
    let name = name.split([':', '+']).next().unwrap_or_default();
    COPY_DENIED_SWITCHES
        .iter()
        .any(|switch| name.eq_ignore_ascii_case(switch))
}

/// True when a switch-shaped token is really an operand: cmd.exe accepts `/` as a
/// path separator, so `/ws/b.txt` (or `/b.txt`) names a file, not a switch. A
/// token carrying a `:` is a switch with its argument (`/R:1`, `/XF:*.log`), and
/// one carrying no path signal at all is an ordinary switch (`/s`, `/-Y`).
fn switch_is_operand(tok: &str) -> bool {
    let Some(tok) = cmd_token(tok) else {
        return false;
    };
    let Some(rest) = tok.strip_prefix(['/', '-']) else {
        return false;
    };
    !rest.contains(':') && rest.contains(['.', '\\', '/'])
}

/// True when a switch-shaped token carries cmd's parameter delimiter after the
/// switch text (`/f,C:\ws\x.txt`, `/move=C:\ws`): cmd.exe splits the token there
/// and hands the text behind the delimiter to the verb as another argument — so
/// the token is neither a switch the layer may drop nor a word it can read as a
/// path. The segment is refused (see `# Matching`).
fn glued_delimiter_switch(tok: &str) -> bool {
    cmd_token(tok)
        .and_then(|tok| tok.strip_prefix(['/', '-']))
        .is_some_and(|rest| rest.contains([',', '=']))
}

/// The path arguments of a Windows command segment: every word that is not a
/// switch — plus a switch-shaped spelling that carries a path signal, which cmd
/// reads as an operand, so it must satisfy the temp gate like any other path.
fn path_args<'a>(args: &[&'a str]) -> Vec<&'a str> {
    args.iter()
        .copied()
        .filter(|tok| !is_switch(tok) || switch_is_operand(tok))
        .collect()
}

/// True when every path argument of a segment resolves under an accepted temp
/// root. Vacuously true with no path argument — that case has its own refusal at
/// the call site.
fn all_paths_under_temp(paths: &[&str], ctx: &CheckContext) -> bool {
    paths.iter().copied().all(|path| under_temp(path, ctx))
}

/// True when the LAST path argument (the destination of a copy-shaped verb)
/// resolves under an accepted temp root. A copy naming fewer than two paths is
/// refused — the why is in the module doc. Takes a segment's path arguments, as
/// [`all_paths_under_temp`] does.
fn destination_under_temp(paths: &[&str], ctx: &CheckContext) -> bool {
    paths.len() >= 2
        && paths
            .last()
            .copied()
            .is_some_and(|dest| under_temp(dest, ctx))
}

// ── Tests ────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::shell::readonly::{
        CheckContext, ShellPlatform, ValidationState, check_command, check_line_divergences,
        is_null_target,
    };
    use std::path::Path;

    /// The fixture's temp root, in Windows spelling: the host's own temp
    /// directory on a Windows host — the reparse-point walk canonicalizes the
    /// nearest existing ancestor, so the root must exist there — and the
    /// fabricated `C:\Temp` elsewhere, where nothing resolves and the lexical
    /// verdict carries (the walk's fallback). It is what the context binds
    /// `%TMP%`/`%TEMP%` to, and it is expressed in its canonical comparison form:
    /// `canonicalize` answers in the on-disk case behind a `\\?\` verbatim
    /// prefix, which `canonical_under_roots` strips off its own probes.
    fn temp_root() -> String {
        if cfg!(windows) {
            let temp = std::env::temp_dir();
            let canonical = temp.canonicalize().unwrap_or(temp);
            strip_verbatim(&canonical.to_string_lossy())
        } else {
            r"C:\Temp".to_string()
        }
    }

    /// A Windows-platform context over the layer's own lexical model: the
    /// platform is pinned (the host must never decide what these assert), while
    /// the temp root and its `%TMP%`/`%TEMP%` bindings come from the host — the
    /// layer expands the variables, so the production `%TEMP%` spelling is
    /// exercised on any host.
    ///
    /// Every temp OPERAND below is written double-quoted. The lane has to hold on
    /// a host whose temp path carries whitespace (`C:\Users\John Doe\…`, a common
    /// install), where cmd.exe hands an unquoted operand to the program in
    /// pieces — the layer refuses that spelling, which is its cmd-parity rule and
    /// not a fixture failure. The unquoted spelling's verdicts are pinned by
    /// `whitespace_after_expansion_fails_closed`.
    fn win_ctx() -> CheckContext {
        let mut ctx = CheckContext::for_platform(Path::new(r"C:\ws"), ShellPlatform::Windows);
        ctx.temp_roots = vec![PathBuf::from(temp_root())];
        // Both names the Windows shells are handed (see `crate::temp`).
        ctx.temp_vars = vec![
            ("TMP".to_string(), temp_root()),
            ("TEMP".to_string(), temp_root()),
        ];
        ctx
    }

    fn ok(cmd: &str) {
        let ctx = win_ctx();
        assert!(
            check_command(cmd, &ctx).is_ok(),
            "expected ALLOW but got REJECT for: `{cmd}`"
        );
    }

    fn assert_rejected(cmd: &str) {
        let ctx = win_ctx();
        assert!(
            check_command(cmd, &ctx).is_err(),
            "expected REJECT but got ALLOW for: `{cmd}`"
        );
    }

    #[test]
    fn verb_key_strips_quotes_prefixes_and_executable_extensions() {
        let cases = [
            (r"C:\Windows\System32\FORMAT.COM", Some("format")),
            (r#""C:\Program Files\x\del.exe""#, Some("del")),
            ("DEL.BAT", Some("del")),
            // A separator-carrying word names a program only through a known
            // executable extension.
            ("/usr/bin/rm", None),
            ("/usr/bin/rm.exe", Some("rm")),
            ("dir", Some("dir")),
            // cmd's `@` no-echo prefix is not part of the command name.
            ("@del", Some("del")),
            ("@FORMAT.COM", Some("format")),
            // A switch glued to a modelled verb is not part of its name; a
            // word whose head is not a verb keeps its basename.
            ("del/q", Some("del")),
            ("RD/S/Q", Some("rd")),
            ("@del/q", Some("del")),
            ("format/q", Some("format")),
            ("attrib/q", Some("attrib")),
            ("\"del\"/q", Some("del")),
            ("C:/Windows/format.com", Some("format")),
            ("bin/rm", None),
            // Only known executable extensions are stripped.
            ("nul.txt", Some("nul.txt")),
            // Indirection, globs and non-names are never plain command names.
            ("%BIN%", None),
            ("!BIN!", None),
            ("$del", None),
            ("de*", None),
            ("de@l", None),
            ("de l", None),
            ("''", None),
        ];
        for (word, expected) in cases {
            assert_eq!(verb_key(word).as_deref(), expected, "word `{word}`");
        }
    }

    /// cmd's internal commands accept their switch glued to the name
    /// (`del/q X` runs `del /q X`), so the glued spelling must get the verdict
    /// of the spaced one — refused where it writes outside temp, admitted
    /// inside it, and never read as a command whose arguments were never gated.
    #[test]
    fn glued_switches_keep_the_spaced_verdict() {
        assert_rejected(r"del/q C:\ws\x.txt");
        assert_rejected(r"rd/s/q C:\ws\tree");
        assert_rejected(r#"move/y C:\ws\a.txt "%TEMP%\b.txt""#);
        assert_rejected(r#"copy/y "%TEMP%\a.txt" C:\ws\b.txt"#);
        // A table verb that is an external program is split the same way (its
        // glued spelling cannot be argued from this host, so it over-rejects).
        assert_rejected(r"taskkill/f /im notepad.exe");
        assert_rejected(r"format/q C:");
        ok(r#"del/q "%TEMP%\x.txt""#);
        ok(r#"rd/s/q "%TEMP%\junk""#);
        ok(r#"md/q "%TEMP%\new""#);
        ok(r#"copy/y C:\ws\a.txt "%TEMP%\b.txt""#);
        // `set /a` evaluates an expression instead of binding a literal.
        assert_rejected("set/a x=1");
        assert_rejected("set/p x=");
        // `cd/d` is a builtin's glued switch: the cd is allowed and the layer
        // never reads it as a program whose arguments would go ungated.
        ok(r#"md "%TEMP%\new" && cd/d "%TEMP%\new""#);
    }

    #[test]
    fn path_model_resolves_drive_and_unc_prefixes() {
        assert_eq!(normalize(r"C:\A\..\B\.\c"), Some(r"c:\b\c".to_string()));
        assert_eq!(normalize(r"c:\a\..\..\b"), None); // climbs above the root
        assert_eq!(
            normalize(r"\\Server\Share\A\..\B"),
            Some(r"\\server\share\b".to_string())
        );
        assert_eq!(split_prefix(r"c:\x"), Some(("c:".to_string(), r"\x")));
        assert_eq!(split_prefix("relative"), Some((String::new(), "relative")));
        // Degenerate UNC and empty paths cannot be proven (fail-closed).
        assert_eq!(split_prefix(r"\\"), None);
        assert_eq!(normalize(""), Some(String::new()));
    }

    #[test]
    fn percent_variables_expand_case_insensitively_and_fail_closed() {
        let ctx = win_ctx();
        let expected = format!("{}\\x", temp_root());
        assert_eq!(
            expand_percent_vars(r"%temp%\x", &ctx).as_deref(),
            Some(expected.as_str())
        );
        assert_eq!(
            expand_percent_vars(r"%TMP%\x", &ctx).as_deref(),
            Some(expected.as_str())
        );
        // A `%` with no partner or a non-name between it stays literal.
        assert_eq!(expand_percent_vars("100%", &ctx).as_deref(), Some("100%"));
        assert_eq!(expand_percent_vars("%%", &ctx).as_deref(), Some("%%"));
        assert_eq!(expand_percent_vars("%a b%", &ctx).as_deref(), Some("%a b%"));
        // An unbound name has no provable expansion — including a name the
        // shared unix assignment path would have recorded: cmd.exe cannot run
        // `NAME=value`, so that binding never exists, and the layer reads the
        // context rather than the shared state.
        assert_eq!(expand_percent_vars("%UNSET%", &ctx), None);
        assert_eq!(expand_percent_vars("%FOO%", &ctx), None);
    }

    /// Every denied verb is refused by its bare name and by a path-qualified,
    /// capitalized, extension-qualified spelling.
    #[test]
    fn denied_verbs_are_refused_in_every_spelling() {
        for verb in DENIED_VERBS {
            assert_rejected(verb);
            assert_rejected(&format!("{verb} /?"));
            assert_rejected(&format!(
                r"C:\Windows\System32\{}.EXE",
                verb.to_ascii_uppercase()
            ));
        }
        // The `@` no-echo prefix does not hide the verb.
        assert_rejected(r"@del C:\ws\x.txt");
        assert_rejected(r"@format C:");
        // A path word the bash parse split at its whitespace must not hide the
        // real verb in a later fragment.
        assert_rejected(r"C:\Program Files\format.com C:");
        // A verb with a variable embedded in its name matches no table — but it
        // is not admitted either: the exact-pinned bash grammar errors on the
        // spelling, so the shared parse failure refuses the line before this
        // layer's tables are consulted.
        assert_rejected(r"del%X% C:\ws\x.txt");
    }

    #[test]
    fn file_mutators_need_every_path_under_temp() {
        let temp = temp_root();
        // The literal absolute spelling, and the `%TEMP%` spelling production
        // hands the layer (the layer expands it to the same text).
        ok(&format!(r#"del "{temp}\scratch.txt""#));
        ok(r#"del "%TEMP%\scratch.txt""#);
        ok(r#"DEL "%TEMP%\scratch.txt""#);
        ok(r#"move "%TEMP%\a.txt" "%TEMP%\b.txt""#);
        ok(r#"md "%TEMP%\new""#);
        ok(r#"replace "%TEMP%\new.txt" "%TEMP%""#);
        // `/` is a path separator for cmd.exe, so a switch-shaped token carrying
        // a path signal is an operand and must satisfy the gate like any other.
        assert_rejected(r#"move "%TEMP%\a.txt" /ws/b.txt"#);
        assert_rejected(r#"del "%TEMP%\a.txt" /b.txt"#);
        ok(r#"move /-Y "%TEMP%\a.txt" "%TEMP%\b.txt""#);
        assert_rejected(r"del C:\ws\src\main.rs");
        assert_rejected(r"del %TEMP%\..\..\ws\main.rs");
        assert_rejected("del src\\main.rs"); // relative: cmd resolves it against a directory no model has
        assert_rejected(r#"del C:\ws\src\main.rs "%TEMP%\a.txt""#); // one outside is enough
        // Nothing to prove — refused for naming no path, not for the temp
        // location the other spellings are refused for.
        let err = check_command("del", &win_ctx()).unwrap_err();
        assert!(err.contains("names no path"), "{err}");
        assert_rejected(r#"erase "%TEMP%\a.txt" "%TEMP%d\b.txt""#);
        // `move`/`replace` with their destination omitted: cmd writes into its own
        // directory, a location the layer does not model.
        assert_rejected(r#"move "%TEMP%\junk.rs""#);
        assert_rejected(r#"move /y "%TEMP%\junk.rs""#);
        assert_rejected(r#"replace "%TEMP%\junk.txt""#);
        assert_rejected(r#"replace /A "%TEMP%\junk.txt""#);
        // `ren`'s usable spelling — a bare new name — is refused with the relative
        // operand it carries.
        assert_rejected(r#"ren "%TEMP%\a.txt" b.txt"#);
        // A single operand outside temp is refused for that write, not for the
        // missing destination — the fix the agent is pointed at is the path.
        let err = check_command(r"move C:\ws\x.txt", &win_ctx()).unwrap_err();
        assert!(
            err.contains("not allowed outside the temp directory"),
            "a single outside-temp operand should be refused as a workspace write: {err}"
        );
    }

    /// Spellings Windows rewrites before touching the filesystem: the lexical
    /// comparison must not read them as the path they look like.
    #[test]
    fn platform_rewritten_paths_fail_closed() {
        // Drive-relative: `<cwd on C>\Temp\scratch.txt` at runtime, not
        // `C:\Temp\scratch.txt`.
        assert_rejected(r"del c:Temp\scratch.txt");
        // Win32 strips a component's trailing space and dot, so this is
        // `C:\Temp\..\x.txt` — outside temp.
        assert_rejected(r"del %TEMP%\.. \x.txt");
        assert_rejected(r"del %TEMP%\..\x.txt.");
    }

    /// cmd.exe splits an unquoted word carrying whitespace where the bash parse
    /// reads one word, so the spelling must fail closed.
    #[test]
    fn whitespace_in_an_operand_fails_closed() {
        // One bash word, two cmd operands — cmd would delete `b.txt` relative
        // to the real cwd.
        assert_rejected(r"del %TEMP%\a\ b.txt");
        // The spelling cmd reads as one operand.
        ok(r#"del "%TEMP%\a b.txt""#);
    }

    /// A single-quoted token is ordinary text for cmd, not a quoted argument: a
    /// classifier that read it as a switch would drop a real operand from the
    /// temp gate (`del "…\a.txt" '/C:\ws\x'` leaves `'/C:\ws\x'` unchecked).
    #[test]
    fn single_quoted_tokens_are_ordinary_text() {
        assert_rejected(r#"del "%TEMP%\a.txt" '/C:\ws\x'"#);
        assert_rejected(r#"del "%TEMP%\a.txt" '-y'"#);
        assert_rejected(r#"move "%TEMP%\a.txt" 'C:\ws\x'"#);
        assert_rejected(r"dir > '%TEMP%\listing.txt'");
        assert_rejected(r#"copy '%TEMP%\a.txt' "C:\ws\b.txt""#);
        // A single-quoted VERB word is judged under its literal name, so a
        // destructive verb cannot hide in quotes — and a word whose rule passes
        // stays a pass, quotes or not.
        assert_rejected(r"'del' C:\ws\x.txt");
        ok(r#"'del' "%TEMP%\x.txt""#);
    }

    /// Whitespace that only appears once a `%VAR%` is expanded: cmd expands
    /// before it tokenises, so a temp path with a space in it (a `%TEMP%` under
    /// `C:\Users\John Doe\...`) splits the operand where the layer reads one
    /// word — the first half is not under temp at all. The quoted spelling is the
    /// one cmd keeps whole, and it is what the rest of this lane writes.
    #[test]
    fn whitespace_after_expansion_fails_closed() {
        let mut ctx = win_ctx();
        let spaced = format!(r"{}\John Doe", temp_root());
        ctx.temp_roots = vec![PathBuf::from(&spaced)];
        ctx.temp_vars = vec![("TEMP".to_string(), spaced)];
        assert!(
            check_command(r"del %TEMP%\x.txt", &ctx).is_err(),
            "expanded whitespace must split the operand, not be read as one word"
        );
        // The other half of the lane's contract: a root that carries a space
        // must not cost the quoted spelling its temp grant. Only the lexical
        // gate is asserted — the fabricated root does not exist, so a verdict
        // through the reparse-point walk would assert the host instead.
        let quoted = resolve(r#""%TEMP%\x.txt""#, &ctx).expect("the quoted spelling resolves");
        assert!(
            under_roots(&quoted, &ctx),
            "a quoted temp path must stay under a temp root that carries a space"
        );
    }

    /// cmd's caret escape and its delayed (`!NAME!`) expansion are not modelled,
    /// and both hide the characters the layer must judge: cmd sees a workspace
    /// file where the lexical model sees a temp path, or a path that only exists
    /// once `setlocal enabledelayedexpansion` — which the interpreter this shell
    /// starts with delayed expansion off does not rule out — expands a name the
    /// model does not track.
    #[test]
    fn caret_escapes_and_delayed_expansion_fail_closed() {
        assert_rejected(r"del C:\ws\^..\..\ws\main.rs");
        assert_rejected(r"del %TEMP%\^..\..\ws\main.rs");
        assert_rejected(r"dir > C:\ws\^..\out.txt");
        assert_rejected(r"del ^C:\ws\f.txt");
        assert_rejected(r"del %TEMP%\sub\!A!\f.txt");
        assert_rejected(
            r"set DEST=C:\ws\src\main.rs && setlocal enabledelayedexpansion && del !DEST!",
        );
    }

    /// cmd's parameter delimiters: unlike the bash parse, cmd splits an
    /// unquoted `,`/`=` into a parameter boundary, so `del a.txt,C:\ws\b.txt`
    /// deletes a workspace file the layer reads as one temp-scoped operand. Glued
    /// behind a switch (`del /f,C:\ws\x.txt`) the delimiter is refused outright,
    /// which is also what stops the copy-shaped verbs' glued deny-list spellings
    /// from slipping past their destination-only grant.
    #[test]
    fn parameter_delimiters_and_separators_fail_closed() {
        assert_rejected(r"copy %TEMP%\a.txt,C:\ws\b.txt");
        assert_rejected(r"del %TEMP%\a.txt,C:\ws\b.txt");
        assert_rejected(r"move %TEMP%\a.txt=C:\ws\b.txt");
        assert_rejected(r#"del /f,C:\ws\x.txt "%TEMP%\junk.txt""#);
        assert_rejected(r#"del /f=C:\ws\x.txt "%TEMP%\junk.txt""#);
        assert_rejected(r#"rd /s,C:\ws\dir "%TEMP%\junk""#);
        // The copy family's twin of the shape, and the reason the rule matters
        // there: a switch name is read up to the first `:` or `+`
        // ([`denied_copy_switch`]), so `/move,` and `/log=` never reached
        // [`COPY_DENIED_SWITCHES`] while the glued token was dropped as a switch —
        // the destination-only grant then admitted the line.
        assert_rejected(r#"xcopy /move,C:\ws\y C:\ws\x.txt "%TEMP%\junk.txt""#);
        assert_rejected(r#"robocopy /log=C:\ws\l C:\ws\x.txt "%TEMP%\junk.txt""#);
        // cmd's separators and redirects: the bash parse keeps them inside one
        // word through a `\` escape, where the line-level `;` rule cannot see
        // them, while cmd.exe still splits or redirects the operand.
        assert_rejected(r"del %TEMP%\x\;C:\ws\y.txt");
        assert_rejected(r"del %TEMP%\a\&b.txt");
        assert_rejected(r"del %TEMP%\x\>C:\ws\out.txt");
        assert_rejected(r"del %TEMP%\x\|C:\ws\y.txt");
        assert_rejected(r"del %TEMP%\x\<C:\ws\y.txt");
        // The quoted spelling is one parameter for cmd too.
        ok(r#"del "%TEMP%\a,b.txt""#);
        ok(r#"copy C:\ws\a.txt "%TEMP%\b,c.txt""#);
        ok(r#"del "%TEMP%\a&b.txt""#);
        ok(r#"del "%TEMP%\a;b.txt""#);
    }

    /// A `;` is bash's command separator but cmd.exe's ARGUMENT delimiter for its
    /// internal commands, so the fragment the parse reads as a second command is
    /// an extra operand of the first — `del %TEMP%\a;C:\ws\x.exe` deletes a
    /// workspace file. The line is refused on the Windows platform, while `&&`
    /// (which both readers split on) keeps working.
    ///
    /// The divergence is a property of the LINE for cmd.exe, not of a nesting
    /// position: the guard reads the raw tree once, so a `;` inside a construct
    /// is refused here too (see `line_divergences_are_refused_inside_every_construct`).
    #[test]
    fn semicolon_split_lines_are_refused() {
        assert_rejected(r"del %TEMP%\a;C:\ws\x.exe");
        assert_rejected(r"del %TEMP%\a;..\..\ws\x.exe");
        assert_rejected(r"dir C:\ws;");
        assert_rejected(r#"del "%TEMP%\a";cmd /c del C:\ws\x.exe"#);
        ok(r#"del "%TEMP%\a" && del "%TEMP%\b""#);
        // Over-rejection, accepted: an unquoted `;` anywhere in the line is
        // refused, even where cmd would treat it as ordinary text (`echo a;b`).
        assert_rejected(r"echo a;b");
        ok(r#"echo "a;b""#);
        ok(r#"dir "C:\ws;a""#);
        // The refusal names the rule, not the mode it was decided in, and its advice
        // names both ways out: `&&`/`&` for two commands, quoting for a literal `;`.
        let err = check_command(r"echo a;b", &win_ctx()).unwrap_err();
        assert!(err.starts_with("Command not run: "), "{err}");
        assert!(err.contains("quote a `;` you mean literally"), "{err}");
    }

    /// cmd.exe has no comment syntax: to it the `#` is an ordinary character, so
    /// the text the bash parse drops as a comment still reaches it — in
    /// `echo hi # & del C:\ws\x.txt` it deletes the workspace file. Refused on
    /// the Windows platform, where a comment node is the divergence; unix keeps
    /// its verdict (a comment executes nothing for either reader there).
    ///
    /// Like the `;` and the heredoc, the divergence is a property of the LINE
    /// rather than of a nesting position
    /// (see `line_divergences_are_refused_inside_every_construct`).
    #[test]
    fn comment_lines_are_refused() {
        assert_rejected(r"echo hi # & del C:\ws\x.txt");
        assert_rejected(r"# & format C:");
        assert_rejected(r"dir C:\ws # list");
        // Quoted or word-internal, the `#` is ordinary text for both readers.
        ok(r#"echo "a # b""#);
        ok(r"echo a#b");
        // The shared frame and the advice that a literal `#` is kept by quoting.
        let err = check_command(r"# & format C:", &win_ctx()).unwrap_err();
        assert!(err.starts_with("Command not run: "), "{err}");
        assert!(err.contains("quote a `#` you mean literally"), "{err}");
    }

    /// Every whole-line divergence is decided once, over the raw syntax tree,
    /// before the walk — so each holds in every position a command can sit in,
    /// including the constructs whose children the walker filters (a subshell, a
    /// command substitution, a brace group, a function body, a case branch and a
    /// pipeline member). A rule hooked into the walker instead would police only
    /// the positions that walker happens to visit. The comment and `;` cases name
    /// a harmless command (`dir`), so the rule's own message is what must come
    /// back — not a parse error or a mutator verdict from the construct around
    /// it.
    #[test]
    fn line_divergences_are_refused_inside_every_construct() {
        for cmd in [
            "(dir C:\\ws # c\n)",
            "echo $(dir C:\\ws # c\n)",
            "{ dir C:\\ws # c\n}",
            "f() { dir C:\\ws # c\n }",
            "case x in a) dir C:\\ws ;; # c\n esac",
            "dir C:\\ws | # c\n more",
        ] {
            let err = check_command(cmd, &win_ctx()).unwrap_err();
            assert!(err.contains("no comment syntax"), "{cmd}: {err}");
        }
        // The same constructs carrying a `;`-split line instead: the fragment
        // after the `;` is an extra operand of `del` for cmd.exe.
        for cmd in [
            "(del %TEMP%\\a;C:\\ws\\x.exe)",
            "echo $(del %TEMP%\\a;C:\\ws\\x.exe)",
            "{ del %TEMP%\\a;C:\\ws\\x.exe\n}",
            "f() { del %TEMP%\\a;C:\\ws\\x.exe\n }",
            "case x in a) del %TEMP%\\a;C:\\ws\\x.exe ;; esac",
            "del %TEMP%\\a;C:\\ws\\x.exe | more",
        ] {
            let err = check_command(cmd, &win_ctx()).unwrap_err();
            assert!(err.contains("argument delimiter"), "{cmd}: {err}");
        }
        // A heredoc is refused the same way, in the same positions.
        for cmd in [
            "cat <<EOF\nx\nEOF",
            "(cat <<EOF\nx\nEOF\n)",
            "{ cat <<EOF\nx\nEOF\n}",
            "echo $(cat <<EOF\nx\nEOF\n)",
            "f() { cat <<EOF\nx\nEOF\n }",
            "case x in a) cat <<EOF\nx\nEOF\n ;; esac",
            "cat <<EOF\nx\nEOF | more",
        ] {
            let err = check_command(cmd, &win_ctx()).unwrap_err();
            assert!(err.contains("no `<<`"), "{cmd}: {err}");
        }
    }

    /// The whole-line divergences as the shared entry the unrestricted mode calls:
    /// it has no walk of its own to find them through, so it must get exactly the
    /// text the read-only guard's walk produces for the same line — same frame,
    /// same wording, on the same trimmed text. A cmd-only shape whose bash parse
    /// has errors is not this rule's business — that is a shape the unrestricted
    /// mode is there to run — so the entry refuses nothing for it, where the
    /// read-only guard's fail-closed parse refusal (asserted here) does.
    #[test]
    fn line_divergences_are_refused_in_both_modes() {
        for cmd in [r"del %TEMP%\a;C:\ws\x.exe", r"echo hi # & del C:\ws\x.txt"] {
            let guard = check_command(cmd, &win_ctx()).expect_err(cmd);
            assert_eq!(
                check_line_divergences(cmd, ShellPlatform::Windows).expect_err(cmd),
                guard,
                "cmd `{cmd}`"
            );
            // Trimmed exactly as the guard trims: the padded spelling is the same
            // text, refused with the same words.
            let padded = format!("  {cmd}  ");
            assert_eq!(
                check_line_divergences(&padded, ShellPlatform::Windows).unwrap_err(),
                guard,
                "padded `{padded}`"
            );
            // Neither divergence is one for unix — the `;` splits and a comment
            // executes nothing there — so the entry adds no refusal of its own.
            assert!(
                check_line_divergences(cmd, ShellPlatform::Unix).is_ok(),
                "{cmd}"
            );
        }
        // A shape only cmd.exe accepts: the parser reads no tree to refuse.
        assert!(check_command("if exist x (echo a;b)", &win_ctx()).is_err());
        assert!(check_line_divergences("if exist x (echo a;b)", ShellPlatform::Windows).is_ok());
    }

    /// A tripwire for the module doc's `# Accepted limits` section: one admitted
    /// spelling per limit, so a later change that starts refusing one comes past
    /// this test first. Not the whole list — the wrapper limit is pinned by
    /// `unmodelled_verbs_fall_through_to_the_shared_dispatch`, and the unix
    /// case-variant one by `unix_platform_verdicts_never_use_the_windows_rules`.
    #[test]
    fn documented_limits_are_not_closed() {
        // A word escaping a separator or a redirect: cmd.exe reads the `\` as an
        // ordinary character and splits or redirects at the character behind it,
        // so the second command runs although the bash parse reads one word.
        ok(r"dir \& del C:\ws\x.exe");
        ok(r"whoami \> C:\ws\out.txt");
        // A VERB word spelled through cmd's escape or its variable syntax, and a
        // decorated or trailing-dot name: none matches a table.
        ok(r"%DEL% C:\ws\x.txt");
        ok(r"!DEL! C:\ws\x.txt");
        ok(r"d^el C:\ws\x.txt");
        ok(r"DEL. C:\ws\x.txt");
        ok(r"format.com. C:");
        // A command on no table at all — the writers among them, and one that
        // picks its own destination when it is given only a source.
        ok(r"certutil -decode C:\ws\x.b64 C:\ws\out.bin");
        ok(r"sort /o C:\ws\out.txt C:\ws\a.txt");
        ok(r"makecab C:\ws\x.txt");
        // A path-qualified verb with forward slashes and no executable
        // extension: a literal word for the bash parse, and no table for
        // `verb_key` — which reads a separator-carrying word as a program only
        // through a known extension. The shared dispatch then judges the
        // basename cmd.exe would resolve, so a name the shared tables carry is
        // still refused in this spelling while a Windows-only one passes. The
        // backslash spelling is the shared classifier's unprovable word instead.
        ok(r"C:/Windows/System32/del C:\ws\x.txt");
        ok(r"C:/Windows/System32/copy C:\ws\a.txt C:\ws\b.txt");
        // The sharpest one: `format` IS on a Windows deny table, and this
        // spelling still reaches the shared dispatch by its basename only.
        ok(r"C:/Windows/System32/format C:");
        assert_rejected(r"C:/Windows/System32/rm C:\ws\x.txt");
        assert_rejected(r"C:\Windows\System32\del C:\ws\x.txt");
        // Shapes the shell refuses on Windows before this guard is consulted:
        // this reader folds both into one command and accepts them.
        ok("dir C:\\ws \\\n del C:\\ws\\x.exe");
        ok("echo \"a\n del C:\\ws\\x.exe\"");
        // An operand `%…%` whose name is not `[A-Za-z0-9_]`: read as literal
        // text rather than expanded.
        ok(r#"del "%TEMP%\%中%\x.txt""#);
    }

    #[test]
    fn clock_builtins_refuse_an_operand() {
        ok("date /t");
        ok("time /t");
        ok("date/t");
        ok("time/t");
        ok("date");
        ok("time");
        assert_rejected("date 12/31/2026");
        assert_rejected("time 12:34:56.78");
        assert_rejected("time/t 12:00");
        assert_rejected("@time 12:00");
    }

    #[test]
    fn copy_shaped_verbs_gate_the_destination_only() {
        ok(r#"copy C:\ws\a.txt "%TEMP%\b.txt""#);
        ok(r#"xcopy C:\ws\src "%TEMP%\backup" /E"#);
        // A tree copy into temp from anywhere, `/MIR` deletions included.
        ok(r#"robocopy C:\ws "%TEMP%\mirror" /MIR"#);
        assert_rejected(r#"copy "%TEMP%\a.txt" C:\ws\b.txt"#);
        assert_rejected(r#"xcopy "%TEMP%\mirror" C:\ws\mirror /E"#);
        assert_rejected(r"robocopy C:\ws C:\ws\backup /MIR");
        // cmd's destination is optional and then defaults to cmd's own
        // directory, which read-only mode never proved is the accepted
        // location, so a single path argument is refused.
        assert_rejected(r#"copy "%TEMP%\report.txt""#);
        assert_rejected(r#"xcopy "%TEMP%\report.txt""#);
        assert_rejected(r"copy C:\ws\a.txt");
        assert_rejected("copy");
        // Switches that stop being a destination-only copy: source deletion,
        // registry/log writes, the archive attribute, and the monitor modes
        // (`/MOT:`/`/MON:` re-run the copy until interrupted).
        assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /MOVE"#);
        assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /MOV"#);
        assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /LOG:C:\ws\run.log"#);
        assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /REG"#);
        assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /MOT:5"#);
        assert_rejected(r#"robocopy C:\ws "%TEMP%\x" /MON:3"#);
        assert_rejected(r#"xcopy C:\ws "%TEMP%\x" /M"#);
        ok(r#"xcopy C:\ws "%TEMP%\x" /A"#);
        // A colon-bearing switch stays a switch and the destination gate holds.
        ok(r#"robocopy C:\ws "%TEMP%\x" /E /R:1 /W:1"#);
    }

    #[test]
    fn list_only_verbs_allow_the_bare_form() {
        ok("attrib");
        ok(r"attrib C:\ws\a.txt");
        ok(r"icacls C:\ws\a.txt");
        assert_rejected(r"attrib +r C:\ws\a.txt");
        assert_rejected(r"attrib -h C:\ws\a.txt");
        assert_rejected(r"attrib /s C:\ws");
        assert_rejected(r"icacls C:\ws\a.txt /grant everyone:f");
        assert_rejected(r"icacls C:\ws\a.txt /deny everyone:d");
        assert_rejected(r"cipher /w:C:\ws");
        assert_rejected(r"compact /c C:\ws\a.txt");
    }

    #[test]
    fn query_verbs_allow_only_the_inspection_spelling() {
        ok(r"reg query HKLM\Software");
        ok("sc query state= all");
        ok("sc queryex wuauserv");
        ok("schtasks /query /fo LIST");
        // The glued spelling is the spaced one (`schtasks/query`).
        ok("reg/query HKLM\\Software");
        ok("schtasks/query /fo LIST");
        assert_rejected(r"reg add HKLM\Software\X /v Y");
        assert_rejected(r"reg delete HKLM\Software\X");
        assert_rejected(r"reg import %TEMP%\x.reg");
        assert_rejected(r"reg/add HKLM\Software\X /v Y");
        assert_rejected("sc start wuauserv");
        assert_rejected("sc stop wuauserv");
        assert_rejected("schtasks /create /tn x /tr y");
        assert_rejected("schtasks /delete /tn x");
    }

    /// Only the daemon temp variables are part of the model: rebinding one is
    /// refused, any other binding is allowed but not tracked — so a later
    /// `%NAME%` reference is unbound and refused.
    #[test]
    fn set_binds_only_what_the_model_can_follow() {
        ok("set");
        ok("set PATH");
        ok("set FOO=bar");
        ok(r#"set "FOO=bar baz""#);
        ok(r"set FOO=%TEMP%\x");
        // Rebinding a temp variable would make every later expansion name a path
        // cmd no longer uses — any spelling, any casing.
        assert_rejected(r"set TEMP=C:\ws");
        assert_rejected(r"set temp=C:\ws");
        assert_rejected(r#"set "TEMP=%TEMP%\x""#);
        assert_rejected(r"set TMP=C:\ws && del %TMP%\x.txt");
        // The binding is not tracked, so using it cannot be resolved.
        assert_rejected(r"set FOO=%TEMP%\x && del %FOO%\x.txt");
        assert_rejected("set /a x=1");
        assert_rejected("set /p x=");
        assert_rejected("set =value");
        // A quote character cmd.exe does not interpret leaves the binding
        // unreadable; the double-quoted spelling is the one cmd assigns, and a
        // temp name is refused through it too.
        assert_rejected(r"set TEMP='C:\ws'");
        assert_rejected(r#"set "TEMP='C:\ws'""#);
        // cmd's transform pass rewrites the line before the builtin binds, so a
        // name carrying an escape would set a variable spelled differently from
        // the text the layer reads.
        assert_rejected(r"set TEMP^=C:\ws");
        assert_rejected(r"set TEMP =C:\ws");
        assert_rejected(r"set TEM%P%=C:\ws");
        // cmd's environment is case-insensitive, so the unix `GIT_*` binding
        // rule applies to any casing; `GIT_PAGER` is the documented carve-out.
        assert_rejected(r"set GIT_SSH_COMMAND=C:\ws\evil.exe && git ls-remote x");
        assert_rejected(r"set git_dir=C:\ws");
        ok("set GIT_PAGER=cat");
    }

    /// cmd.exe dispatches `Git`/`GIT`/`GIT.EXE` to the same program, so a
    /// mixed-case or extension-qualified verb must get exactly the `git`
    /// verdict.
    #[test]
    fn mixed_case_git_verbs_keep_their_verdict() {
        for verb in ["Git", "GIT", "git.exe", "GIT.EXE", "Git.EXE"] {
            for args in [
                "push origin main",
                "clean -fdx",
                "checkout -f .",
                "status",
                "log --oneline",
            ] {
                let ctx = win_ctx();
                let mixed = check_command(&format!("{verb} {args}"), &ctx);
                assert_eq!(
                    mixed.is_ok(),
                    check_command(&format!("git {args}"), &ctx).is_ok(),
                    "`{verb} {args}` must match the `git {args}` verdict"
                );
            }
        }
        assert_rejected("Git push origin main");
        assert_rejected("GIT clean -fdx");
        assert_rejected("GIT.EXE clean -fdx");
        ok("Git status");
        ok("git.exe log --oneline");
    }

    /// No relative path operand is provable: cmd resolves one against a
    /// directory the guard does not model, and that directory is not reliably
    /// outside the temp roots — the temp cleaner runs its shell in a workspace
    /// built at the private temp root, where a workspace-anchored relative path
    /// would satisfy the gate while cmd writes wherever a `cd` left it. The temp
    /// gate therefore accepts absolute spellings only.
    #[test]
    fn relative_operands_are_refused() {
        let ctx = win_ctx();
        // No relative word has a proving form.
        assert_eq!(resolve_raw("x.txt", &ctx), None);
        assert!(!under_temp("x.txt", &ctx));
        // cmd's other relative spellings: root-relative (the drive's own current
        // directory) and drive-relative.
        assert_eq!(resolve_raw(r"\x.txt", &ctx), None);
        assert_eq!(resolve_raw("c:x.txt", &ctx), None);
        assert!(!under_temp(r"\x.txt", &ctx));
        // The temp cleaner's session: the workspace IS the private temp root, so
        // an anchored relative operand would have satisfied the gate.
        let mut cleaner =
            CheckContext::for_platform(Path::new(&temp_root()), ShellPlatform::Windows);
        cleaner.temp_roots = vec![PathBuf::from(temp_root())];
        cleaner.temp_vars = vec![
            ("TMP".to_string(), temp_root()),
            ("TEMP".to_string(), temp_root()),
        ];
        assert!(
            check_command(r"del x.txt", &cleaner).is_err(),
            "a relative operand must be refused even when the workspace is a temp root"
        );
        assert!(
            check_command(r"cd ..\.. && del x.txt", &cleaner).is_err(),
            "the directory change must not make a relative operand provable"
        );
        assert!(
            check_command(&format!(r#"del "{}\x.txt""#, temp_root()), &cleaner).is_ok(),
            "the absolute spelling of the same write stays admitted"
        );
        // A relative operand stays refused in every spelling, whatever the cd
        // family around it does.
        assert_rejected(r"cd %TEMP% && del x.txt");
        assert_rejected(r"cd /d %TEMP% && del x.txt");
        assert_rejected(r"md %TEMP%\new && cd %TEMP%\new && del x.txt");
        assert_rejected(r"CD %TEMP% && del x.txt");
        assert_rejected(r"pushd %TEMP% && del x.txt");
        assert_rejected(r"cd %TEMP% && cd.. && del x.txt");
        assert_rejected(r"cd %TEMP% && dir > out.txt");
        assert_rejected(r"cd %TEMP% && copy x.txt C:\ws\y.txt");
        assert_rejected(r"cd %dir% && del x.txt");
        // The absolute spelling of the same write stays admitted.
        ok(r#"cd %TEMP% && del "%TEMP%\x.txt""#);
    }

    /// The layer owns the cd family outright: it writes nothing, and the shared
    /// dispatch would read a cmd path as an unknown command word. The target is
    /// not modelled — a spelling fused with a separator (`cd..\..`, `cd\Users`)
    /// is unprovable and refused — and no cd can make a relative operand
    /// writable (see `relative_operands_are_refused`).
    #[test]
    fn cd_family_is_owned_by_the_layer() {
        ok("cd");
        ok(r"cd C:\ws");
        ok("cd..");
        ok(r"cd /d %TEMP%");
        ok("cdrecord /dev/x"); // a longer name is not the builtin
        // cmd's own spellings of the family: case-folded, glued switch, and the
        // `chdir` alias are the same builtin.
        ok(r"CD C:\ws");
        ok("cd/d C:\\ws");
        ok(r"chdir C:\ws");
        ok("pushd C:\\ws");
        ok("popd");
        assert_rejected(r"cd..\..");
        assert_rejected(r"cd\Users");
        // A bare drive switch writes nothing and is allowed; one carrying an
        // operand is not a shape cmd.exe has, and reading the rest as words of
        // one command would hide a mutator.
        ok("C:");
        ok(r"C: && dir C:\ws");
        assert_rejected(r"D: del C:\ws\x.txt");
        assert_rejected(r#"D: del "%TEMP%\x.txt""#);
    }

    /// A non-ASCII command word must be judged, never panic: a panic here aborts
    /// a phase dispatch. The word is either an unmodelled program (allowed) or
    /// unprovable (refused) — what matters is that it has a verdict.
    #[test]
    fn non_ascii_command_words_are_judged_not_panicked() {
        for cmd in [
            "céd",
            "céd/d",
            "céd..",
            "céd\\x",
            "c€x /x",
            "\u{4e2d}\u{6587} C:\\ws",
            "del céd.txt",
            "«rd» C:\\ws",
        ] {
            let _ = check_command(cmd, &win_ctx());
            let _ = verb_key(cmd);
            let _ = split_glued_switch(cmd);
        }
        // Refusals still hold around a non-ASCII operand.
        assert_rejected("del céd.txt"); // relative: no absolute spelling, so refused
        assert_rejected(r#"del "céd.txt""#); // quoted, but still not under temp
        assert_rejected(r"céd\del C:\ws\x.txt"); // unprovable verb
        ok(r#"dir C:\ws\céd && del "%TEMP%\céd.txt""#);
    }

    /// The exact command block the sanitation temp-cleanup task is given on
    /// Windows must keep running: the removal verbs are inside its scan roots
    /// (which are the guard's accepted temp roots, passed as literal absolute
    /// paths), and the inspection rows — `forfiles` included — are not chased as
    /// wrappers.
    #[test]
    fn sanitation_windows_tool_block_still_works() {
        let temp = temp_root();
        ok("whoami /user");
        ok(&format!(r#"dir /a /q /tw "{temp}\junk""#));
        ok(&format!(r#"dir /a:l /s /b "{temp}""#));
        ok(&format!(r#"dir /a "{temp}""#));
        ok(&format!(
            r#"forfiles /P "{temp}" /S /M * /C "cmd /c echo @fdate @ftime @path""#
        ));
        ok(&format!(r#"del /f /q "{temp}\junk.txt""#));
        ok(&format!(r#"rd /s /q "{temp}\junk""#));
        ok(&format!(r#"rd "{temp}\junk""#));
    }

    #[test]
    fn redirects_accept_the_platform_null_device_and_temp_paths() {
        let temp = temp_root();
        ok("dir > NUL");
        ok("dir > nul:");
        ok("dir > NUL.txt");
        ok(&format!(r#"dir > "{temp}\listing.txt""#));
        ok(r#"dir > "%TEMP%\listing.txt""#);
        ok("dir 2>&1");
        assert_rejected(r"dir > C:\ws\listing.txt");
        assert_rejected("dir > NULX");
        // cmd does not interpret single quotes: the target is an ordinary file
        // in the cwd, not the device.
        assert_rejected("dir > 'NUL'");
        // A separator makes the spelling an ordinary path, not the device.
        assert_rejected(r"dir > NUL\..\..\ws\x.txt");
        assert_rejected("dir > NUL.x\\foo");
        // `/dev/null` is a unix spelling: under cmd.exe it is a real path.
        assert_rejected("dir > /dev/null");
    }

    /// `canonicalize` returns the on-disk case behind a `\\?\` verbatim prefix,
    /// so the comparison must fold before matching the lowercased temp roots.
    #[test]
    fn canonical_paths_are_folded_before_comparing() {
        let ctx = win_ctx();
        assert!(canonical_under_roots(
            &format!(r"\\?\{}\MahBot\X.txt", temp_root().to_ascii_uppercase()),
            &ctx
        ));
        assert!(!canonical_under_roots(
            r"\\?\C:\definitely-not-temp\x",
            &ctx
        ));
    }

    #[test]
    fn unmodelled_verbs_fall_through_to_the_shared_dispatch() {
        // `forfiles` is what the sanitation temp-cleanup task reports a tree's
        // newest mtime with, so refusing it would break a documented read-only
        // workflow.
        ok(r"forfiles /p %TEMP% /m * /d -7");
        ok(r"dir /b C:\ws");
        ok(r"where cargo.exe");
        ok(r"type C:\ws\Cargo.toml");
        ok(r"findstr /s /i TODO C:\ws\src\*.rs");
        // Interpreters stay unchased, as on unix — the shell itself included,
        // with its own `/c`.
        ok(r"powershell -Command Get-ChildItem");
        ok(r"cmd /c del C:\ws\x.txt");
        // cmd.exe dispatches case-insensitively and ignores the executable
        // extension, so the shared tables are matched on the layer's verb key:
        // `tar`/`curl`/`sed`/`dd` are flag-gated and `shutdown` is an
        // unconditional mutator, in any casing and with or without `.exe`.
        assert_rejected(r"TAR -xf C:\ws\a.tar");
        assert_rejected(r"CURL -o C:\ws\f https://example.com");
        assert_rejected(r"SHUTDOWN /s");
        assert_rejected(r"tar.exe -xf C:\ws\a.tar");
        assert_rejected(r"Tar.EXE -xf C:\ws\a.tar");
        assert_rejected(r"sed.exe -i C:\ws\f");
        assert_rejected(r"dd.exe of=C:\ws\f");
        assert_rejected(r"shutdown.exe /s");
        ok(r"tar.exe -tf C:\ws\a.tar");
        // The unix mutator tables get the same treatment, extension included.
        assert_rejected(r"rm.exe C:\ws\x.txt");
        assert_rejected(r"C:\tools\rm.exe C:\ws\x.txt");
        ok(r#"rm.exe "%TEMP%\x.txt""#);
        // A quoted absolute executable path is a command word cmd.exe runs, so it
        // is admitted too.
        ok(r#""C:\Program Files\mahbot\mahbot.exe" --version"#);
    }

    /// The platform is a runtime value, so this host can pin both lanes: the
    /// Windows-only rules (the null device, the case fold) must not reach a
    /// unix verdict. The uppercase unix verdicts asserted here are the
    /// documented case-sensitivity limit of the shared tables, not a Windows
    /// behaviour — the fold is what makes the difference.
    #[test]
    fn unix_platform_verdicts_never_use_the_windows_rules() {
        let unix = CheckContext::for_platform(Path::new("/__ws__"), ShellPlatform::Unix);
        let state = ValidationState::new(&unix);
        assert!(!is_null_target("NUL", &state));
        assert!(!is_null_target("nul:", &state));
        // `NUL` is not the unix null device, so the unix redirect verdict stands.
        assert!(check_command("echo hi > NUL", &unix).is_err());
        assert!(check_command("tar -xf a.tar", &unix).is_err());
        assert!(check_command("TAR -xf a.tar", &unix).is_ok());
        assert!(check_command("shutdown /s", &unix).is_err());
        assert!(check_command("SHUTDOWN /s", &unix).is_ok());
        assert!(check_command("shutdown.exe /s", &unix).is_ok());
        // A `;` separates commands for both readers on unix, so the Windows
        // `;`-refusal must not reach a unix verdict.
        assert!(check_command("echo a; echo b", &unix).is_ok());
        // A comment executes nothing on unix, so only Windows refuses it.
        assert!(check_command("echo a # b", &unix).is_ok());
        // A heredoc is ordinary bash: only Windows refuses the `<<`.
        assert!(check_command("cat <<EOF\nbody\nEOF", &unix).is_ok());
    }
}