@dzhechkov/harness-core 0.8.11 → 0.8.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/.dz-manifest.json +399 -139
  2. package/LICENSE +13 -0
  3. package/README.md +420 -6
  4. package/dist/agents-policy.d.ts +15 -1
  5. package/dist/agents-policy.d.ts.map +1 -1
  6. package/dist/agents-policy.js +27 -1
  7. package/dist/agents-policy.js.map +1 -1
  8. package/dist/amendment-trace.d.ts +72 -0
  9. package/dist/amendment-trace.d.ts.map +1 -1
  10. package/dist/amendment-trace.js +385 -17
  11. package/dist/amendment-trace.js.map +1 -1
  12. package/dist/backlog-public.d.ts +153 -0
  13. package/dist/backlog-public.d.ts.map +1 -0
  14. package/dist/backlog-public.js +415 -0
  15. package/dist/backlog-public.js.map +1 -0
  16. package/dist/backlog-transitions.d.ts +48 -0
  17. package/dist/backlog-transitions.d.ts.map +1 -0
  18. package/dist/backlog-transitions.js +64 -0
  19. package/dist/backlog-transitions.js.map +1 -0
  20. package/dist/backlog.d.ts.map +1 -1
  21. package/dist/backlog.js +13 -0
  22. package/dist/backlog.js.map +1 -1
  23. package/dist/claim-check.d.ts.map +1 -1
  24. package/dist/claim-check.js +24 -2
  25. package/dist/claim-check.js.map +1 -1
  26. package/dist/claude-hooks-assets.d.ts +93 -0
  27. package/dist/claude-hooks-assets.d.ts.map +1 -0
  28. package/dist/claude-hooks-assets.js +208 -0
  29. package/dist/claude-hooks-assets.js.map +1 -0
  30. package/dist/clean-room-smoke.d.ts +83 -0
  31. package/dist/clean-room-smoke.d.ts.map +1 -0
  32. package/dist/clean-room-smoke.js +138 -0
  33. package/dist/clean-room-smoke.js.map +1 -0
  34. package/dist/cmd-usage.d.ts.map +1 -1
  35. package/dist/cmd-usage.js +36 -6
  36. package/dist/cmd-usage.js.map +1 -1
  37. package/dist/codex-hooks-assets.d.ts +25 -7
  38. package/dist/codex-hooks-assets.d.ts.map +1 -1
  39. package/dist/codex-hooks-assets.js +138 -30
  40. package/dist/codex-hooks-assets.js.map +1 -1
  41. package/dist/codex-hooks.d.ts +21 -1
  42. package/dist/codex-hooks.d.ts.map +1 -1
  43. package/dist/codex-hooks.js +21 -1
  44. package/dist/codex-hooks.js.map +1 -1
  45. package/dist/course-staleness.d.ts +19 -0
  46. package/dist/course-staleness.d.ts.map +1 -0
  47. package/dist/course-staleness.js +95 -0
  48. package/dist/course-staleness.js.map +1 -0
  49. package/dist/destructive-guard-hook.d.ts +40 -0
  50. package/dist/destructive-guard-hook.d.ts.map +1 -0
  51. package/dist/destructive-guard-hook.js +109 -0
  52. package/dist/destructive-guard-hook.js.map +1 -0
  53. package/dist/destructive-guard.d.ts +27 -0
  54. package/dist/destructive-guard.d.ts.map +1 -0
  55. package/dist/destructive-guard.js +2808 -0
  56. package/dist/destructive-guard.js.map +1 -0
  57. package/dist/discrimination-gate.d.ts +28 -3
  58. package/dist/discrimination-gate.d.ts.map +1 -1
  59. package/dist/discrimination-gate.js +76 -16
  60. package/dist/discrimination-gate.js.map +1 -1
  61. package/dist/feature-adr-routing.d.ts +95 -1
  62. package/dist/feature-adr-routing.d.ts.map +1 -1
  63. package/dist/feature-adr-routing.js +193 -27
  64. package/dist/feature-adr-routing.js.map +1 -1
  65. package/dist/guard.d.ts +90 -0
  66. package/dist/guard.d.ts.map +1 -1
  67. package/dist/guard.js +271 -0
  68. package/dist/guard.js.map +1 -1
  69. package/dist/harness-core-location.d.ts +18 -0
  70. package/dist/harness-core-location.d.ts.map +1 -0
  71. package/dist/harness-core-location.js +42 -0
  72. package/dist/harness-core-location.js.map +1 -0
  73. package/dist/index.d.ts +24 -6
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +24 -3
  76. package/dist/index.js.map +1 -1
  77. package/dist/lead-shift-gate.d.ts +95 -0
  78. package/dist/lead-shift-gate.d.ts.map +1 -0
  79. package/dist/lead-shift-gate.js +100 -0
  80. package/dist/lead-shift-gate.js.map +1 -0
  81. package/dist/ledger-backfill.d.ts +11 -1
  82. package/dist/ledger-backfill.d.ts.map +1 -1
  83. package/dist/ledger-backfill.js +19 -0
  84. package/dist/ledger-backfill.js.map +1 -1
  85. package/dist/loop-blobs.generated.d.ts +1 -1
  86. package/dist/loop-blobs.generated.d.ts.map +1 -1
  87. package/dist/loop-blobs.generated.js +12 -3
  88. package/dist/loop-blobs.generated.js.map +1 -1
  89. package/dist/loop-lint.d.ts.map +1 -1
  90. package/dist/loop-lint.js +56 -7
  91. package/dist/loop-lint.js.map +1 -1
  92. package/dist/loop-plan-graph.d.ts +1 -3
  93. package/dist/loop-plan-graph.d.ts.map +1 -1
  94. package/dist/loop-plan-graph.js +70 -1
  95. package/dist/loop-plan-graph.js.map +1 -1
  96. package/dist/loop-trace.d.ts.map +1 -1
  97. package/dist/loop-trace.js +16 -2
  98. package/dist/loop-trace.js.map +1 -1
  99. package/dist/managed-hooks.d.ts +5 -6
  100. package/dist/managed-hooks.d.ts.map +1 -1
  101. package/dist/managed-hooks.js +2 -2
  102. package/dist/managed-hooks.js.map +1 -1
  103. package/dist/model-recommender.d.ts.map +1 -1
  104. package/dist/model-recommender.js +14 -3
  105. package/dist/model-recommender.js.map +1 -1
  106. package/dist/operations.d.ts.map +1 -1
  107. package/dist/operations.js +82 -0
  108. package/dist/operations.js.map +1 -1
  109. package/dist/patterns.d.ts +23 -0
  110. package/dist/patterns.d.ts.map +1 -1
  111. package/dist/patterns.js +10 -0
  112. package/dist/patterns.js.map +1 -1
  113. package/dist/publish.d.ts +11 -0
  114. package/dist/publish.d.ts.map +1 -1
  115. package/dist/publish.js +16 -2
  116. package/dist/publish.js.map +1 -1
  117. package/dist/registry.d.ts.map +1 -1
  118. package/dist/registry.js +3 -2
  119. package/dist/registry.js.map +1 -1
  120. package/dist/score.d.ts.map +1 -1
  121. package/dist/score.js +15 -4
  122. package/dist/score.js.map +1 -1
  123. package/dist/session-retro.d.ts +121 -2
  124. package/dist/session-retro.d.ts.map +1 -1
  125. package/dist/session-retro.js +454 -11
  126. package/dist/session-retro.js.map +1 -1
  127. package/dist/setup.d.ts +28 -0
  128. package/dist/setup.d.ts.map +1 -1
  129. package/dist/setup.js +223 -6
  130. package/dist/setup.js.map +1 -1
  131. package/dist/sign.d.ts.map +1 -1
  132. package/dist/sign.js +18 -1
  133. package/dist/sign.js.map +1 -1
  134. package/dist/skills-verify.d.ts +34 -1
  135. package/dist/skills-verify.d.ts.map +1 -1
  136. package/dist/skills-verify.js +82 -0
  137. package/dist/skills-verify.js.map +1 -1
  138. package/dist/stage-line.d.ts +68 -0
  139. package/dist/stage-line.d.ts.map +1 -0
  140. package/dist/stage-line.js +129 -0
  141. package/dist/stage-line.js.map +1 -0
  142. package/dist/statusline.d.ts +99 -0
  143. package/dist/statusline.d.ts.map +1 -1
  144. package/dist/statusline.js +310 -44
  145. package/dist/statusline.js.map +1 -1
  146. package/dist/store-counts.d.ts +26 -0
  147. package/dist/store-counts.d.ts.map +1 -0
  148. package/dist/store-counts.js +125 -0
  149. package/dist/store-counts.js.map +1 -0
  150. package/dist/store-guard.d.ts +106 -0
  151. package/dist/store-guard.d.ts.map +1 -0
  152. package/dist/store-guard.js +294 -0
  153. package/dist/store-guard.js.map +1 -0
  154. package/dist/swarm-brief.d.ts +95 -0
  155. package/dist/swarm-brief.d.ts.map +1 -0
  156. package/dist/swarm-brief.js +660 -0
  157. package/dist/swarm-brief.js.map +1 -0
  158. package/dist/trace-bundle.d.ts +8 -0
  159. package/dist/trace-bundle.d.ts.map +1 -1
  160. package/dist/trace-bundle.js +11 -0
  161. package/dist/trace-bundle.js.map +1 -1
  162. package/package.json +12 -11
  163. package/sbom.json +804 -154
  164. package/src/agents-policy.ts +46 -2
  165. package/src/amendment-trace.ts +441 -19
  166. package/src/backlog-public.ts +503 -0
  167. package/src/backlog-transitions.ts +77 -0
  168. package/src/backlog.ts +12 -0
  169. package/src/claim-check.ts +25 -2
  170. package/src/claude-hooks-assets.ts +227 -0
  171. package/src/clean-room-smoke.ts +195 -0
  172. package/src/cmd-usage.ts +29 -5
  173. package/src/codex-hooks-assets.ts +140 -30
  174. package/src/codex-hooks.ts +21 -1
  175. package/src/course-staleness.ts +125 -0
  176. package/src/destructive-guard-hook.ts +151 -0
  177. package/src/destructive-guard.ts +3027 -0
  178. package/src/discrimination-gate.ts +98 -19
  179. package/src/feature-adr-routing.ts +220 -22
  180. package/src/guard.ts +318 -0
  181. package/src/harness-core-location.ts +44 -0
  182. package/src/index.ts +111 -3
  183. package/src/lead-shift-gate.ts +145 -0
  184. package/src/ledger-backfill.ts +20 -1
  185. package/src/loop-blobs.generated.ts +12 -3
  186. package/src/loop-lint.ts +52 -7
  187. package/src/loop-plan-graph.ts +66 -1
  188. package/src/loop-trace.ts +13 -1
  189. package/src/managed-hooks.ts +5 -6
  190. package/src/model-recommender.ts +14 -3
  191. package/src/operations.ts +75 -0
  192. package/src/patterns.ts +33 -0
  193. package/src/publish.ts +27 -2
  194. package/src/registry.ts +3 -2
  195. package/src/score.ts +16 -4
  196. package/src/session-retro.ts +466 -11
  197. package/src/setup.ts +246 -9
  198. package/src/sign.ts +18 -1
  199. package/src/skills-verify.ts +99 -1
  200. package/src/stage-line.ts +151 -0
  201. package/src/statusline.ts +396 -47
  202. package/src/store-counts.ts +154 -0
  203. package/src/store-guard.ts +388 -0
  204. package/src/swarm-brief.ts +661 -0
  205. package/src/trace-bundle.ts +10 -0
@@ -0,0 +1,2808 @@
1
+ // destructive-guard — feature `destructive-command-guard`, ADR-001
2
+ // ("the guard's scope is set by what it can DECIDE from the form, not by how bad the consequence is").
3
+ //
4
+ // WHY THIS IS SO NARROW. A measurement over 20 938 real shell commands from this project's
5
+ // transcripts showed that for the broad class "recursive deletion" the guard cannot justify 83 % of
6
+ // its own hits (44 % target in a shell variable, 39 % relative target after a directory change).
7
+ // A check that invents violations is worse than no check — people learn to switch it off, and the
8
+ // 17 % that worked goes with it. So the rules below fire ONLY on a LITERAL path into a protected
9
+ // store, where the form IS the fact.
10
+ //
11
+ // PURE BY CONTRACT: a string in, a structure out. No filesystem access, no subprocess, and above
12
+ // all no variable expansion — asking the shell where `$T` points means EXECUTING a fragment of an
13
+ // untrusted command inside the defence against that command (ADR-001, option C, rejected by
14
+ // construction).
15
+ //
16
+ // THREE OUTCOMES, NOT TWO. `undecidable` is never dressed up as `refuse`: a refusal derived from an
17
+ // inability to parse is a false guarantee, and the consumer must PASS on it (AC-10).
18
+ /**
19
+ * The limits the guard is obliged to PRINT rather than keep quiet about.
20
+ *
21
+ * Verbatim from ADR-001, section "Пределы, которые страж обязан НАЗЫВАТЬ в собственном тексте".
22
+ * MEASURED reason: a guard that stays silent about its limits reads as a guarantee. During the
23
+ * 2026-09-04 shift the false-refusal class "text ABOUT a command is not a command" fired six times,
24
+ * twice on the very lesson recording that class.
25
+ */
26
+ const LIMITS = Object.freeze([
27
+ 'цель в переменной оболочки — не разрешается, страж пропускает',
28
+ 'относительный путь после смены каталога — не разрешается, страж пропускает',
29
+ '`git rm` — не удаление файлов, исключено по построению',
30
+ 'содержимое кавычек читается ТОЛЬКО у глагола удаления и у носителя команды, исполняемой оболочкой («sh -c», «npm/npx -c», «pnpm --shell-mode exec»); под любой другой головой это текст о команде, а не команда; тела вставных блоков не читаются никогда',
31
+ ]);
32
+ /** The complete rule table. A refusal always carries one of these ids. */
33
+ export const DESTRUCTIVE_RULES = Object.freeze([
34
+ { id: 'protected-store-dz', what: 'буквальный путь в защищаемое хранилище .dz' },
35
+ { id: 'protected-store-agentic-qe', what: 'буквальный путь в защищаемое хранилище .agentic-qe' },
36
+ { id: 'database-file', what: 'буквальный путь к файлу базы данных (.db/.sqlite/.sqlite3/.rvf)' },
37
+ ]);
38
+ /** Directory names that ARE a protected store, matched as a whole path segment. */
39
+ const PROTECTED_SEGMENTS = new Map([
40
+ ['.dz', 'protected-store-dz'],
41
+ ['.agentic-qe', 'protected-store-agentic-qe'],
42
+ ]);
43
+ /** A live store file, recognised by extension. `-wal`/`-shm`/`-journal` are SQLite satellites. */
44
+ const DATABASE_FILE = /\.(db|sqlite|sqlite3|rvf)(-wal|-shm|-journal)?$/i;
45
+ /**
46
+ * Options that make a deletion verb print something and EXIT — no operand is ever removed.
47
+ *
48
+ * MEASURED with GNU coreutils 8.32, each case in its own temp directory: `<verb> --help .dzprobe`,
49
+ * `<verb> --version .dzprobe`, `rmdir --help …`, `unlink --help …` and `shred --version …` each
50
+ * printed usage or a version banner, exited 0 and left the target ALIVE — and position does not
51
+ * matter, `<verb> -rf .dzprobe --help` behaves the same. The classifier skipped these as ordinary
52
+ * flags and then named the operand as a protected deletion (cross-family review, gpt-5.6-sol,
53
+ * round 5): a refusal naming a store the command CANNOT touch, the R-flags defect.
54
+ *
55
+ * They are only modes BEFORE the option terminator. MEASURED the other way too:
56
+ * `<verb> -rf -- --help .dzprobe` left the target GONE, and `<verb> -- --help .dzprobe` reported
57
+ * `cannot remove '--help': No such file or directory` — past `--` the word is a FILE NAME.
58
+ */
59
+ const TERMINAL_MODE_OPTIONS = new Set(['--help', '--version']);
60
+ /** Verbs that delete from the filesystem. Deliberately short. */
61
+ const DELETE_VERBS = new Set(['rm', 'rmdir', 'unlink', 'shred']);
62
+ const NO_VALUE_OPTIONS = { short: '', long: new Set() };
63
+ function shortClusterValue(letters, flag) {
64
+ if (letters === '' || flag.startsWith('--') || !flag.startsWith('-'))
65
+ return null;
66
+ for (let p = 1; p < flag.length; p++) {
67
+ const letter = flag.charAt(p);
68
+ if (!letters.includes(letter))
69
+ continue;
70
+ const rest = flag.slice(p + 1);
71
+ return { letter, attached: rest === '' ? null : rest };
72
+ }
73
+ return null;
74
+ }
75
+ /** True when `flag`, written exactly like this, consumes the word that follows it. */
76
+ function consumesNextWord(arity, flag) {
77
+ if (flag.startsWith('--')) {
78
+ const name = flag.slice(2);
79
+ return !name.includes('=') && arity.long.has(name); // `--unset=FOO` carries its own value
80
+ }
81
+ const hit = shortClusterValue(arity.short, flag);
82
+ return hit !== null && hit.attached === null;
83
+ }
84
+ /**
85
+ * Commands that EXEC another command, so the real verb is the next word — with the arity of the
86
+ * options each of them accepts.
87
+ *
88
+ * This is an ALLOWLIST on purpose: an unknown head word stops the search, i.e. the default is
89
+ * "not a deletion, allow". A denylist would default to "keep looking", which refuses on
90
+ * `echo rm .dz/x` — the very false-refusal class this feature exists to avoid.
91
+ */
92
+ // The arities are named data consumed by the single carrier table below; none of them identifies a
93
+ // wrapper on its own. R17 deliberately removed the old parallel `ARGV_WRAPPER_OPTIONS` registry:
94
+ // splitting "where is the command?" across that map and the strategy table hid npm's shell carrier.
95
+ // `sudo`: `-h`/`--host` was the gap. sudo's own `--help` documents `-h, --host=host`, and the
96
+ // value was not consumed, so the HOST became the command head and the deletion behind it was
97
+ // allowed (cross-family review, gpt-5.6-sol, round 7). MEASURED honestly in both directions on
98
+ // this build: `sudo -u root printf RAN` and `sudo -g root printf RAN` print RAN (the value IS
99
+ // eaten), while `sudo -H/-E/-n/-s/-i/-b/-k printf RAN` print RAN written bare (they eat nothing).
100
+ // `-h` itself could NOT be exercised here — `sudo -h localhost <verb> …` answers `a remote host
101
+ // may only be specified when listing privileges` and deletes nothing on this build — so its
102
+ // evidence is sudo's own synopsis, not a run. It is entered as value-taking anyway, because the
103
+ // expensive direction of the error is a SILENT MISS on a build whose plugin does support remote
104
+ // execution, while the false refusal it can cause costs a measured zero (`sudo -h`/`--host`
105
+ // appears 0 times in the 20 938-command corpus).
106
+ const SUDO_WRAPPER_OPTIONS = { short: 'ugpCUrtDRTh', long: new Set(['user', 'group', 'prompt', 'close-from', 'other-user', 'role', 'type', 'chdir', 'chroot', 'command-timeout', 'host']) };
107
+ const DOAS_WRAPPER_OPTIONS = { short: 'uCa', long: new Set() };
108
+ // `env`: `-S`/`--split-string` is NOT an ordinary value-taking option — its value IS the command
109
+ // (the option-value carrier in the table below), and the letter stays in `short` only so a cluster
110
+ // ending in it is walked correctly. The three signal options are absent on purpose: their argument is
111
+ // OPTIONAL (`--ignore-signal[=SIG]`), so they never eat a separate word. MEASURED —
112
+ // `env --ignore-signal printf RAN` prints RAN and exits 0, while `env --unset printf RAN`
113
+ // consumes `RAN` and exits 127. Listing them as value-taking swallowed the deletion verb itself
114
+ // and the guard went silent on a live deletion (cross-family review, gpt-5.6-sol, round 4; F19).
115
+ const ENV_WRAPPER_OPTIONS = { short: 'uCS', long: new Set(['unset', 'chdir']) };
116
+ const NICE_WRAPPER_OPTIONS = { short: 'n', long: new Set(['adjustment']) };
117
+ const STDBUF_WRAPPER_OPTIONS = { short: 'ioe', long: new Set(['input', 'output', 'error']) };
118
+ const TIME_WRAPPER_OPTIONS = { short: 'of', long: new Set(['output', 'format']) };
119
+ // `xargs`: GNU spells three of its long options with an OPTIONAL argument — `--eof[=eof-str]`,
120
+ // `--replace[=R]`, `--max-lines[=n]` — so written BARE they consume nothing and the next word is
121
+ // the command. MEASURED with GNU findutils 4.8.0: `printf x | xargs --eof <verb> -rf .dzprobe`
122
+ // exited 0 and left the target GONE, and so did `--replace` and `--max-lines`; the five that
123
+ // remain each refused to run with the verb in the value slot (`invalid number "rm"`,
124
+ // `Invalid input delimiter specification rm`, `invalid option -- 'f'`), which is the proof that
125
+ // they DO consume a word. The SHORT spellings `-I`, `-E`, `-L` take a required argument and stay.
126
+ // `xargs`: `--process-slot-var` was the gap — MEASURED end to end,
127
+ // `printf x | xargs --process-slot-var SLOT <verb> -rf .dzprobe` exited 0 and left the target
128
+ // GONE. The sweep confirmed the rest in both directions: `-I -E -n -P -s -a -d -L` and
129
+ // `--max-args --max-procs --max-chars --arg-file --delimiter` each consume a word, while
130
+ // `-0 -r -t -x`, the optional-argument `-l`, and `--eof/--replace/--max-lines` (round 6) do not.
131
+ const XARGS_WRAPPER_OPTIONS = { short: 'IEnPsadL', long: new Set(['max-args', 'max-procs', 'max-chars', 'arg-file', 'delimiter', 'process-slot-var']) };
132
+ const EXEC_WRAPPER_OPTIONS = { short: 'a', long: new Set() };
133
+ // `ionice <verb> -rf .dz` runs the deletion, but ionice was missing from the allowlist, so
134
+ // resolution stopped at an unknown head and allowed it (cross-family review, gpt-5.6-sol,
135
+ // round 8). The arity is the round-8 sweep's, MEASURED: `-c`/`--class` and `-n`/`--classdata`
136
+ // consume a word (`ionice -c 2 printf RAN` prints RAN, bare `-c` does not), `-t`/`--ignore`
137
+ // consume nothing, and `--help`/`--version` exec nothing at all. The pid/uid selectors take a
138
+ // value per ionice's own synopsis (`-p, --pid <pid>...`).
139
+ //
140
+ // Only ionice was added. The other candidates the sweep turned up — timeout, chroot, taskset,
141
+ // chrt, flock, unbuffer, script, torify — are NOT here: widening the allowlist is an ADR question
142
+ // for the owner (backlog 422f7596), not a fix-round decision, and "an unknown head stops the
143
+ // search" is a DELIBERATE limit of ADR-001 rather than a defect.
144
+ const IONICE_WRAPPER_OPTIONS = { short: 'cnpPu', long: new Set(['class', 'classdata', 'pid', 'pgid', 'uid']) };
145
+ /**
146
+ * Options of a wrapper whose VALUE IS THE COMMAND, not a parameter of the wrapper.
147
+ *
148
+ * `env -S '<verb> -rf .dz'` does not run a program called `<verb> -rf .dz`; it SPLITS the string
149
+ * into words and runs the result, with any remaining operands of the `env` line appended. Read as
150
+ * an ordinary value-taking option, the whole command text was skipped and a live deletion of the
151
+ * store was allowed (cross-family review, gpt-5.6-sol, round 4). MEASURED with GNU coreutils 8.32,
152
+ * each case in its own temp directory: `env -S '<verb> -rf .dzprobe'`,
153
+ * `env --split-string '<verb> -rf .dzprobe'` and `env --split-string=<verb> -rf .dzprobe` all exited
154
+ * 0 and left the target GONE — note that the third carries only the VERB in the string, which is
155
+ * why the rest of the line has to be appended rather than dropped.
156
+ *
157
+ * This does NOT widen the scope, for the same reason `sh -c` does not: a string is data about a
158
+ * command only while the head does not EXECUTE it (ADR-001, limit 4).
159
+ */
160
+ const ENV_COMMAND_STRING_OPTIONS = { short: 'S', long: new Set(['split-string']) };
161
+ /**
162
+ * Options that turn a wrapper into an INSPECTION: it reports where a command lives and runs nothing.
163
+ *
164
+ * MEASURED: `command -v <verb> .dzprobe` printed `/usr/bin/rm`, `command -V <verb> .dzprobe` printed
165
+ * `rm is /usr/bin/rm`, and `command -pv <verb> .dzprobe` printed `/bin/rm` — all exited 0 and left
166
+ * the target ALIVE. Resolving the next word as an executable unconditionally refused a pure lookup
167
+ * (cross-family review, gpt-5.6-sol, round 5); the idiom appears 16 times in the 20 938-command
168
+ * corpus, so the false refusal is not hypothetical. The letters are what disarm it and nothing else:
169
+ * `command <verb> -rf .dzprobe` and `command -p <verb> -rf .dzprobe` both left the target GONE.
170
+ *
171
+ * `builtin` is deliberately NOT here, and the reason is named rather than left to a reader's guess.
172
+ * The review is right that `builtin <verb>` deletes nothing under bash — MEASURED for all four
173
+ * verbs, each `not a shell builtin`, exit 1, target ALIVE — but `builtin` dispatches to whatever the
174
+ * RUNNING shell has, and zsh's `zsh/files` module provides a real builtin `rm`. zsh is not installed
175
+ * on this machine, so that could not be probed, and turning a refusal into `allow` on an unprobed
176
+ * shell is the expensive direction of the error. The false refusal it leaves costs a measured zero:
177
+ * `builtin` at the head of a command appears 0 times in the same corpus.
178
+ */
179
+ const INSPECTION_OPTIONS = new Map([
180
+ ['command', { short: 'vV', long: new Set() }],
181
+ ]);
182
+ /** True when `flag` puts the wrapper into an inspection mode — any letter of a cluster counts. */
183
+ function isInspectionFlag(inspect, flag) {
184
+ if (flag.startsWith('--')) {
185
+ const eq = flag.indexOf('=');
186
+ return inspect.long.has(eq === -1 ? flag.slice(2) : flag.slice(2, eq));
187
+ }
188
+ for (let p = 1; p < flag.length; p++)
189
+ if (inspect.short.includes(flag.charAt(p)))
190
+ return true;
191
+ return false;
192
+ }
193
+ /**
194
+ * Wrappers for which a LONE `-` is an option rather than the command.
195
+ *
196
+ * MEASURED with GNU coreutils 8.32: `env - <verb> -rf .dzprobe` exited 0 and left the target GONE,
197
+ * exactly like `env -i …`, and `env - printf RAN` prints RAN. A one-character word ended the
198
+ * wrapper scan, so `-` resolved as the command head, an unknown head STOPS the search, and a live
199
+ * deletion was allowed (cross-family review, gpt-5.6-sol, round 7).
200
+ *
201
+ * It also ENDS the options, like `--`: `env - -i <marker>` answers `env: '-i': No such file or
202
+ * directory`, i.e. the word after the dash is the COMMAND. And it is env's spelling alone —
203
+ * `sudo - <marker>` answers `sudo: -: command not found`.
204
+ */
205
+ const LONE_DASH_WRAPPERS = new Set(['env']);
206
+ /**
207
+ * Options that make a WRAPPER print something and exit without exec'ing anything.
208
+ *
209
+ * MEASURED across all eight wrappers available here — env, nice, stdbuf, xargs, setsid, nohup,
210
+ * sudo, ionice — sixteen runs, and the marker program executed in NONE of them. The classifier
211
+ * walked past the option to the verb and refused a command that runs nothing (cross-family review,
212
+ * gpt-5.6-sol, round 7).
213
+ *
214
+ * RECORDED BECAUSE THE FIRST MEASUREMENT WAS WRONG, and the error is the interesting half: the
215
+ * probe counted output lines matching `RAN`, and the GNU version banner contains `WARRANTY`. Five
216
+ * wrappers therefore looked as though `--version` still ran the command. The instrument decided the
217
+ * verdict until it was re-run against the marker's full text.
218
+ *
219
+ * Only these two LONG spellings are claimed. Short spellings differ per tool (`sudo -V` is a
220
+ * version request, `-V` is not that anywhere else here), so they are deliberately not generalised.
221
+ */
222
+ const WRAPPER_TERMINAL_OPTIONS = new Set(['--help', '--version']);
223
+ /** How many times one segment may be rewritten by a command-string option before we give up. */
224
+ const MAX_COMMAND_STRING_REWRITES = 4;
225
+ /**
226
+ * `-S`'s value split into words the way GNU `env` splits it — MEASURED, not inferred.
227
+ *
228
+ * The distinction from a shell parse is load-bearing in BOTH directions. `env -S` hands the split
229
+ * result to `execvp`, so `;`, `|` and `&` are ordinary characters of an argument: in
230
+ * `env -S '<verb> -rf .dz; echo hi'` the operand handed to the verb is `.dz;`, a name that does not
231
+ * exist, and the store is untouched — classifying that string as a shell command line would refuse
232
+ * on a store the command does not delete (the R-flags defect).
233
+ *
234
+ * ROUND-6 CORRECTION. This function used to treat `\_` as an escaped underscore. It is env's WORD
235
+ * SEPARATOR: `env -S '<verb>\_-rf\_.dzprobe'` exited 0 and left the target GONE, and printing argv
236
+ * from inside `-S` shows `a\_b` arriving as TWO arguments, `<a><b>`. Read as one word, the head was
237
+ * not a deletion verb and the guard allowed a live deletion (cross-family review, gpt-5.6-sol,
238
+ * round 6).
239
+ *
240
+ * THE COMPLETE MEASURED TABLE (GNU coreutils 8.32, argv printed from inside `-S`):
241
+ * - OUTSIDE quotes: `\_` SEPARATES words (repeats and edges collapse: `\_a\_b\_` is `<a><b>`);
242
+ * `\t \n \f \r \v` are those control characters INSIDE the word, not separators; `\\ \# \$` are
243
+ * the literal character; `\c` ENDS the whole string (`a\cb\_c` is `<a>`); ordinary spaces
244
+ * separate as well.
245
+ * - Inside DOUBLE quotes: escapes still apply but `\_` is an ordinary SPACE (`a"b\_c"d` is
246
+ * `<ab cd>`), and `\c` is an ERROR (`'\c' must not appear in double-quoted -S string`).
247
+ * - Inside SINGLE quotes: NOTHING is processed (`a'b\_c'd` is `<ab\_cd>`, `'${HOME}'` stays
248
+ * literal).
249
+ * - `${VAR}` is expanded by env; a BARE `$VAR` is an error (`only ${VARNAME} expansion is
250
+ * supported`), and so is any unrecognised escape (`invalid sequence '\q' in -S`).
251
+ *
252
+ * WHAT AN ERROR MEANS HERE. When env refuses the string it runs NOTHING, so the safe reading is
253
+ * "not decidable by form": the word is marked dynamic, which can never produce a refusal and cannot
254
+ * hide a deletion that never happens. Expansion is marked the same way — it is the guard's first
255
+ * printed limit.
256
+ */
257
+ /** `env -S` escapes that stand for one character INSIDE a word, outside single quotes. */
258
+ const ENV_S_ESCAPES = new Map([
259
+ ['t', '\t'], ['n', '\n'], ['f', '\f'], ['r', '\r'], ['v', '\v'],
260
+ ['\\', '\\'], ['#', '#'], ['$', '$'],
261
+ ]);
262
+ /** The split of an `env -S` value, or null when env would REFUSE the whole string and run nothing. */
263
+ function splitStringWords(value) {
264
+ const words = [];
265
+ let buf = '';
266
+ let has = false;
267
+ let dynamic = false;
268
+ /**
269
+ * True when env would reject the whole `-S` value and exec NOTHING.
270
+ *
271
+ * MEASURED, four spellings, each in a throwaway directory: an unterminated double quote, an
272
+ * unterminated single quote, an unknown escape `\q` and a bare `$BARE` each make env exit 125
273
+ * (`no terminating quote in -S string`, `invalid sequence '\q' in -S`, `only ${VARNAME}
274
+ * expansion is supported`) and leave the target ALIVE. Marking only the offending WORD dynamic
275
+ * left the words BEFORE it standing and produced a refusal for a command that runs nothing
276
+ * (cross-family review, gpt-5.6-sol, round 10) — the invalidity belongs to the whole string.
277
+ */
278
+ let invalid = false;
279
+ const flush = () => {
280
+ if (!has)
281
+ return;
282
+ // `env -S` splits its string and calls execvp DIRECTLY — there is no shell left to expand
283
+ // anything. MEASURED: with a directory `.dz` and a file literally named `{.dz,foo}` side by
284
+ // side, `env -S '<verb> -rf {.dz,foo}'` removed the FILE and left `.dz` ALIVE, and argv printed
285
+ // from inside `-S` shows `<{.dz,foo}>` as one literal argument. So every brace here is part of
286
+ // the name; expanding it refused a path the command never touches (cross-family review,
287
+ // gpt-5.6-sol, round 9). Operands the OUTER shell appends keep their own mask.
288
+ words.push({ kind: 'word', text: buf, dynamic, literalMask: '1'.repeat(buf.length) });
289
+ buf = '';
290
+ has = false;
291
+ dynamic = false;
292
+ };
293
+ /** One escape, shared by the unquoted and double-quoted paths. Returns the next index. */
294
+ const escape = (i, inDoubleQuotes) => {
295
+ const e = value.charAt(i + 1);
296
+ if (e === '_') {
297
+ if (inDoubleQuotes) {
298
+ buf += ' ';
299
+ has = true;
300
+ } // an ordinary space inside quotes
301
+ else
302
+ flush(); // …but a WORD SEPARATOR outside them
303
+ return i + 2;
304
+ }
305
+ if (e === 'c' && !inDoubleQuotes) {
306
+ flush();
307
+ return value.length;
308
+ } // ends the whole string
309
+ const lit = ENV_S_ESCAPES.get(e);
310
+ if (lit !== undefined) {
311
+ buf += lit;
312
+ has = true;
313
+ return i + 2;
314
+ }
315
+ invalid = true; // env REFUSES this string and runs nothing
316
+ return i + 2;
317
+ };
318
+ for (let i = 0; i < value.length;) {
319
+ const c = value.charAt(i);
320
+ if (c === '\\') {
321
+ if (i + 1 >= value.length) {
322
+ invalid = true;
323
+ break;
324
+ }
325
+ i = escape(i, false);
326
+ continue;
327
+ }
328
+ if (c === "'") { // single quotes: nothing is processed
329
+ const end = value.indexOf("'", i + 1);
330
+ if (end === -1) {
331
+ invalid = true;
332
+ break;
333
+ }
334
+ buf += value.slice(i + 1, end);
335
+ has = true;
336
+ i = end + 1;
337
+ continue;
338
+ }
339
+ if (c === '"') {
340
+ let j = i + 1;
341
+ let closed = false;
342
+ has = true;
343
+ while (j < value.length) {
344
+ const d = value.charAt(j);
345
+ if (d === '"') {
346
+ closed = true;
347
+ break;
348
+ }
349
+ if (d === '\\') {
350
+ j = escape(j, true);
351
+ continue;
352
+ }
353
+ if (d === '$') {
354
+ dynamic = true;
355
+ buf += d;
356
+ j++;
357
+ continue;
358
+ }
359
+ buf += d;
360
+ j++;
361
+ }
362
+ if (!closed) {
363
+ invalid = true;
364
+ break;
365
+ }
366
+ i = j + 1;
367
+ continue;
368
+ }
369
+ // `${VARNAME}` is expanded; a BARE `$VAR` is an error env refuses to run.
370
+ if (c === '$') {
371
+ if (value.charAt(i + 1) !== '{') {
372
+ invalid = true;
373
+ break;
374
+ }
375
+ dynamic = true;
376
+ buf += c;
377
+ has = true;
378
+ i++;
379
+ continue;
380
+ }
381
+ if (/\s/.test(c)) {
382
+ flush();
383
+ i++;
384
+ continue;
385
+ }
386
+ buf += c;
387
+ has = true;
388
+ i++;
389
+ }
390
+ if (invalid)
391
+ return null;
392
+ flush();
393
+ return words;
394
+ }
395
+ /** See the note next to DELETE_VERBS. `rm`/`rmdir`/`unlink` take no separate option values. */
396
+ const DELETE_OPTIONS = new Map([
397
+ ['rm', NO_VALUE_OPTIONS],
398
+ ['rmdir', NO_VALUE_OPTIONS],
399
+ ['unlink', NO_VALUE_OPTIONS],
400
+ // RETRACTED CLAIM. Round 1 wrote here that only a FILE-valued option can be mistaken for a
401
+ // target, and that `-n`/`--iterations` and `-s`/`--size` — which take NUMBERS — give the same
402
+ // verdict whether their value is skipped or scanned, so no test could tell the two tables apart.
403
+ // That is FALSE for MALFORMED input, and the falsifier is a single line: `shred -s .dz/foo
404
+ // /tmp/ordinary` makes shred read `.dz/foo` as the SIZE, reject it and destroy nothing (MEASURED
405
+ // with GNU coreutils 8.32 on a throwaway file: `shred: invalid file size: '.dz/foo'`, exit 1,
406
+ // the file byte-identical afterwards), while the classifier named that path as a protected
407
+ // deletion and refused — a refusal naming a store the command does not touch, the R-flags
408
+ // defect. The entries are back WITH the test that discriminates them (F16), and the claim is
409
+ // retracted rather than left standing next to its own counterexample.
410
+ ['shred', { short: 'ns', long: new Set(['random-source', 'iterations', 'size']) }],
411
+ ]);
412
+ const NPM_EXEC_OPTIONS = {
413
+ short: 'pwc',
414
+ long: new Set(['package', 'workspace']),
415
+ };
416
+ const NPM_CALL_OPTIONS = {
417
+ short: 'c',
418
+ long: new Set(['call']),
419
+ };
420
+ const PNPM_EXEC_OPTIONS = {
421
+ short: 'C',
422
+ long: new Set(['dir', 'filter']),
423
+ };
424
+ const NPM_GLOBAL_OPTIONS = {
425
+ short: 'Cw',
426
+ long: new Set(['cache', 'loglevel', 'prefix', 'registry', 'userconfig', 'workspace']),
427
+ };
428
+ const PNPM_GLOBAL_OPTIONS = {
429
+ short: 'C',
430
+ long: new Set(['config-dir', 'dir', 'filter', 'global-dir', 'store-dir', 'virtual-store-dir']),
431
+ };
432
+ const PNPM_SHELL_MODE_OPTIONS = {
433
+ short: 'c',
434
+ long: new Set(['shell-mode']),
435
+ };
436
+ const YARN_GLOBAL_OPTIONS = {
437
+ short: '',
438
+ long: new Set(['cwd']),
439
+ };
440
+ const YARN_EXEC_OPTIONS = {
441
+ short: 'p',
442
+ long: new Set(['package']),
443
+ };
444
+ const CONTAINER_EXEC_OPTIONS = {
445
+ short: 'euw',
446
+ long: new Set(['env', 'env-file', 'user', 'workdir', 'detach-keys']),
447
+ };
448
+ const KUBECTL_EXEC_OPTIONS = {
449
+ short: 'cn',
450
+ long: new Set(['container', 'namespace', 'pod-running-timeout', 'request-timeout']),
451
+ };
452
+ const argvCommand = (options, positionalsBeforeCommand = 0, extras = { execution: 'argv' }) => ({
453
+ kind: 'argv',
454
+ location: 'first-positional',
455
+ options,
456
+ positionalsBeforeCommand,
457
+ ...extras,
458
+ });
459
+ const subcommandWrapper = (nonFilesystem, commands = [], options = null, shellModeOptions) => ({
460
+ kind: 'subcommand',
461
+ options,
462
+ ...(shellModeOptions === undefined ? {} : { shellModeOptions }),
463
+ nonFilesystem: new Set(nonFilesystem),
464
+ commands: new Map(commands),
465
+ });
466
+ const COMMAND_WRAPPER_STRATEGIES = new Map([
467
+ ['sudo', argvCommand(SUDO_WRAPPER_OPTIONS)],
468
+ ['doas', argvCommand(DOAS_WRAPPER_OPTIONS)],
469
+ ['env', argvCommand(ENV_WRAPPER_OPTIONS, 0, {
470
+ execution: 'argv',
471
+ optionValueCommands: [{ options: ENV_COMMAND_STRING_OPTIONS, execution: 'argv', split: 'env', appendRemaining: true }],
472
+ })],
473
+ ['nice', argvCommand(NICE_WRAPPER_OPTIONS)],
474
+ ['stdbuf', argvCommand(STDBUF_WRAPPER_OPTIONS)],
475
+ ['time', argvCommand(TIME_WRAPPER_OPTIONS)],
476
+ ['xargs', argvCommand(XARGS_WRAPPER_OPTIONS)],
477
+ ['exec', argvCommand(EXEC_WRAPPER_OPTIONS)],
478
+ ['command', argvCommand(NO_VALUE_OPTIONS)],
479
+ ['builtin', argvCommand(NO_VALUE_OPTIONS)],
480
+ ['nohup', argvCommand(NO_VALUE_OPTIONS)],
481
+ ['setsid', argvCommand(NO_VALUE_OPTIONS)],
482
+ ['ionice', argvCommand(IONICE_WRAPPER_OPTIONS)],
483
+ ...['sh', 'bash', 'zsh', 'dash', 'ksh'].map((name) => [
484
+ name,
485
+ { kind: 'shell-c', location: 'shell-c-string', execution: 'shell' },
486
+ ]),
487
+ ['npx', argvCommand(NPM_EXEC_OPTIONS, 0, {
488
+ execution: 'argv',
489
+ optionValueCommands: [{ options: NPM_CALL_OPTIONS, execution: 'shell', split: 'shell' }],
490
+ })],
491
+ ['npm', subcommandWrapper(['rm', 'remove', 'uninstall', 'un'], [
492
+ ['exec', argvCommand(NPM_EXEC_OPTIONS, 0, {
493
+ execution: 'argv',
494
+ optionValueCommands: [{ options: NPM_CALL_OPTIONS, execution: 'shell', split: 'shell' }],
495
+ })],
496
+ ['x', argvCommand(NPM_EXEC_OPTIONS, 0, {
497
+ execution: 'argv',
498
+ optionValueCommands: [{ options: NPM_CALL_OPTIONS, execution: 'shell', split: 'shell' }],
499
+ })],
500
+ ['run', { kind: 'external-script', location: 'named-script', execution: 'shell' }],
501
+ ['run-script', { kind: 'external-script', location: 'named-script', execution: 'shell' }],
502
+ ], NPM_GLOBAL_OPTIONS)],
503
+ ['pnpm', subcommandWrapper(['rm', 'remove', 'uninstall', 'un'], [
504
+ ['exec', argvCommand(PNPM_EXEC_OPTIONS)],
505
+ ['dlx', argvCommand(PNPM_EXEC_OPTIONS)],
506
+ ['run', { kind: 'external-script', location: 'named-script', execution: 'shell' }],
507
+ ], PNPM_GLOBAL_OPTIONS, PNPM_SHELL_MODE_OPTIONS)],
508
+ ['yarn', subcommandWrapper(['remove'], [
509
+ ['exec', argvCommand(YARN_EXEC_OPTIONS)],
510
+ ['dlx', argvCommand(YARN_EXEC_OPTIONS)],
511
+ ['run', { kind: 'external-script', location: 'named-script', execution: 'shell' }],
512
+ ], YARN_GLOBAL_OPTIONS)],
513
+ ['bun', subcommandWrapper(['rm', 'remove', 'uninstall'], [
514
+ ['x', argvCommand(NO_VALUE_OPTIONS)],
515
+ ['run', { kind: 'external-script', location: 'named-script', execution: 'shell' }],
516
+ ])],
517
+ ...['git', 'hg', 'svn', 'jj', 'bzr'].map((name) => [name, subcommandWrapper(['rm'])]),
518
+ ['cargo', subcommandWrapper(['rm'], [['run', { kind: 'external-script', location: 'named-script', execution: 'shell' }]])],
519
+ ['pip', subcommandWrapper(['uninstall'])],
520
+ ['pip3', subcommandWrapper(['uninstall'])],
521
+ ['gem', subcommandWrapper(['uninstall'])],
522
+ ['apt', subcommandWrapper(['remove'])],
523
+ ['apt-get', subcommandWrapper(['remove'])],
524
+ ['brew', subcommandWrapper(['rm', 'remove', 'uninstall'])],
525
+ ['docker', subcommandWrapper(['rm'], [['exec', argvCommand(CONTAINER_EXEC_OPTIONS, 1)]])],
526
+ ['podman', subcommandWrapper(['rm'], [['exec', argvCommand(CONTAINER_EXEC_OPTIONS, 1)]])],
527
+ ['kubectl', subcommandWrapper([], [['exec', argvCommand(KUBECTL_EXEC_OPTIONS, 1)]])],
528
+ ['helm', subcommandWrapper([])],
529
+ ]);
530
+ const VCS_TOOLS = new Set(['git', 'hg', 'svn', 'jj', 'bzr']);
531
+ /**
532
+ * Shells that RUN an inline string given after `-c`.
533
+ *
534
+ * MEASURED, not theory: on the Codex host the model puts exactly `sh -c '…'` into
535
+ * `tool_input.command` (features/crossrt-2-codex-hooks/07_code_changes/probe-results/
536
+ * spike-arming.md:247), so a guard that only reads the direct call protects nothing there.
537
+ *
538
+ * This does NOT widen the scope — it applies the SAME rule as a quoted operand: a string is data
539
+ * about a command only while the head does not EXECUTE it. Under `echo` it stays prose; under
540
+ * `sh -c` it is the command, and it is classified as one.
541
+ */
542
+ /**
543
+ * Which shell runners perform BRACE EXPANSION on the string they are given.
544
+ *
545
+ * MEASURED by printing argv from inside each shell present here: `bash -c` and `ksh -c` turn
546
+ * `.d{y..z} x{a,b}` into `<.dy><.dz><xa><xb>`, while `dash -c` passes `<.d{y..z}><x{a,b}>`
547
+ * unchanged — dash has no brace expansion — and `dash -c '<verb> -rf .d{y..z}'` left the target
548
+ * ALIVE. Classifying the inner string with the outer bash-like lexer refused a safe command
549
+ * (cross-family review, gpt-5.6-sol, round 12).
550
+ *
551
+ * `sh` is DELIBERATELY on the expanding side even though `/bin/sh` resolves to `/usr/bin/dash` on
552
+ * this machine, because what `sh` IS varies: it is bash on many systems, and there
553
+ * `sh -c '<verb> -rf .d{y..z}'` deletes the store. Exempting it would trade a false refusal on one
554
+ * machine for a silent MISS on another — the same tie-break as `builtin` (round 6), `sudo --host`
555
+ * (round 8) and the unknown-option decision (round 9). `zsh` is documented to expand and is not
556
+ * installed here, so it stays on the fail-closed side too (F55-guard).
557
+ */
558
+ const SHELLS_WITHOUT_BRACE_EXPANSION = new Set(['dash']);
559
+ /**
560
+ * Options of a shell runner that take the NEXT word as their value.
561
+ *
562
+ * Same defect class as the wrapper arity table (F3), one command further along: the search for the
563
+ * `-c` string stopped at the bare word `extglob` in `bash -O extglob -c '…'`, so the string the
564
+ * shell actually RUNS was never unpacked and the deletion inside it was allowed (cross-family
565
+ * review, gpt-5.6-sol, round 2). Only these four take a separate word in `sh`/`bash`/`zsh`/`dash`/
566
+ * `ksh`; the list is short on purpose, because a wrong entry here consumes the `-c` itself and the
567
+ * guard goes silent on the very form F7 exists to catch.
568
+ */
569
+ const SHELL_VALUE_OPTIONS = new Set(['-o', '-O', '+o', '+O']);
570
+ /**
571
+ * GNU LONG options of a shell runner that take the NEXT word as their value.
572
+ *
573
+ * The same defect as SHELL_VALUE_OPTIONS, one option SPELLING further along: the search for the
574
+ * `-c` string stopped at the bare word `/dev/null` in `bash --rcfile /dev/null -c '<deletion>'`,
575
+ * so the string bash actually RUNS was never unpacked and the deletion inside it was allowed
576
+ * (cross-family review, gpt-5.6-sol, round 3).
577
+ *
578
+ * WHAT WAS CHECKED, so the next reader need not guess at the completeness of a two-entry list.
579
+ * `bash --help` (GNU bash 5.1.16) prints the COMPLETE set of 17 GNU long options: --debug,
580
+ * --debugger, --dump-po-strings, --dump-strings, --help, --init-file, --login, --noediting,
581
+ * --noprofile, --norc, --posix, --pretty-print, --rcfile, --restricted, --verbose, --version.
582
+ * Exactly two of them take a separate word — `--rcfile` and `--init-file`, which are synonyms —
583
+ * and that was PROBED, not read: each value-less one still ran `-c 'echo RAN'` and printed RAN,
584
+ * while both of these two consumed the file name and then ran the `-c` string. `dash` has no long
585
+ * options at all (`dash --help` → `Illegal option --`), and `ksh`/`ksh93` spell their long options
586
+ * as set-option NAMES (`--posix`, `--noglob`, …), none of which takes a value — also probed.
587
+ * `zsh` is NOT installed on this machine and was therefore NOT probed; its long forms are
588
+ * documented as the same set-option names plus `-o option`, which SHELL_VALUE_OPTIONS already
589
+ * covers, so no zsh-specific entry is claimed here.
590
+ *
591
+ * The `--rcfile=FILE` spelling is deliberately absent: bash REJECTS it (`bash --rcfile=/dev/null
592
+ * -c 'echo RAN'` → exit 2, `invalid option`), so that line runs nothing at all.
593
+ *
594
+ * As with every arity table in this file, a WRONG entry consumes the `-c` itself and the guard
595
+ * goes silent on the very form F7 exists to catch — which is why this list is exactly the probed
596
+ * two and not "every long option that looks file-ish".
597
+ */
598
+ const SHELL_LONG_VALUE_OPTIONS = new Set(['--rcfile', '--init-file']);
599
+ /**
600
+ * Shell options that make the `-c` string PARSED but never executed.
601
+ *
602
+ * MEASURED across four shells, each in a throwaway directory: `bash -n -c`, `sh -n -c`,
603
+ * `dash -n -c` and `ksh -n -c` all exit 0 and leave the target ALIVE, as do the clustered
604
+ * `bash -nc` and `bash -o noexec -c`. The string was unpacked and classified unconditionally, so a
605
+ * syntax check was refused (cross-family review, gpt-5.6-sol, round 10).
606
+ *
607
+ * `n` is the only letter here, and that is measured rather than assumed: `-v` and `-x` print the
608
+ * command AND run it — the target was GONE for both on all four shells — so neither may disarm the
609
+ * unpacking (F47-guard).
610
+ */
611
+ const SHELL_NO_EXEC_LETTER = 'n';
612
+ const SHELL_NO_EXEC_SET_OPTION = 'noexec';
613
+ /**
614
+ * Shell options after which the `-c` string is never executed at all.
615
+ *
616
+ * MEASURED with bash 5.1.16, each in a throwaway directory: `--help`, `--version`, `-D`,
617
+ * `--dump-strings` and `--dump-po-strings` print or dump and exit with the target ALIVE, and the
618
+ * clustered `-Dc` behaves the same. On the other shells the same spellings are refused outright —
619
+ * `sh`/`dash` answer `Illegal option --`, `ksh` prints its usage, all non-zero and ALIVE — so
620
+ * nothing runs there either. They were skipped as ordinary flags and the string behind them was
621
+ * then classified as a real deletion (cross-family review, gpt-5.6-sol, round 11).
622
+ *
623
+ * The list is exactly what was RUN, not what reads as terminal on a man page: `--pretty-print`,
624
+ * `--noediting`, `--norc` and `--posix` all left the target GONE, so none of them is here
625
+ * (F49-guard).
626
+ */
627
+ const SHELL_TERMINAL_OPTIONS = new Set([
628
+ '--help', '--version', '--dump-strings', '--dump-po-strings',
629
+ ]);
630
+ /** The short letter of bash's translation-dump mode, which also never runs the string. */
631
+ const SHELL_DUMP_LETTER = 'D';
632
+ /** How deep an inline `-c` string is unpacked. One level; deeper is answered `undecidable`. */
633
+ const MAX_SHELL_DEPTH = 1;
634
+ /**
635
+ * Words the shell reads as SYNTAX in command position rather than as the name of a program.
636
+ *
637
+ * `! rm -rf .agentic-qe` and `if true; then rm -rf .agentic-qe; fi` both delete the store. The
638
+ * classifier took `!` and `then` for an unknown executable, and an unknown head STOPS the search
639
+ * (that default is deliberate — see WRAPPERS), so the deletion behind the keyword was never
640
+ * examined (cross-family review, gpt-5.6-sol, round 2).
641
+ *
642
+ * This is an allowlist for the same reason WRAPPERS is one, and it is short: every entry is a word
643
+ * the POSIX shell reserves in command position, so nothing that could be a real program is skipped.
644
+ * `{`, `(`, `)` and `}` are not here because the lexer already treats a standalone brace or
645
+ * parenthesis as a SEPARATOR, so they never reach this function as a word.
646
+ *
647
+ * `if`/`while`/`until` are transparent for the same reason as `then`/`do`: in `if rm -rf .dz; then
648
+ * …` the condition itself is executed. The head of the segment AFTER the condition is reached
649
+ * through `then`/`do`, since `;` already ends the segment.
650
+ */
651
+ const CONTROL_WORDS = new Set([
652
+ '!', 'if', 'then', 'elif', 'else', 'while', 'until', 'do',
653
+ // `coproc` EXECUTES the command that follows it, asynchronously. MEASURED:
654
+ // `coproc <verb> -rf .dzprobe; wait $COPROC_PID` exited 0 and left the target GONE, as did the
655
+ // named form `coproc NAME { <verb> -rf .dzprobe; }`. Read as an unknown executable it stopped the
656
+ // search, so the deletion behind it was allowed (cross-family review, gpt-5.6-sol, round 5).
657
+ 'coproc',
658
+ ]);
659
+ /** Characters that end one command and start the next. */
660
+ const SEPARATORS = new Set([';', '&', '|', '(', ')', '\n']);
661
+ /**
662
+ * `{` and `}` are RESERVED WORDS, not metacharacters: the shell reads them as a group only when
663
+ * each stands alone as a whole word. Splitting on every occurrence broke `rm /tmp/foo{bar}.db` into
664
+ * an operand `/tmp/foo` plus debris, and the guard allowed a deletion the shell performed
665
+ * (cross-family review, gpt-5.6-sol, round 2). Note the contrast with `(`/`)`, which ARE
666
+ * metacharacters and separate a word wherever they appear — that is why they stay in SEPARATORS.
667
+ */
668
+ const BRACE_ENDS_A_WORD = new Set(['', ' ', '\t', '\n', ';', '&', '|', '(', ')']);
669
+ /** Metacharacters that end a heredoc DELIMITER word (whitespace ends it too). */
670
+ const DELIMITER_ENDS = new Set([';', '&', '|', '(', ')', '<', '>']);
671
+ /** Unescaped occurrences of these always make an unquoted word undecidable by form. */
672
+ const DYNAMIC_CHARS = /[`*?]/;
673
+ /**
674
+ * Whether `$` at `index` starts an expansion rather than denoting a literal dollar byte.
675
+ *
676
+ * A trailing `$` and `$` before `/` are literal in POSIX-like shells. Marking every dollar dynamic
677
+ * skipped literal operands such as `.dz/$` and let a real deletion pass (R16 P2-2). Named,
678
+ * positional and special parameters, `${...}`, `$()`, legacy `$[...]`, and bash's translated/
679
+ * ANSI-C quote prefixes remain dynamic (the latter is decoded by its dedicated branch).
680
+ */
681
+ function dollarExpands(command, index, inDoubleQuotes = false) {
682
+ const next = command.charAt(index + 1);
683
+ if (next === '')
684
+ return false;
685
+ if (/[A-Za-z0-9_]/.test(next))
686
+ return true;
687
+ if (inDoubleQuotes && (next === "'" || next === '"'))
688
+ return false;
689
+ return '@*#?-$!({[\'"'.includes(next);
690
+ }
691
+ /** The placeholder a text-less expansion occupies in a word — see addExpandedSpan. */
692
+ const DYNAMIC_SPAN_MARK = '\ue000';
693
+ /** The single-character ANSI-C escapes, exactly as `bash` expands them inside `$'…'`. */
694
+ const ANSI_C_SIMPLE = new Map([
695
+ ['a', '\x07'], ['b', '\b'], ['e', '\x1b'], ['E', '\x1b'], ['f', '\f'],
696
+ ['n', '\n'], ['r', '\r'], ['t', '\t'], ['v', '\v'],
697
+ ['\\', '\\'], ["'", "'"], ['"', '"'], ['?', '?'],
698
+ ]);
699
+ /**
700
+ * The index of the `'` that closes an ANSI-C string opened at `open` (the index of the quote), or
701
+ * -1 when it never closes. `\'` is an escaped quote there, so the scan skips escaped characters.
702
+ */
703
+ function ansiCEnd(command, open) {
704
+ for (let j = open + 1; j < command.length; j++) {
705
+ const c = command.charAt(j);
706
+ if (c === '\\') {
707
+ j++;
708
+ continue;
709
+ }
710
+ if (c === "'")
711
+ return j;
712
+ }
713
+ return -1;
714
+ }
715
+ /** Closing backtick for a command substitution, respecting the backtick escape grammar. */
716
+ function backtickEnd(command, open) {
717
+ for (let j = open + 1; j < command.length; j++) {
718
+ if (command.charAt(j) === '\\') {
719
+ j++;
720
+ continue;
721
+ }
722
+ if (command.charAt(j) === '`')
723
+ return j;
724
+ }
725
+ return -1;
726
+ }
727
+ /** Closing ordinary single quote. Kept as one reader so nested scanners do not grow private rules. */
728
+ function singleQuotedEnd(command, open) {
729
+ return command.indexOf("'", open + 1);
730
+ }
731
+ /**
732
+ * `$'…'` decoded the way the shell decodes it.
733
+ *
734
+ * WHY THIS IS NOT THE GENERIC DYNAMIC PATH. `$'…'` is a QUOTING form, not an expansion: the shell
735
+ * resolves it itself and hands the program a fully LITERAL word, so the form IS the fact. The lexer
736
+ * saw the leading `$` and marked the operand dynamic, and a literal path into a protected store was
737
+ * skipped by both hooks (cross-family review, gpt-5.6-sol, round 5). MEASURED in a throwaway
738
+ * directory: `<verb> -rf $'.dzprobe'` and `<verb> -rf $'.dz\x70robe'` both exited 0 and left the
739
+ * target GONE.
740
+ *
741
+ * THE DECODER MUST BE RIGHT, not merely present — a wrong byte either invents a path (a refusal
742
+ * naming a store the command does not touch) or misses one. Every rule below was read off `bash`
743
+ * 5.1.16 with `printf %s $'<case>' | od -An -tx1`, and the whole battery is pinned in F23-oracle:
744
+ * - an UNRECOGNISED escape keeps its backslash (`$'\.dz'` is four characters, `\.dz`);
745
+ * - `\nnn` is octal, at most three digits (`$'\1234'` is `S4`);
746
+ * - `\xHH` takes at most two hex digits and, with none at all, stays the literal `\x`;
747
+ * - `\cX` is a control character;
748
+ * - a NUL TERMINATES the word the shell builds (`$'ab\0cd'` is `ab`).
749
+ */
750
+ /**
751
+ * A code point rendered the way bash renders it, or null when bash emits bytes a JavaScript string
752
+ * cannot hold.
753
+ *
754
+ * `String.fromCodePoint` THROWS above 0x10FFFF, and the hook's catch turns any exception from this
755
+ * classifier into fail-OPEN — so `rm -rf $'\Uffffffff'; rm -rf .dz` deleted the store while the
756
+ * guard said nothing (cross-family review, gpt-5.6-sol, round 9).
757
+ *
758
+ * MEASURED where the boundary is, because "invalid" is not one behaviour. `$'A\U########B'`:
759
+ * - `\U80000000`, `\UFFFFFFFE`, `\UFFFFFFFF` produce the two bytes `AB` — the escape expands to
760
+ * NOTHING. Reproducing that is load-bearing, not cosmetic: `rm -rf $'.d\Uffffffffz'` deletes
761
+ * `.dz`, so an empty expansion BUILDS a protected path.
762
+ * - `\U110000`, `\U1FFFFF`, `\U3FFFFFF`, `\U7FFFFFFF` emit four to six bytes of old-style UTF-8,
763
+ * which a UTF-16 string cannot represent. Those are answered `null`, and the caller marks the
764
+ * word not-decidable-by-form rather than inventing a spelling for it.
765
+ */
766
+ const ANSI_C_EMPTY_FROM = 0x80000000;
767
+ function ansiCCharacter(code) {
768
+ if (code >= ANSI_C_EMPTY_FROM)
769
+ return ''; // bash expands it to nothing at all
770
+ if (code > 0x10ffff)
771
+ return null; // real bytes, but not expressible here
772
+ return String.fromCodePoint(code);
773
+ }
774
+ function decodeAnsiC(body) {
775
+ let inexpressible = false;
776
+ let out = '';
777
+ for (let i = 0; i < body.length; i++) {
778
+ const c = body.charAt(i);
779
+ if (c !== '\\') {
780
+ out += c;
781
+ continue;
782
+ }
783
+ const e = body.charAt(i + 1);
784
+ if (e === '') {
785
+ out += '\\';
786
+ break;
787
+ }
788
+ const simple = ANSI_C_SIMPLE.get(e);
789
+ if (simple !== undefined) {
790
+ out += simple;
791
+ i++;
792
+ continue;
793
+ }
794
+ if (e >= '0' && e <= '7') { // \nnn — octal, up to three
795
+ const m = /^[0-7]{1,3}/.exec(body.slice(i + 1));
796
+ const code = parseInt(m[0], 8);
797
+ if (code === 0)
798
+ return { text: out, inexpressible }; // a NUL ends the shell's word
799
+ out += String.fromCharCode(code);
800
+ i += m[0].length;
801
+ continue;
802
+ }
803
+ if (e === 'x' || e === 'u' || e === 'U') { // \xHH / \uHHHH / \UHHHHHHHH
804
+ const width = e === 'x' ? 2 : e === 'u' ? 4 : 8;
805
+ const m = new RegExp(`^[0-9A-Fa-f]{1,${width}}`).exec(body.slice(i + 2));
806
+ if (m === null) {
807
+ out += '\\' + e;
808
+ i++;
809
+ continue;
810
+ } // `$'\x'` is the literal `\x`
811
+ const code = parseInt(m[0], 16);
812
+ if (code === 0)
813
+ return { text: out, inexpressible };
814
+ const rendered = ansiCCharacter(code);
815
+ if (rendered === null)
816
+ inexpressible = true;
817
+ else
818
+ out += rendered;
819
+ i += 1 + m[0].length;
820
+ continue;
821
+ }
822
+ if (e === 'c') { // \cX — a control character
823
+ const x = body.charAt(i + 2);
824
+ if (x === '') {
825
+ out += '\\c';
826
+ i++;
827
+ continue;
828
+ }
829
+ const code = x === '?' ? 0x7f : x.toUpperCase().charCodeAt(0) & 0x1f;
830
+ if (code === 0)
831
+ return { text: out, inexpressible };
832
+ out += String.fromCharCode(code);
833
+ i += 2;
834
+ continue;
835
+ }
836
+ out += '\\' + e; // unrecognised: the backslash stays
837
+ i++;
838
+ }
839
+ return { text: out, inexpressible };
840
+ }
841
+ /**
842
+ * The subset that still expands INSIDE double quotes: parameter and command substitution, nothing
843
+ * else. Globbing is filename expansion, and the shell does not perform it on a quoted word.
844
+ *
845
+ * MEASURED in a throwaway directory (bash 5.1.16): with a file literally named `*` inside the
846
+ * store, `<verb> -rf ".dzprobe/*"` deleted THAT file and left the rest of the directory alone; with
847
+ * both `a?.db` and `ab.db` present, `<verb> ".dzprobe/a?.db"` deleted `a?.db` and left `ab.db`. So
848
+ * the operand is a LITERAL path and the form IS the fact. Applying the unquoted test inside quotes
849
+ * marked it undecidable, and a literal path into a protected store was skipped (cross-family
850
+ * review, gpt-5.6-sol, round 4; F21). The unquoted direction does not move — F21-guard.
851
+ */
852
+ const DQ_DYNAMIC_CHARS = /[`]/;
853
+ const SPAN_COMMAND_PREFIX_WORDS = new Set([
854
+ 'if', 'then', 'elif', 'else', 'do', 'while', 'until', 'for', 'select', '!', 'time', 'coproc',
855
+ ]);
856
+ function newCommandSpanFrame() {
857
+ return {
858
+ kind: 'command',
859
+ parenDepth: 1,
860
+ atCommandPosition: true,
861
+ word: '',
862
+ wordBare: true,
863
+ wordStarted: false,
864
+ cases: [],
865
+ pendingHeredocs: [],
866
+ };
867
+ }
868
+ function markSpanWordOpaque(frame) {
869
+ frame.wordStarted = true;
870
+ frame.wordBare = false;
871
+ }
872
+ /** Finish one bare word only far enough to distinguish Bash grammar from literal `)` bytes. */
873
+ function finishSpanWord(frame) {
874
+ if (!frame.wordStarted)
875
+ return;
876
+ const token = frame.wordBare ? frame.word : '';
877
+ const activeCase = frame.cases.at(-1);
878
+ if (token === 'esac' && frame.atCommandPosition && activeCase !== undefined
879
+ && activeCase.phase !== 'await-in') {
880
+ frame.cases.pop();
881
+ frame.atCommandPosition = false;
882
+ }
883
+ else if (token === 'in' && activeCase?.phase === 'await-in') {
884
+ activeCase.phase = 'patterns';
885
+ frame.atCommandPosition = true;
886
+ }
887
+ else if (token === 'case' && frame.atCommandPosition) {
888
+ frame.cases.push({ phase: 'await-in', patternParenDepth: frame.parenDepth });
889
+ frame.atCommandPosition = false;
890
+ }
891
+ else if (activeCase?.phase !== 'patterns') {
892
+ frame.atCommandPosition = SPAN_COMMAND_PREFIX_WORDS.has(token);
893
+ }
894
+ frame.word = '';
895
+ frame.wordBare = true;
896
+ frame.wordStarted = false;
897
+ }
898
+ /** Read and dequote the one shell word naming a heredoc terminator. */
899
+ function readSpanHeredoc(command, operator) {
900
+ let j = operator + 2;
901
+ let stripTabs = false;
902
+ if (command.charAt(j) === '-') {
903
+ stripTabs = true;
904
+ j++;
905
+ }
906
+ while (command.charAt(j) === ' ' || command.charAt(j) === '\t')
907
+ j++;
908
+ let delim = '';
909
+ let started = false;
910
+ while (j < command.length) {
911
+ const c = command.charAt(j);
912
+ if (/\s/.test(c) || ';&|()<>'.includes(c))
913
+ break;
914
+ if (c === '\\') {
915
+ const next = command.charAt(j + 1);
916
+ if (next === '')
917
+ return null;
918
+ delim += next;
919
+ started = true;
920
+ j += 2;
921
+ continue;
922
+ }
923
+ if (c === '$' && command.charAt(j + 1) === "'") {
924
+ const end = ansiCEnd(command, j + 1);
925
+ if (end === -1)
926
+ return null;
927
+ delim += decodeAnsiC(command.slice(j + 2, end)).text;
928
+ started = true;
929
+ j = end + 1;
930
+ continue;
931
+ }
932
+ if (c === "'" || c === '"') {
933
+ let end = j + 1;
934
+ let text = '';
935
+ while (end < command.length && command.charAt(end) !== c) {
936
+ if (c === '"' && command.charAt(end) === '\\' && end + 1 < command.length) {
937
+ text += command.charAt(end + 1);
938
+ end += 2;
939
+ }
940
+ else {
941
+ text += command.charAt(end);
942
+ end++;
943
+ }
944
+ }
945
+ if (end >= command.length)
946
+ return null;
947
+ delim += text;
948
+ started = true;
949
+ j = end + 1;
950
+ continue;
951
+ }
952
+ delim += c;
953
+ started = true;
954
+ j++;
955
+ }
956
+ return started ? { delim, stripTabs, next: j } : null;
957
+ }
958
+ /**
959
+ * Find a double-quote or command-substitution boundary with one EXPLICIT stack.
960
+ *
961
+ * R20 made the two readers mutually recursive. That fixed one nesting level but made call-stack
962
+ * depth attacker-controlled: enough alternating `"$(` frames threw before the trailing literal
963
+ * deletion was read. This machine has no input-shaped calls; every byte advances `j` or pushes/
964
+ * pops a heap frame.
965
+ *
966
+ * A `$()` body is shell grammar, not merely balanced punctuation. In particular, the `)` after a
967
+ * case pattern is a branch delimiter. Command frames therefore keep the small amount of lexical
968
+ * state needed to assign every bare `)` its grammatical role; quoted, parameter, arithmetic,
969
+ * process-substitution and nested-command regions are separate frames rather than new recursion,
970
+ * and heredoc bodies are skipped as data before any of their bytes can acquire syntax.
971
+ */
972
+ function shellSpanEnd(command, open, initial) {
973
+ const stack = [initial === 'command' ? newCommandSpanFrame() : { kind: 'double-quote' }];
974
+ let j = open + 1;
975
+ while (j < command.length) {
976
+ const frame = stack.at(-1);
977
+ const c = command.charAt(j);
978
+ if (frame.kind === 'double-quote') {
979
+ if (c === '\\') {
980
+ j += Math.min(2, command.length - j);
981
+ continue;
982
+ }
983
+ if (c === '$' && command.charAt(j + 1) === '(' && command.charAt(j + 2) === '(') {
984
+ stack.push({ kind: 'arithmetic', parenDepth: 2 });
985
+ j += 3;
986
+ continue;
987
+ }
988
+ if (c === '$' && command.charAt(j + 1) === '(') {
989
+ stack.push(newCommandSpanFrame());
990
+ j += 2;
991
+ continue;
992
+ }
993
+ if (c === '$' && command.charAt(j + 1) === '{') {
994
+ stack.push({ kind: 'parameter', braceDepth: 1 });
995
+ j += 2;
996
+ continue;
997
+ }
998
+ if (c === '`') {
999
+ const end = backtickEnd(command, j);
1000
+ if (end === -1)
1001
+ return -1;
1002
+ j = end + 1;
1003
+ continue;
1004
+ }
1005
+ if (c === '"') {
1006
+ stack.pop();
1007
+ if (stack.length === 0)
1008
+ return j;
1009
+ j++;
1010
+ continue;
1011
+ }
1012
+ j++;
1013
+ continue;
1014
+ }
1015
+ if (frame.kind === 'parameter') {
1016
+ if (c === '\\') {
1017
+ j += Math.min(2, command.length - j);
1018
+ continue;
1019
+ }
1020
+ if (c === '$' && command.charAt(j + 1) === '(' && command.charAt(j + 2) === '(') {
1021
+ stack.push({ kind: 'arithmetic', parenDepth: 2 });
1022
+ j += 3;
1023
+ continue;
1024
+ }
1025
+ if (c === '$' && command.charAt(j + 1) === '(') {
1026
+ stack.push(newCommandSpanFrame());
1027
+ j += 2;
1028
+ continue;
1029
+ }
1030
+ if (c === '$' && command.charAt(j + 1) === '{') {
1031
+ frame.braceDepth++;
1032
+ j += 2;
1033
+ continue;
1034
+ }
1035
+ if (c === '"') {
1036
+ stack.push({ kind: 'double-quote' });
1037
+ j++;
1038
+ continue;
1039
+ }
1040
+ if (c === "'") {
1041
+ const end = singleQuotedEnd(command, j);
1042
+ if (end === -1)
1043
+ return -1;
1044
+ j = end + 1;
1045
+ continue;
1046
+ }
1047
+ if (c === '}') {
1048
+ frame.braceDepth--;
1049
+ if (frame.braceDepth === 0)
1050
+ stack.pop();
1051
+ }
1052
+ j++;
1053
+ continue;
1054
+ }
1055
+ if (frame.kind === 'arithmetic') {
1056
+ if (c === '\\') {
1057
+ j += Math.min(2, command.length - j);
1058
+ continue;
1059
+ }
1060
+ if (c === '$' && command.charAt(j + 1) === '(' && command.charAt(j + 2) !== '(') {
1061
+ stack.push(newCommandSpanFrame());
1062
+ j += 2;
1063
+ continue;
1064
+ }
1065
+ if (c === '$' && command.charAt(j + 1) === '{') {
1066
+ stack.push({ kind: 'parameter', braceDepth: 1 });
1067
+ j += 2;
1068
+ continue;
1069
+ }
1070
+ if (c === '(')
1071
+ frame.parenDepth++;
1072
+ if (c === ')') {
1073
+ frame.parenDepth--;
1074
+ if (frame.parenDepth === 0)
1075
+ stack.pop();
1076
+ }
1077
+ j++;
1078
+ continue;
1079
+ }
1080
+ // From here the frame is a command-substitution body.
1081
+ if (c === '\\') {
1082
+ markSpanWordOpaque(frame);
1083
+ j += Math.min(2, command.length - j);
1084
+ continue;
1085
+ }
1086
+ if (c === '$' && command.charAt(j + 1) === "'") {
1087
+ markSpanWordOpaque(frame);
1088
+ const end = ansiCEnd(command, j + 1);
1089
+ if (end === -1)
1090
+ return -1;
1091
+ j = end + 1;
1092
+ continue;
1093
+ }
1094
+ if (c === '$' && command.charAt(j + 1) === '(' && command.charAt(j + 2) === '(') {
1095
+ markSpanWordOpaque(frame);
1096
+ stack.push({ kind: 'arithmetic', parenDepth: 2 });
1097
+ j += 3;
1098
+ continue;
1099
+ }
1100
+ if (c === '$' && command.charAt(j + 1) === '(') {
1101
+ markSpanWordOpaque(frame);
1102
+ stack.push(newCommandSpanFrame());
1103
+ j += 2;
1104
+ continue;
1105
+ }
1106
+ if (c === '$' && command.charAt(j + 1) === '{') {
1107
+ markSpanWordOpaque(frame);
1108
+ stack.push({ kind: 'parameter', braceDepth: 1 });
1109
+ j += 2;
1110
+ continue;
1111
+ }
1112
+ if (c === '<' && command.charAt(j + 1) === '<' && command.charAt(j + 2) !== '<') {
1113
+ finishSpanWord(frame);
1114
+ const heredoc = readSpanHeredoc(command, j);
1115
+ if (heredoc === null)
1116
+ return -1;
1117
+ frame.pendingHeredocs.push({ delim: heredoc.delim, stripTabs: heredoc.stripTabs });
1118
+ j = heredoc.next;
1119
+ continue;
1120
+ }
1121
+ if ((c === '<' || c === '>') && command.charAt(j + 1) === '(') {
1122
+ markSpanWordOpaque(frame);
1123
+ stack.push(newCommandSpanFrame());
1124
+ j += 2;
1125
+ continue;
1126
+ }
1127
+ if (c === '`') {
1128
+ markSpanWordOpaque(frame);
1129
+ const end = backtickEnd(command, j);
1130
+ if (end === -1)
1131
+ return -1;
1132
+ j = end + 1;
1133
+ continue;
1134
+ }
1135
+ if (c === "'") {
1136
+ markSpanWordOpaque(frame);
1137
+ const end = singleQuotedEnd(command, j);
1138
+ if (end === -1)
1139
+ return -1;
1140
+ j = end + 1;
1141
+ continue;
1142
+ }
1143
+ if (c === '"') {
1144
+ markSpanWordOpaque(frame);
1145
+ stack.push({ kind: 'double-quote' });
1146
+ j++;
1147
+ continue;
1148
+ }
1149
+ if (c === '#' && !frame.wordStarted) {
1150
+ const newline = command.indexOf('\n', j + 1);
1151
+ j = newline === -1 ? command.length : newline;
1152
+ continue;
1153
+ }
1154
+ if (c === '\n') {
1155
+ finishSpanWord(frame);
1156
+ j++;
1157
+ for (const heredoc of frame.pendingHeredocs) {
1158
+ let terminated = false;
1159
+ while (j <= command.length) {
1160
+ const newline = command.indexOf('\n', j);
1161
+ const lineEnd = newline === -1 ? command.length : newline;
1162
+ const line = command.slice(j, lineEnd);
1163
+ j = newline === -1 ? command.length + 1 : newline + 1;
1164
+ const comparable = heredoc.stripTabs ? line.replace(/^\t+/, '') : line;
1165
+ if (comparable === heredoc.delim) {
1166
+ terminated = true;
1167
+ break;
1168
+ }
1169
+ if (newline === -1)
1170
+ break;
1171
+ }
1172
+ if (!terminated)
1173
+ return -1;
1174
+ }
1175
+ frame.pendingHeredocs.length = 0;
1176
+ frame.atCommandPosition = true;
1177
+ continue;
1178
+ }
1179
+ if (/\s/.test(c)) {
1180
+ finishSpanWord(frame);
1181
+ j++;
1182
+ continue;
1183
+ }
1184
+ if (c === ';' || c === '&' || c === '|') {
1185
+ finishSpanWord(frame);
1186
+ const activeCase = frame.cases.at(-1);
1187
+ const caseEndLength = c === ';' && command.charAt(j + 1) === ';'
1188
+ ? (command.charAt(j + 2) === '&' ? 3 : 2)
1189
+ : c === ';' && command.charAt(j + 1) === '&' ? 2 : 0;
1190
+ if (caseEndLength > 0 && activeCase?.phase === 'body')
1191
+ activeCase.phase = 'patterns';
1192
+ frame.atCommandPosition = true;
1193
+ j += Math.max(caseEndLength, command.charAt(j + 1) === c ? 2 : 1);
1194
+ continue;
1195
+ }
1196
+ if (c === '(') {
1197
+ finishSpanWord(frame);
1198
+ frame.parenDepth++;
1199
+ frame.atCommandPosition = true;
1200
+ j++;
1201
+ continue;
1202
+ }
1203
+ if (c === ')') {
1204
+ finishSpanWord(frame);
1205
+ const activeCase = frame.cases.at(-1);
1206
+ if (activeCase?.phase === 'patterns' && frame.parenDepth === activeCase.patternParenDepth) {
1207
+ activeCase.phase = 'body';
1208
+ frame.atCommandPosition = true;
1209
+ j++;
1210
+ continue;
1211
+ }
1212
+ frame.parenDepth--;
1213
+ if (frame.parenDepth === 0) {
1214
+ stack.pop();
1215
+ if (stack.length === 0)
1216
+ return j;
1217
+ }
1218
+ j++;
1219
+ continue;
1220
+ }
1221
+ frame.wordStarted = true;
1222
+ frame.word += c;
1223
+ j++;
1224
+ }
1225
+ return -1;
1226
+ }
1227
+ /** Closing ordinary double quote, respecting iterative nested shell spans. */
1228
+ function doubleQuotedEnd(command, open) {
1229
+ return shellSpanEnd(command, open, 'double-quote');
1230
+ }
1231
+ /** Closing `)` for a `$(` opened at `open`, respecting iterative nested shell spans. */
1232
+ function commandSubstitutionEnd(command, open) {
1233
+ return shellSpanEnd(command, open, 'command');
1234
+ }
1235
+ function lex(command) {
1236
+ const n = command.length;
1237
+ const lexemes = [];
1238
+ const pending = [];
1239
+ let buf = '';
1240
+ /** One character per character of `buf` — see Word.literalMask. */
1241
+ let mask = '';
1242
+ let hasWord = false;
1243
+ let dynamic = false;
1244
+ /** Append text the shell will NOT read as syntax, because it was quoted or escaped. */
1245
+ const addQuoted = (text) => { buf += text; mask += '1'.repeat(text.length); };
1246
+ /** Append text the shell reads as bare syntax. */
1247
+ const addBare = (text) => { buf += text; mask += '0'.repeat(text.length); };
1248
+ /**
1249
+ * A span the shell EXPANDS but that contributes no text of its own — `$(…)` and a backtick.
1250
+ *
1251
+ * It still has to occupy a POSITION, or the mask cannot say which brace alternative was dynamic:
1252
+ * MEASURED, `{.dzprobe,$(echo x)}` and `{.dzprobe,\`echo x\`}` both delete the store, so the
1253
+ * literal alternative must be refused while the substitution alternative is ignored. The
1254
+ * placeholder is a private-use character, and any expansion containing it is dynamic by
1255
+ * construction, so it can never reach a reported path.
1256
+ */
1257
+ const addExpandedSpan = () => { buf += DYNAMIC_SPAN_MARK; mask += 'd'; };
1258
+ let i = 0;
1259
+ const fail = (why) => ({ lexemes: [], failure: why, prefixRuns: false });
1260
+ /** The shell warns and runs what it already parsed — see LexResult.prefixRuns. */
1261
+ const failKeepingPrefix = (why) => ({ lexemes, failure: why, prefixRuns: true });
1262
+ /**
1263
+ * A numeric file descriptor written against a redirection operator (`2>`, `3<<EOF`) belongs to
1264
+ * the OPERATOR, not to the preceding word — the shell never passes it to the program.
1265
+ *
1266
+ * The ordinary-redirection branch had this; the HEREDOC branch did not, so `3<<EOF <deletion>`
1267
+ * flushed `3` as the segment's first word, the head became the unknown command `3`, and an
1268
+ * unknown head STOPS the search — the deletion the shell performs was never examined
1269
+ * (cross-family review, gpt-5.6-sol, round 3). Only an ALL-DIGIT word is dropped: dropping any
1270
+ * word before the operator would erase a live operand, e.g. `rm -rf .dz<<EOF` (F13-guard).
1271
+ */
1272
+ const dropAttachedDescriptor = () => {
1273
+ if (hasWord && /^\d+$/.test(buf)) {
1274
+ buf = '';
1275
+ mask = '';
1276
+ hasWord = false;
1277
+ dynamic = false;
1278
+ }
1279
+ };
1280
+ const flush = () => {
1281
+ if (!hasWord)
1282
+ return;
1283
+ lexemes.push({ kind: 'word', text: buf, dynamic, literalMask: mask });
1284
+ buf = '';
1285
+ mask = '';
1286
+ hasWord = false;
1287
+ dynamic = false;
1288
+ };
1289
+ while (i < n) {
1290
+ const c = command.charAt(i);
1291
+ // A heredoc body is swallowed at the newline that opens it — it is data, not command.
1292
+ if (c === '\n' && pending.length > 0) {
1293
+ flush();
1294
+ lexemes.push({ kind: 'sep', ch: '\n' });
1295
+ i++;
1296
+ for (const h of pending) {
1297
+ let terminated = false;
1298
+ while (i < n) {
1299
+ const nl = command.indexOf('\n', i);
1300
+ const lineEnd = nl === -1 ? n : nl;
1301
+ const line = command.slice(i, lineEnd);
1302
+ i = nl === -1 ? n : nl + 1;
1303
+ // EXACT, not trimmed. The shell closes a heredoc only on a line that IS the delimiter:
1304
+ // `EOF ` is data, not the end of the block. Comparing with `trimEnd()` closed the block
1305
+ // one line early and the lines that are still DATA were then classified as commands — a
1306
+ // refusal on a deletion the shell never performs (cross-family review, gpt-5.6-sol,
1307
+ // round 2). `<<-` strips LEADING TABS and nothing else, which is the only relaxation the
1308
+ // shell itself allows.
1309
+ const cmp = h.stripTabs ? line.replace(/^\t+/, '') : line;
1310
+ if (cmp === h.delim) {
1311
+ terminated = true;
1312
+ break;
1313
+ }
1314
+ }
1315
+ if (!terminated)
1316
+ return failKeepingPrefix(`вставной блок не закрыт меткой ${h.delim}`);
1317
+ }
1318
+ pending.length = 0;
1319
+ continue;
1320
+ }
1321
+ // A backslash makes the NEXT character literal and is then REMOVED by the shell, so the guard
1322
+ // removes it too: `.d\z` is the file `.dz`. The first version erased both characters, which
1323
+ // left `.d` — a path that matches nothing (cross-family review, gpt-5.6-sol). An escaped
1324
+ // metacharacter stays a plain character: not a separator, not a quote, and not a glob, which
1325
+ // is why nothing here sets `dynamic`.
1326
+ if (c === '\\') {
1327
+ if (i + 1 >= n) {
1328
+ addQuoted('\\');
1329
+ hasWord = true;
1330
+ i++;
1331
+ continue;
1332
+ }
1333
+ const e = command.charAt(i + 1);
1334
+ if (e === '\n') {
1335
+ i += 2;
1336
+ continue;
1337
+ } // line continuation: the shell drops both
1338
+ addQuoted(e);
1339
+ hasWord = true;
1340
+ i += 2;
1341
+ continue;
1342
+ }
1343
+ // Inside SINGLE quotes the shell expands NOTHING: `$`, a backtick and a glob are ordinary
1344
+ // characters of the file name, so `rm -rf '.agentic-qe/$x'` deletes a literal path. Applying
1345
+ // the dynamic-character test to this body marked a perfectly decidable word undecidable and the
1346
+ // guard skipped it — the form WAS the fact and we chose not to look (cross-family review,
1347
+ // gpt-5.6-sol, round 2). Nothing here sets `dynamic`, for exactly the same reason the backslash
1348
+ // branch does not.
1349
+ if (c === "'") {
1350
+ const end = singleQuotedEnd(command, i);
1351
+ if (end === -1)
1352
+ return fail('незакрытая одинарная кавычка');
1353
+ addQuoted(command.slice(i + 1, end));
1354
+ hasWord = true;
1355
+ i = end + 1;
1356
+ continue;
1357
+ }
1358
+ if (c === '"') {
1359
+ let j = i + 1;
1360
+ let closed = false;
1361
+ let acc = '';
1362
+ let accMask = '';
1363
+ const keep = (text, kind) => { acc += text; accMask += kind.repeat(text.length); };
1364
+ while (j < n) {
1365
+ const d = command.charAt(j);
1366
+ if (d === '\\') {
1367
+ if (j + 1 >= n) {
1368
+ keep('\\', '1');
1369
+ j++;
1370
+ continue;
1371
+ }
1372
+ const e = command.charAt(j + 1);
1373
+ if (e === '"' || e === '\\' || e === '$' || e === '`') {
1374
+ keep(e, '1');
1375
+ j += 2;
1376
+ continue;
1377
+ }
1378
+ if (e === '\n') {
1379
+ j += 2;
1380
+ continue;
1381
+ }
1382
+ keep('\\' + e, '1');
1383
+ j += 2;
1384
+ continue;
1385
+ }
1386
+ // `$()` remains active inside double quotes, but every byte of its BODY belongs to the
1387
+ // nested shell text. In particular, a `"` quoted inside that body cannot close THIS word.
1388
+ // Preserve the whole expansion as one dynamic position and let the shared balanced reader
1389
+ // skip it. Backticks are the same shell feature in the legacy spelling.
1390
+ if (d === '$' && command.charAt(j + 1) === '(') {
1391
+ const end = commandSubstitutionEnd(command, j + 1);
1392
+ if (end === -1)
1393
+ return fail('незакрытая подстановка команды $( в двойных кавычках');
1394
+ dynamic = true;
1395
+ keep(DYNAMIC_SPAN_MARK, 'd');
1396
+ j = end + 1;
1397
+ continue;
1398
+ }
1399
+ if (d === '`') {
1400
+ const end = backtickEnd(command, j);
1401
+ if (end === -1)
1402
+ return fail('незакрытая обратная кавычка в двойных кавычках');
1403
+ dynamic = true;
1404
+ keep(DYNAMIC_SPAN_MARK, 'd');
1405
+ j = end + 1;
1406
+ continue;
1407
+ }
1408
+ if (d === '"') {
1409
+ closed = true;
1410
+ break;
1411
+ }
1412
+ // Only `$` and a backtick still expand inside double quotes — a glob does not. See
1413
+ // DQ_DYNAMIC_CHARS for the measurement that separates the two.
1414
+ if (DQ_DYNAMIC_CHARS.test(d) || (d === '$' && dollarExpands(command, j, true))) {
1415
+ dynamic = true;
1416
+ keep(d, 'd');
1417
+ }
1418
+ else
1419
+ keep(d, '1');
1420
+ j++;
1421
+ }
1422
+ if (!closed)
1423
+ return fail('незакрытая двойная кавычка');
1424
+ buf += acc;
1425
+ mask += accMask;
1426
+ hasWord = true;
1427
+ i = j + 1;
1428
+ continue;
1429
+ }
1430
+ // `$(…)` is a command substitution — the SAME shell feature as a backtick in its modern
1431
+ // spelling, and the same deliberate refusal to read it (ADR-001, option C). It is a dynamic
1432
+ // SPAN INSIDE the word, not a separator. Reading its two parentheses as unconditional
1433
+ // separators STARTED a new segment headed by the word that followed, so `echo $(pwd) <deletion
1434
+ // words>` — which the shell hands to `echo`, deleting nothing — was REFUSED (cross-family
1435
+ // review, gpt-5.6-sol, round 3). Bare parentheses stay in SEPARATORS: `( <deletion> )` really
1436
+ // is a subshell, and if they stopped separating, the head of the group would become the
1437
+ // parenthesis itself and the deletion behind it would read as an unknown executable.
1438
+ //
1439
+ // The OUTCOME is the backtick's outcome, deliberately: a `$(…)` target is `allow` (the target
1440
+ // is resolved by the shell — the guard's first printed limit) and an UNCLOSED one is
1441
+ // `undecidable`. Two spellings of one shell feature may not decide differently.
1442
+ // `$'…'` is ANSI-C QUOTING — the shell resolves it and passes a literal word, so it is decoded
1443
+ // here rather than falling into the dynamic path below. See decodeAnsiC for the measurement.
1444
+ if (c === '$' && command.charAt(i + 1) === "'") {
1445
+ const end = ansiCEnd(command, i + 1);
1446
+ if (end === -1)
1447
+ return fail("незакрытая кавычка ANSI-C $'");
1448
+ const ansiC = decodeAnsiC(command.slice(i + 2, end));
1449
+ addQuoted(ansiC.text);
1450
+ if (ansiC.inexpressible) {
1451
+ dynamic = true;
1452
+ addExpandedSpan();
1453
+ }
1454
+ hasWord = true;
1455
+ i = end + 1;
1456
+ continue;
1457
+ }
1458
+ if (c === '$' && command.charAt(i + 1) === '(') {
1459
+ const end = commandSubstitutionEnd(command, i + 1);
1460
+ if (end === -1)
1461
+ return fail('незакрытая подстановка команды $(');
1462
+ addExpandedSpan();
1463
+ hasWord = true;
1464
+ dynamic = true;
1465
+ i = end + 1;
1466
+ continue;
1467
+ }
1468
+ // A command substitution is a command we deliberately DO NOT read (ADR-001, option C).
1469
+ if (c === '`') {
1470
+ const j = backtickEnd(command, i);
1471
+ if (j === -1)
1472
+ return fail('незакрытая обратная кавычка');
1473
+ addExpandedSpan();
1474
+ hasWord = true;
1475
+ dynamic = true;
1476
+ i = j + 1;
1477
+ continue;
1478
+ }
1479
+ // `#` starts a comment only at the start of a word, so `http://x#y` is left alone.
1480
+ if (c === '#' && !hasWord) {
1481
+ const nl = command.indexOf('\n', i);
1482
+ i = nl === -1 ? n : nl;
1483
+ continue;
1484
+ }
1485
+ // Heredoc: `<<WORD`, `<<-WORD`, `<<'WORD'`. `<<<` is a here-STRING, not a heredoc.
1486
+ if (c === '<' && command.charAt(i + 1) === '<' && command.charAt(i + 2) !== '<') {
1487
+ let j = i + 2;
1488
+ let stripTabs = false;
1489
+ if (command.charAt(j) === '-') {
1490
+ stripTabs = true;
1491
+ j++;
1492
+ }
1493
+ while (j < n && (command.charAt(j) === ' ' || command.charAt(j) === '\t'))
1494
+ j++;
1495
+ // The delimiter is one WORD, and quoting is the SHELL's syntax inside it, not part of the
1496
+ // label: `<<E"OF"` is dequoted to `EOF`, exactly like `<<"EOF"` and `<<E\\OF`. Reading only up
1497
+ // to the first quote recorded `E`, so the block closed at the first `E` line and the lines
1498
+ // that are still DATA were classified as commands — a refusal on a deletion the shell never
1499
+ // performs (cross-family review, gpt-5.6-sol, round 5). MEASURED: `cat <<E"OF"` with a body
1500
+ // of `E` then `<verb> -rf .dzprobe`, closed by `EOF`, printed BOTH lines and left the target
1501
+ // ALIVE. Whether the delimiter was quoted decides expansion INSIDE the body, which this guard
1502
+ // never reads, so only the label text matters here.
1503
+ let delim = '';
1504
+ let sawLabel = false;
1505
+ while (j < n) {
1506
+ const d = command.charAt(j);
1507
+ if (d === "'" || d === '"') {
1508
+ const end = command.indexOf(d, j + 1);
1509
+ if (end === -1)
1510
+ return fail('незакрытая кавычка в метке вставного блока');
1511
+ delim += command.slice(j + 1, end);
1512
+ sawLabel = true;
1513
+ j = end + 1;
1514
+ continue;
1515
+ }
1516
+ // `$'…'` is a quoting form here too — MEASURED: `cat <<$'EOF'` with a plain `EOF`
1517
+ // terminator prints the body and runs on, so bash dequotes the delimiter to `EOF`. Keeping
1518
+ // the `$` recorded `$EOF`, the block never closed, the whole command failed to lex and the
1519
+ // hook failed open (cross-family review, gpt-5.6-sol, round 9).
1520
+ if (d === '$' && command.charAt(j + 1) === "'") {
1521
+ const close = ansiCEnd(command, j + 1);
1522
+ if (close === -1)
1523
+ return fail("незакрытая кавычка ANSI-C $' в метке вставного блока");
1524
+ delim += decodeAnsiC(command.slice(j + 2, close)).text;
1525
+ sawLabel = true;
1526
+ j = close + 1;
1527
+ continue;
1528
+ }
1529
+ if (d === '\\') {
1530
+ if (j + 1 >= n)
1531
+ break;
1532
+ delim += command.charAt(j + 1);
1533
+ sawLabel = true;
1534
+ j += 2;
1535
+ continue;
1536
+ }
1537
+ if (/\s/.test(d) || DELIMITER_ENDS.has(d))
1538
+ break;
1539
+ delim += d;
1540
+ sawLabel = true;
1541
+ j++;
1542
+ }
1543
+ if (!sawLabel)
1544
+ return fail('вставной блок без метки');
1545
+ dropAttachedDescriptor();
1546
+ flush();
1547
+ i = j;
1548
+ pending.push({ delim, stripTabs });
1549
+ continue;
1550
+ }
1551
+ // A redirection is an OPERATOR, not text. Without this, a deletion with the redirection glued
1552
+ // to its operand arrives as ONE token whose first path segment is `.dz>` — which is not the
1553
+ // protected store, so the guard allowed it while the shell deleted the store (cross-family
1554
+ // review, gpt-5.6-sol). Must be tested BEFORE the separators, so `&>` is not read as `&`.
1555
+ if (c === '>' || c === '<' || (c === '&' && command.charAt(i + 1) === '>')) {
1556
+ // A leading file descriptor (`2>`) belongs to the operator, not to the previous word.
1557
+ dropAttachedDescriptor();
1558
+ flush();
1559
+ if (c === '&')
1560
+ i++;
1561
+ i++;
1562
+ while (command.charAt(i) === '>' || command.charAt(i) === '<')
1563
+ i++; // `>>`, `<<<`
1564
+ if (command.charAt(i) === '&')
1565
+ i++; // `>&`, `<&`
1566
+ lexemes.push({ kind: 'redirect' });
1567
+ continue;
1568
+ }
1569
+ // A brace separates only when it is a word of its own — see BRACE_ENDS_A_WORD.
1570
+ if (c === '{' || c === '}') {
1571
+ if (!hasWord && BRACE_ENDS_A_WORD.has(command.charAt(i + 1))) {
1572
+ flush();
1573
+ lexemes.push({ kind: 'sep', ch: c });
1574
+ i++;
1575
+ continue;
1576
+ }
1577
+ addBare(c);
1578
+ hasWord = true;
1579
+ i++;
1580
+ continue;
1581
+ }
1582
+ // Keep the identity of shell short-circuit operators. Treating both characters as unrelated
1583
+ // separators erased the one fact that can prove the right-hand command does not execute.
1584
+ if ((c === '&' || c === '|') && command.charAt(i + 1) === c) {
1585
+ flush();
1586
+ lexemes.push({ kind: 'sep', ch: c + c });
1587
+ i += 2;
1588
+ continue;
1589
+ }
1590
+ if (SEPARATORS.has(c)) {
1591
+ flush();
1592
+ lexemes.push({ kind: 'sep', ch: c });
1593
+ i++;
1594
+ continue;
1595
+ }
1596
+ if (/\s/.test(c)) {
1597
+ flush();
1598
+ i++;
1599
+ continue;
1600
+ }
1601
+ if (DYNAMIC_CHARS.test(c) || (c === '$' && dollarExpands(command, i))) {
1602
+ dynamic = true;
1603
+ buf += c;
1604
+ mask += 'd';
1605
+ }
1606
+ else
1607
+ addBare(c);
1608
+ hasWord = true;
1609
+ i++;
1610
+ }
1611
+ if (pending.length > 0) {
1612
+ flush();
1613
+ return failKeepingPrefix(`вставной блок не закрыт меткой ${pending[0]?.delim ?? ''}`);
1614
+ }
1615
+ flush();
1616
+ return { lexemes, failure: null, prefixRuns: false };
1617
+ }
1618
+ /**
1619
+ * Every word of one segment, with its brace groups expanded — the shell's own order, head included.
1620
+ *
1621
+ * MEASURED: bash expands braces in COMMAND position as well, and the FIRST expanded word becomes
1622
+ * the verb: `r{m,m} -rf x` arrives as `<rm><rm><-rf><x>` and `{rm,ls} -rf x` as
1623
+ * `<rm><ls><-rf><x>`, both deleting the target. Expanding only operands compared the head
1624
+ * unexpanded, an unknown head STOPS the search, and the deletion was allowed — a direct bypass
1625
+ * (cross-family review, gpt-5.6-sol, round 10). Doing it here, once, for the whole segment is also
1626
+ * what makes a MIXED group work: each expansion carries its OWN mask, so `{.dz,$OTHER}` becomes a
1627
+ * literal `.dz` and a dynamic `$OTHER`, exactly like `rm -rf .dz $OTHER`.
1628
+ *
1629
+ * null when the expansion is too large to enumerate — reported as "could not read", never as safe.
1630
+ */
1631
+ function expandSegment(words, expandsBraces) {
1632
+ const out = [];
1633
+ let overflowed = false;
1634
+ for (const word of words) {
1635
+ // A shell WITHOUT brace expansion passes the word through untouched — see SHELL_EXPANDS_BRACES.
1636
+ const parts = expandsBraces ? expandBraces(word.text, word.literalMask) : null;
1637
+ if (parts === null) {
1638
+ // Either this shell does not expand, or the expansion overflowed. In the OVERFLOW case only
1639
+ // THIS word is unreadable: MEASURED, ten adjacent `{a,b}` groups make exactly 1024 words and
1640
+ // `<verb> -rf {a,b}×10 .dzprobe` still deletes the store, so dropping the whole segment to
1641
+ // `undecidable` let the hooks pass it through (cross-family review, gpt-5.6-sol, round 12).
1642
+ // The word itself is marked dynamic so it can never produce a refusal of its own.
1643
+ if (expandsBraces) {
1644
+ overflowed = true;
1645
+ // `exactOptionalPropertyTypes` means an OPTIONAL property may be absent or a string, never
1646
+ // an explicit `undefined` — assigning `word.literalMask` straight through is TS2379 and it
1647
+ // fails `npm run typecheck`, i.e. it blocks the build and the publish (cross-family review,
1648
+ // gpt-5.6-sol, round 13). Round 13 shipped without running typecheck; vitest does not run it.
1649
+ out.push(word.literalMask === undefined
1650
+ ? { kind: 'word', text: word.text, dynamic: true }
1651
+ : { kind: 'word', text: word.text, dynamic: true, literalMask: word.literalMask });
1652
+ continue;
1653
+ }
1654
+ out.push(word);
1655
+ continue;
1656
+ }
1657
+ for (const part of parts) {
1658
+ out.push({
1659
+ kind: 'word',
1660
+ text: part.text,
1661
+ // Decided PER EXPANSION: only an expansion that still holds something the shell resolves
1662
+ // is undecidable by form. That is the whole point of the third mask state.
1663
+ dynamic: part.mask.includes('d'),
1664
+ literalMask: part.mask,
1665
+ });
1666
+ }
1667
+ }
1668
+ return { words: out, overflowed };
1669
+ }
1670
+ function splitSegmentsWithConnectors(lexemes) {
1671
+ const segments = [];
1672
+ let current = [];
1673
+ let connector = null;
1674
+ for (let k = 0; k < lexemes.length; k++) {
1675
+ const lx = lexemes[k];
1676
+ if (lx.kind === 'sep') {
1677
+ const hadWords = current.length > 0;
1678
+ if (hadWords)
1679
+ segments.push({ words: current, connector });
1680
+ current = [];
1681
+ // An opening grouping delimiter starts the right-hand command; it does not replace the
1682
+ // `&&`/`||` that decides whether that command is reachable. Consecutive separators used to
1683
+ // erase the short-circuit here (`false && (rm …)`, `true || { rm …; }`).
1684
+ if (!hadWords && (lx.ch === '(' || lx.ch === '{') && (connector === '&&' || connector === '||')) {
1685
+ continue;
1686
+ }
1687
+ connector = lx.ch;
1688
+ continue;
1689
+ }
1690
+ if (lx.kind === 'redirect') {
1691
+ // The word after an operator is the redirection TARGET. It is not an operand of the verb:
1692
+ // `> .dz/log` creates or truncates a file, it does not delete one, and a refusal that names
1693
+ // a rule the command does not break is the first step to being switched off (see R-flags).
1694
+ if (lexemes[k + 1]?.kind === 'word')
1695
+ k++;
1696
+ continue;
1697
+ }
1698
+ current.push(lx);
1699
+ }
1700
+ if (current.length > 0)
1701
+ segments.push({ words: current, connector });
1702
+ return segments;
1703
+ }
1704
+ /** Only the literal shell builtins requested by F68 are treated as statically decidable. */
1705
+ function literalExitStatus(words) {
1706
+ if (words.length !== 1 || words[0]?.dynamic)
1707
+ return 'unknown';
1708
+ if (words[0]?.text === 'true')
1709
+ return 'success';
1710
+ if (words[0]?.text === 'false')
1711
+ return 'failure';
1712
+ return 'unknown';
1713
+ }
1714
+ /**
1715
+ * Drop only branches whose non-execution follows from literal `true`/`false` status. Unknown
1716
+ * commands remain reachable in both directions, preserving the guard's prior fail-closed choice.
1717
+ */
1718
+ function reachableSegments(lexemes) {
1719
+ const reachable = [];
1720
+ let status = 'unknown';
1721
+ for (const segment of splitSegmentsWithConnectors(lexemes)) {
1722
+ const literal = literalExitStatus(segment.words);
1723
+ if (segment.connector === '&&') {
1724
+ if (status !== 'failure')
1725
+ reachable.push(segment.words);
1726
+ if (status === 'success')
1727
+ status = literal;
1728
+ else if (status === 'unknown')
1729
+ status = literal === 'failure' ? 'failure' : 'unknown';
1730
+ continue;
1731
+ }
1732
+ if (segment.connector === '||') {
1733
+ if (status !== 'success')
1734
+ reachable.push(segment.words);
1735
+ if (status === 'failure')
1736
+ status = literal;
1737
+ else if (status === 'unknown')
1738
+ status = literal === 'success' ? 'success' : 'unknown';
1739
+ continue;
1740
+ }
1741
+ reachable.push(segment.words);
1742
+ status = literal;
1743
+ }
1744
+ return reachable;
1745
+ }
1746
+ /** A name the shell may bind a function to. Deliberately narrower than bash allows. */
1747
+ const FUNCTION_NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
1748
+ /**
1749
+ * Split the stream into what the shell EXECUTES and the function bodies it merely BINDS.
1750
+ *
1751
+ * MEASURED with bash 5.1.16, each case in its own temp directory: `f() { <verb> -rf .dzprobe; }`,
1752
+ * `f() ( <verb> -rf .dzprobe )` and `function f { <verb> -rf .dzprobe; }` all exit 0 and leave the
1753
+ * target ALIVE, while the same line followed by `; f` leaves it GONE. A definition binds a name and
1754
+ * runs nothing. The classifier read the body as an ordinary segment and refused a deletion the
1755
+ * shell never performs — the false-refusal class this feature exists to avoid (cross-family review,
1756
+ * gpt-5.6-sol, round 4; F22).
1757
+ *
1758
+ * FAIL-CLOSED IN EVERY DOUBTFUL DIRECTION, because the opposite error is a silent miss on a live
1759
+ * deletion:
1760
+ * - a body whose opening brace or parenthesis is never matched is NOT a definition, so it stays in
1761
+ * the executed stream and is classified in full;
1762
+ * - a name that does not look like a function name is not a definition either;
1763
+ * - a lifted body is put BACK the moment its name appears as a word anywhere else in the command —
1764
+ * that criterion is deliberately blunter than "a segment head", so that a body reached through
1765
+ * another body (`f() { g; }; g() { <verb> …; }; f`) cannot slip out. It over-refuses on
1766
+ * `f() { … }; echo f`, which is the cheap direction of the error;
1767
+ * - a body that IS reinstated is classified as written, so a definition nested inside it counts as
1768
+ * executed. One level of leniency, never two.
1769
+ */
1770
+ function liftFunctionBodies(lexemes) {
1771
+ const main = [];
1772
+ const bodies = [];
1773
+ const sepAt = (k) => {
1774
+ const lx = lexemes[k];
1775
+ return lx !== undefined && lx.kind === 'sep' ? lx.ch : null;
1776
+ };
1777
+ /**
1778
+ * Step over NEWLINE separators only, which is what may stand between a function header and its
1779
+ * body. MEASURED with bash 5.1.16: `f()` on one line and `{ <verb> -rf .dzprobe; }` on the next
1780
+ * exited 0 and left the target ALIVE, as did a blank line between them, the `function f`
1781
+ * spelling, and a `( … )` body. A COMMAND separator is NOT part of a header — bash answers
1782
+ * `f() ; { … }` with `syntax error near unexpected token`, exit 2 — so nothing else is skipped:
1783
+ * lifting a body over a separator the shell itself rejects would widen the non-executed region
1784
+ * on input we cannot reason about.
1785
+ */
1786
+ const skipNewlines = (from) => {
1787
+ let j = from;
1788
+ while (sepAt(j) === '\n')
1789
+ j++;
1790
+ return j;
1791
+ };
1792
+ /** The index of the separator closing a body opened at `open`, or -1 when it never closes. */
1793
+ const bodyEnd = (open) => {
1794
+ const opener = sepAt(open);
1795
+ const closer = opener === '{' ? '}' : ')';
1796
+ if (opener === null)
1797
+ return -1;
1798
+ let depth = 0;
1799
+ for (let k = open; k < lexemes.length; k++) {
1800
+ const ch = sepAt(k);
1801
+ if (ch === opener)
1802
+ depth++;
1803
+ else if (ch === closer) {
1804
+ depth--;
1805
+ if (depth === 0)
1806
+ return k;
1807
+ }
1808
+ }
1809
+ return -1;
1810
+ };
1811
+ for (let k = 0; k < lexemes.length; k++) {
1812
+ const lx = lexemes[k];
1813
+ if (lx.kind === 'word') {
1814
+ // `name () {` … `}` — and `name () (` … `)`, which binds a subshell body.
1815
+ const isKeyword = lx.text === 'function';
1816
+ const nameWord = isKeyword ? lexemes[k + 1] : lx;
1817
+ if (nameWord !== undefined && nameWord.kind === 'word' && FUNCTION_NAME.test(nameWord.text)) {
1818
+ let cursor = k + (isKeyword ? 2 : 1);
1819
+ // `function name` may write the parentheses or omit them; `name` alone may not.
1820
+ if (sepAt(cursor) === '(' && sepAt(cursor + 1) === ')')
1821
+ cursor += 2;
1822
+ else if (!isKeyword) {
1823
+ main.push(lx);
1824
+ continue;
1825
+ }
1826
+ cursor = skipNewlines(cursor);
1827
+ const opener = sepAt(cursor);
1828
+ if (opener === '{' || opener === '(') {
1829
+ const end = bodyEnd(cursor);
1830
+ if (end !== -1) {
1831
+ bodies.push({ name: nameWord.text, lexemes: lexemes.slice(cursor + 1, end) });
1832
+ k = end;
1833
+ continue;
1834
+ }
1835
+ }
1836
+ }
1837
+ }
1838
+ main.push(lx);
1839
+ }
1840
+ return { main, bodies };
1841
+ }
1842
+ /**
1843
+ * A word bash reads as an ENVIRONMENT ASSIGNMENT in front of a command, not as the command.
1844
+ *
1845
+ * MEASURED with bash 5.1.16, each case in its own temp directory: `FOO+=x <verb> -rf .dzprobe`
1846
+ * exited 0 and left the target GONE, and so did `FOO+=`, `A=1 B=2`, `A=`, `_A=1` and `A1=1`. The
1847
+ * APPEND form was not accepted here, so `FOO+=x` read as an unknown executable, an unknown head
1848
+ * STOPS the search (see WRAPPERS), and the deletion behind it was allowed (cross-family review,
1849
+ * gpt-5.6-sol, round 6).
1850
+ *
1851
+ * The subscripted form is included because bash RUNS THE COMMAND ANYWAY: `FOO[0]=x <verb> -rf
1852
+ * .dzprobe` prints `bash: FOO[0]: not a valid identifier`, exits 0 and the target is GONE — the
1853
+ * assignment fails, the deletion does not.
1854
+ *
1855
+ * The pattern is no looser than that, because a pattern that swallowed a real command word would
1856
+ * move the head past it. MEASURED in the other direction: `1A=1 <verb> -rf .dzprobe` and
1857
+ * `A-B=1 <verb> -rf .dzprobe` both exit 127 with `command not found` and leave the target ALIVE —
1858
+ * bash reads those as the COMMAND — so they must stop the search, exactly as they already did.
1859
+ */
1860
+ const ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=/;
1861
+ /**
1862
+ * Wrappers that accept `VAR=value` in front of the command they run.
1863
+ *
1864
+ * MEASURED, one throwaway directory per wrapper, `<wrapper> FOO=bar <verb> -rf .dzprobe`:
1865
+ * - ACCEPTS (target GONE): `env`, `sudo`.
1866
+ * - REJECTS (target ALIVE, exit 125/127): `command` (`bash: FOO=bar: command not found`),
1867
+ * `builtin`, `nohup`, `setsid`, `nice`, `stdbuf`, `ionice`, `exec`, `xargs`, and the BINARY
1868
+ * `/usr/bin/time` (`cannot run FOO=bar`).
1869
+ * Skipping the word for every wrapper refused all of the second group even though the command
1870
+ * never reaches the verb (cross-family review, gpt-5.6-sol, round 13).
1871
+ *
1872
+ * `time` is on the ACCEPTING side anyway, and the reason is measured rather than assumed: the bare
1873
+ * word is a shell KEYWORD here (`type -t time` answers `keyword`), so `time FOO=bar <verb> -rf`
1874
+ * really does delete — the assignment is the SHELL's. A basename cannot tell the keyword from
1875
+ * `/usr/bin/time`, so the guard keeps the fail-closed reading of the two.
1876
+ *
1877
+ * `doas` is here for the same fail-closed reason and NOT because it was measured: it is not
1878
+ * installed on this machine, so its behaviour could not be run. Where the live answer is
1879
+ * unavailable the guard keeps the refusal rather than inventing a pass.
1880
+ */
1881
+ const WRAPPERS_TAKING_ASSIGNMENTS = new Set(['env', 'sudo', 'doas', 'time']);
1882
+ /**
1883
+ * Wrappers that are RESERVED WORDS of the shell rather than programs, so what follows them is still
1884
+ * shell syntax.
1885
+ *
1886
+ * MEASURED: `time ! <verb> -rf .dzprobe` exits 1 and the target is GONE — `time` is a keyword, the
1887
+ * pipeline behind it still reads `!` as negation, and the deletion runs; `time -p !` and
1888
+ * `time time !` behave the same. Treating `time` as an ordinary wrapper cleared command position,
1889
+ * `!` became an unknown executable and the search stopped (cross-family review, gpt-5.6-sol,
1890
+ * round 14).
1891
+ *
1892
+ * Only the syntactically BARE word is the keyword. A path, quote or escape is a discriminator —
1893
+ * MEASURED, `/usr/bin/time`, `\time`, `'time'`, and `t"ime"` followed by `! <verb> -rf .dzprobe`
1894
+ * all exit 127 and leave the target ALIVE, because the binary tries to execute `!`. The lexer has
1895
+ * already decoded quotes/escapes, so `Word.literalMask` is what preserves that distinction.
1896
+ */
1897
+ const SHELL_KEYWORD_WRAPPERS = new Set(['time']);
1898
+ const basename = (token) => {
1899
+ const parts = token.split('/');
1900
+ return parts[parts.length - 1] || token;
1901
+ };
1902
+ /**
1903
+ * The value of a COMMAND-STRING option, when `flag` is one — `null` when it is an ordinary flag.
1904
+ *
1905
+ * The short spelling is read through the SAME cluster walk as every other option, so `-iS 'str'`
1906
+ * and `-Sstr` are answered consistently with `-iu FOO` and `-uFOO`, and a cluster whose first
1907
+ * value-taking letter is an ORDINARY one (`-uS`, where `S` is `-u`'s value) is not mistaken for a
1908
+ * command string.
1909
+ */
1910
+ function commandStringValue(arity, cmd, flag) {
1911
+ if (flag.startsWith('--')) {
1912
+ const eq = flag.indexOf('=');
1913
+ const name = eq === -1 ? flag.slice(2) : flag.slice(2, eq);
1914
+ if (!cmd.long.has(name))
1915
+ return null;
1916
+ return { attached: eq === -1 ? null : flag.slice(eq + 1) };
1917
+ }
1918
+ const hit = shortClusterValue(arity.short, flag);
1919
+ if (hit === null || !cmd.short.includes(hit.letter))
1920
+ return null;
1921
+ return { attached: hit.attached };
1922
+ }
1923
+ function optionValueCommand(rule, flag) {
1924
+ for (const carrier of rule.optionValueCommands ?? []) {
1925
+ const value = commandStringValue(rule.options, carrier.options, flag);
1926
+ if (value !== null)
1927
+ return { carrier, attached: value.attached };
1928
+ }
1929
+ return null;
1930
+ }
1931
+ function resolveOptionValueCommand(rule, words, flagIndex) {
1932
+ const flagWord = words[flagIndex];
1933
+ const carried = optionValueCommand(rule, flagWord.text);
1934
+ if (carried === null)
1935
+ return null;
1936
+ const value = carried.attached !== null
1937
+ ? { kind: 'word', text: carried.attached, dynamic: flagWord.dynamic }
1938
+ : words[flagIndex + 1];
1939
+ if (value === undefined)
1940
+ return { kind: 'missing-value' };
1941
+ if (value.dynamic)
1942
+ return { kind: 'opaque' };
1943
+ if (carried.carrier.execution === 'shell')
1944
+ return { kind: 'shell', command: value };
1945
+ if (carried.carrier.split !== 'env')
1946
+ return { kind: 'invalid' };
1947
+ const split = splitStringWords(value.text);
1948
+ if (split === null)
1949
+ return { kind: 'invalid' };
1950
+ const rest = carried.carrier.appendRemaining === true
1951
+ ? words.slice(carried.attached !== null ? flagIndex + 1 : flagIndex + 2)
1952
+ : [];
1953
+ return { kind: 'argv', words: [...split, ...rest] };
1954
+ }
1955
+ /** True when a boolean option occurs before any value-taking short option swallows the suffix. */
1956
+ function hasOptionFlag(arity, sought, flag) {
1957
+ if (flag.startsWith('--')) {
1958
+ const eq = flag.indexOf('=');
1959
+ return sought.long.has(eq === -1 ? flag.slice(2) : flag.slice(2, eq));
1960
+ }
1961
+ for (let i = 1; i < flag.length; i++) {
1962
+ const letter = flag.charAt(i);
1963
+ if (sought.short.includes(letter))
1964
+ return true;
1965
+ if (arity.short.includes(letter))
1966
+ return false;
1967
+ }
1968
+ return false;
1969
+ }
1970
+ function argvCommandIndex(words, start, rule, executionOverride) {
1971
+ let optionsEnded = false;
1972
+ let remainingPositionals = rule.positionalsBeforeCommand ?? 0;
1973
+ let execution = executionOverride ?? rule.execution;
1974
+ for (let i = start; i < words.length; i++) {
1975
+ const t = words[i]?.text ?? '';
1976
+ if (!optionsEnded && t === '--') {
1977
+ optionsEnded = true;
1978
+ continue;
1979
+ }
1980
+ if (!optionsEnded && (t.startsWith('-') || t.startsWith('+')) && t.length > 1) {
1981
+ if (rule.shellModeOptions !== undefined && hasOptionFlag(rule.options, rule.shellModeOptions, t)) {
1982
+ execution = 'shell';
1983
+ }
1984
+ if (consumesNextWord(rule.options, t))
1985
+ i++;
1986
+ continue;
1987
+ }
1988
+ if (remainingPositionals > 0) {
1989
+ remainingPositionals--;
1990
+ continue;
1991
+ }
1992
+ return { index: i, execution };
1993
+ }
1994
+ return null;
1995
+ }
1996
+ /** A terminal option in the option prefix makes the carrier exit before any nested command. */
1997
+ function hasTerminalOptionBeforeFirstPositional(words, start, arity) {
1998
+ for (let i = start; i < words.length; i++) {
1999
+ const flag = words[i]?.text ?? '';
2000
+ if (flag === '--')
2001
+ return false;
2002
+ if (!(flag.startsWith('-') && flag.length > 1))
2003
+ return false;
2004
+ if (WRAPPER_TERMINAL_OPTIONS.has(flag))
2005
+ return true;
2006
+ if (consumesNextWord(arity, flag))
2007
+ i++;
2008
+ }
2009
+ return false;
2010
+ }
2011
+ /** The shell source pnpm constructs from the command tail in `--shell-mode`. */
2012
+ function shellCommandTail(words) {
2013
+ if (words.length === 0)
2014
+ return null;
2015
+ return {
2016
+ kind: 'word',
2017
+ text: words.map((word) => word.text).join(' '),
2018
+ dynamic: words.some((word) => word.dynamic),
2019
+ };
2020
+ }
2021
+ const ENV_CLEAR_OPTIONS = { short: 'i', long: new Set(['ignore-environment']) };
2022
+ const ENV_UNSET_OPTIONS = { short: 'u', long: new Set(['unset']) };
2023
+ function resolveInvocation(words, inheritedOptionMode = 'unknown') {
2024
+ let list = words;
2025
+ let i = 0;
2026
+ let rewrites = 0;
2027
+ let optionMode = inheritedOptionMode;
2028
+ /**
2029
+ * True while the resolver is still where the SHELL would accept a reserved word.
2030
+ *
2031
+ * MEASURED: once a wrapper or an assignment has taken command position, `!`, `if` and `then` are
2032
+ * ordinary program NAMES — `env '!' <verb> -rf .dzprobe` exits 127 with
2033
+ * `env: '!': No such file or directory` and the target is ALIVE, `sudo if …` answers
2034
+ * `sudo: if: command not found`, and `A=1 ! <verb> …` exits 127 — while at the head of a segment
2035
+ * they really are syntax and the deletion behind them HAPPENS (`! <verb> -rf .dzprobe` and
2036
+ * `! A=1 <verb> …` both left the target GONE). Skipping them unconditionally refused every one of
2037
+ * the first group (cross-family review, gpt-5.6-sol, round 11).
2038
+ */
2039
+ let atCommandPosition = true;
2040
+ /** The most recent wrapper that took command position — see WRAPPERS_TAKING_ASSIGNMENTS. */
2041
+ let lastWrapper = null;
2042
+ outer: while (i < list.length) {
2043
+ const t = list[i]?.text ?? '';
2044
+ // A leading shell assignment is always a prefix; after a WRAPPER has taken command position the
2045
+ // same shape is a program NAME unless that wrapper accepts assignments itself.
2046
+ // Before any wrapper, ANY number of leading assignments are a prefix (`A=1 B=2 <verb> …`
2047
+ // deletes — MEASURED round 7), so the test is the WRAPPER, not the command position: an
2048
+ // assignment ends control-word position but not assignment position.
2049
+ const assignmentAllowed = lastWrapper === null || WRAPPERS_TAKING_ASSIGNMENTS.has(lastWrapper);
2050
+ if (ASSIGNMENT_PREFIX.test(t) && assignmentAllowed) {
2051
+ if (/^POSIXLY_CORRECT(?:\+)?=/.test(t))
2052
+ optionMode = 'enabled';
2053
+ i++;
2054
+ atCommandPosition = false;
2055
+ continue;
2056
+ }
2057
+ if (atCommandPosition && CONTROL_WORDS.has(t)) {
2058
+ i++;
2059
+ continue;
2060
+ } // ! rm / then rm
2061
+ const name = basename(t);
2062
+ const strategy = COMMAND_WRAPPER_STRATEGIES.get(name);
2063
+ if (strategy?.kind === 'argv') {
2064
+ const arity = strategy.options;
2065
+ // A shell KEYWORD does not take command position away from what follows it — but only when
2066
+ // every character was syntactically bare. `\time`, `'time'`, `t"ime"`, a path spelling, and
2067
+ // a word produced by env -S all invoke the binary; text alone cannot distinguish them after
2068
+ // lexing, so the literal mask is load-bearing here.
2069
+ const isShellKeyword = SHELL_KEYWORD_WRAPPERS.has(name)
2070
+ && !t.includes('/')
2071
+ && (list[i]?.literalMask ?? '') === '0'.repeat(t.length);
2072
+ if (!isShellKeyword)
2073
+ atCommandPosition = false;
2074
+ lastWrapper = name;
2075
+ i++;
2076
+ while (i < list.length) {
2077
+ const flagWord = list[i];
2078
+ const flag = flagWord.text;
2079
+ if (flag === '--') {
2080
+ i++;
2081
+ break;
2082
+ }
2083
+ // A lone `-` is env's `-i`, and it ends the options — see LONE_DASH_WRAPPERS.
2084
+ if (flag === '-' && LONE_DASH_WRAPPERS.has(name)) {
2085
+ if (name === 'env')
2086
+ optionMode = 'disabled';
2087
+ i++;
2088
+ break;
2089
+ }
2090
+ if (!flag.startsWith('-') || flag.length < 2)
2091
+ break;
2092
+ if (name === 'env') {
2093
+ if (hasOptionFlag(ENV_WRAPPER_OPTIONS, ENV_CLEAR_OPTIONS, flag))
2094
+ optionMode = 'disabled';
2095
+ const unset = commandStringValue(ENV_WRAPPER_OPTIONS, ENV_UNSET_OPTIONS, flag);
2096
+ if (unset !== null) {
2097
+ const value = unset.attached ?? list[i + 1]?.text ?? '';
2098
+ if (value === 'POSIXLY_CORRECT')
2099
+ optionMode = 'disabled';
2100
+ }
2101
+ }
2102
+ // `--help` / `--version` make the WRAPPER print and exit, so the segment execs nothing.
2103
+ // Reached only when the spelling is in an OPTION slot: a previous iteration has already
2104
+ // stepped over any word that was some option's VALUE (`sudo -h --help <verb> …`).
2105
+ if (WRAPPER_TERMINAL_OPTIONS.has(flag))
2106
+ return { kind: 'other', name, args: [] };
2107
+ // A lookup option means the segment RUNS nothing — see INSPECTION_OPTIONS.
2108
+ const inspect = INSPECTION_OPTIONS.get(name);
2109
+ if (inspect !== undefined && isInspectionFlag(inspect, flag)) {
2110
+ return { kind: 'other', name, args: [] };
2111
+ }
2112
+ // An option whose VALUE IS THE COMMAND (`env -S '<verb> -rf .dz'`): the string is split into
2113
+ // words and the REST of the segment is appended to it, which is what makes
2114
+ // `env --split-string=<verb> -rf .dz` delete. Then the whole resolution restarts on the
2115
+ // rewritten word list, so the head is found by the ordinary rules.
2116
+ const carried = resolveOptionValueCommand(strategy, list, i);
2117
+ if (carried !== null) {
2118
+ if (carried.kind === 'missing-value')
2119
+ break;
2120
+ if (carried.kind === 'opaque') {
2121
+ return { kind: 'opaque', why: `«${name} ${flag}» получает команду, собранную оболочкой` };
2122
+ }
2123
+ if (rewrites >= MAX_COMMAND_STRING_REWRITES) {
2124
+ return { kind: 'opaque', why: `«${name} ${flag}» вложен глубже ${MAX_COMMAND_STRING_REWRITES} раз` };
2125
+ }
2126
+ if (carried.kind === 'shell') {
2127
+ return { kind: 'shell-command', name: `${name} ${flag}`, command: carried.command, optionMode };
2128
+ }
2129
+ if (carried.kind === 'invalid') {
2130
+ return { kind: 'opaque', why: `«${name} ${flag}» получает строку, которую env отвергает целиком — не исполняется ничего` };
2131
+ }
2132
+ rewrites++;
2133
+ list = carried.words;
2134
+ i = 0;
2135
+ continue outer;
2136
+ }
2137
+ i++;
2138
+ if (consumesNextWord(arity, flag))
2139
+ i++; // the value is NOT the command
2140
+ }
2141
+ continue;
2142
+ }
2143
+ if (strategy?.kind === 'subcommand') {
2144
+ if (strategy.options !== null
2145
+ && hasTerminalOptionBeforeFirstPositional(list, i + 1, strategy.options)) {
2146
+ return { kind: 'other', name, args: [] };
2147
+ }
2148
+ const subcommandLocation = strategy.options === null
2149
+ ? { index: i + 1, execution: 'argv' }
2150
+ : argvCommandIndex(list, i + 1, {
2151
+ kind: 'argv',
2152
+ location: 'first-positional',
2153
+ execution: 'argv',
2154
+ options: strategy.options,
2155
+ ...(strategy.shellModeOptions === undefined ? {} : { shellModeOptions: strategy.shellModeOptions }),
2156
+ });
2157
+ if (subcommandLocation === null)
2158
+ return { kind: 'other', name, args: [] };
2159
+ const subcommandAt = subcommandLocation.index;
2160
+ const subcommandWord = list[subcommandAt];
2161
+ if (subcommandWord === undefined)
2162
+ return { kind: 'other', name, args: [] };
2163
+ if (subcommandWord.dynamic) {
2164
+ return { kind: 'opaque', why: `«${name}» получает подкоманду, собранную оболочкой` };
2165
+ }
2166
+ const subcommand = subcommandWord.text;
2167
+ if (strategy.nonFilesystem.has(subcommand)) {
2168
+ return { kind: 'tool', name, args: list.slice(i + 1) };
2169
+ }
2170
+ const nested = strategy.commands.get(subcommand);
2171
+ if (nested?.kind === 'external-script') {
2172
+ return {
2173
+ kind: 'external-script',
2174
+ name,
2175
+ subcommand,
2176
+ };
2177
+ }
2178
+ if (nested?.kind === 'argv') {
2179
+ if (hasTerminalOptionBeforeFirstPositional(list, subcommandAt + 1, nested.options)) {
2180
+ return { kind: 'other', name, args: [] };
2181
+ }
2182
+ for (let k = subcommandAt + 1; k < list.length; k++) {
2183
+ const flagWord = list[k];
2184
+ const flag = flagWord.text;
2185
+ if (flag === '--')
2186
+ break;
2187
+ if (!flag.startsWith('-') || flag.length < 2)
2188
+ break;
2189
+ const carried = resolveOptionValueCommand(nested, list, k);
2190
+ if (carried === null) {
2191
+ if (consumesNextWord(nested.options, flag))
2192
+ k++;
2193
+ continue;
2194
+ }
2195
+ if (carried.kind === 'missing-value')
2196
+ return { kind: 'other', name, args: [] };
2197
+ if (carried.kind === 'opaque') {
2198
+ return { kind: 'opaque', why: `«${name} ${subcommand} ${flag}» получает команду, собранную оболочкой` };
2199
+ }
2200
+ if (carried.kind === 'shell') {
2201
+ return { kind: 'shell-command', name: `${name} ${subcommand} ${flag}`, command: carried.command, optionMode };
2202
+ }
2203
+ if (rewrites >= MAX_COMMAND_STRING_REWRITES) {
2204
+ return { kind: 'opaque', why: `«${name} ${subcommand} ${flag}» вложен глубже ${MAX_COMMAND_STRING_REWRITES} раз` };
2205
+ }
2206
+ if (carried.kind === 'invalid') {
2207
+ return { kind: 'opaque', why: `«${name} ${subcommand} ${flag}» получает строку, которую носитель отвергает целиком — не исполняется ничего` };
2208
+ }
2209
+ rewrites++;
2210
+ list = carried.words;
2211
+ i = 0;
2212
+ continue outer;
2213
+ }
2214
+ const commandLocation = argvCommandIndex(list, subcommandAt + 1, nested, subcommandLocation.execution === 'shell' ? 'shell' : undefined);
2215
+ if (commandLocation === null)
2216
+ return { kind: 'other', name, args: [] };
2217
+ const command = list[commandLocation.index];
2218
+ if (command.dynamic) {
2219
+ return { kind: 'opaque', why: `«${name} ${subcommand}» получает имя команды, собранное оболочкой` };
2220
+ }
2221
+ if (rewrites >= MAX_COMMAND_STRING_REWRITES) {
2222
+ return { kind: 'opaque', why: `«${name} ${subcommand}» вложен глубже ${MAX_COMMAND_STRING_REWRITES} раз` };
2223
+ }
2224
+ if (commandLocation.execution === 'shell') {
2225
+ const shellCommand = shellCommandTail(list.slice(commandLocation.index));
2226
+ if (shellCommand === null)
2227
+ return { kind: 'other', name, args: [] };
2228
+ if (shellCommand.dynamic) {
2229
+ return { kind: 'opaque', why: `«${name} ${subcommand}» получает команду, собранную оболочкой` };
2230
+ }
2231
+ return { kind: 'shell-command', name: `${name} ${subcommand}`, command: shellCommand, optionMode };
2232
+ }
2233
+ rewrites++;
2234
+ list = list.slice(commandLocation.index);
2235
+ i = 0;
2236
+ continue;
2237
+ }
2238
+ break;
2239
+ }
2240
+ break;
2241
+ }
2242
+ if (i >= list.length)
2243
+ return { kind: 'other', name: null, args: [] };
2244
+ const head = basename(list[i]?.text ?? '');
2245
+ if (DELETE_VERBS.has(head)) {
2246
+ return { kind: 'delete', name: head, args: list.slice(i + 1), optionMode };
2247
+ }
2248
+ if (COMMAND_WRAPPER_STRATEGIES.get(head)?.kind === 'shell-c') {
2249
+ return { kind: 'shell', name: head, args: list.slice(i + 1), optionMode };
2250
+ }
2251
+ return { kind: 'other', name: head, args: list.slice(i + 1) };
2252
+ }
2253
+ /**
2254
+ * The inline string a shell was told to RUN: the first non-option word after option processing has
2255
+ * seen `c` (`-c`, `-lc`, `-ec`). Shells keep accepting options between `-c` and that word:
2256
+ * `bash -c -- '<cmd>'`, `bash -c -x '<cmd>'`, and `bash -c -o posix '<cmd>'` all execute `<cmd>`.
2257
+ *
2258
+ * A positional word BEFORE any `-c` is a script PATH, not an inline command — `sh script.sh` runs
2259
+ * a file this guard never reads, so the search stops there rather than guessing.
2260
+ *
2261
+ * `--` ends the options, so the word after it is a script NAME even when it is spelled `-c`:
2262
+ * `bash -- -c '<string>'` runs the FILE called `-c` and hands the string to it as `$1`. Reading it
2263
+ * would refuse on a deletion that never happens — the false-refusal class, in the OTHER direction.
2264
+ */
2265
+ function inlineShellCommand(args) {
2266
+ // No-exec is a STATE, not a single match: the options are applied left to right and the LAST one
2267
+ // wins. MEASURED — `bash -n -c '<cmd>'` leaves the target ALIVE but `bash -n +n -c '<cmd>'`
2268
+ // leaves it GONE, and `-o noexec +o noexec -c` is GONE while `+o noexec -o noexec -c` is ALIVE.
2269
+ // Returning on the first no-exec option classified a real deletion as allowed (cross-family
2270
+ // review, gpt-5.6-sol, round 11).
2271
+ let noExec = false;
2272
+ let terminal = false;
2273
+ let commandRequested = false;
2274
+ for (let k = 0; k < args.length; k++) {
2275
+ const t = args[k]?.text ?? '';
2276
+ if (t === '--') {
2277
+ // Before `-c`, the next word is a script name. After `-c`, `--` merely ends the remaining
2278
+ // option scan and the next word is the requested command string (R16 live shell matrix).
2279
+ return commandRequested && !noExec && !terminal ? args[k + 1] ?? null : null;
2280
+ }
2281
+ if (SHELL_TERMINAL_OPTIONS.has(t)) {
2282
+ terminal = true;
2283
+ continue;
2284
+ }
2285
+ if (SHELL_VALUE_OPTIONS.has(t)) {
2286
+ // `-o noexec` sets it, `+o noexec` clears it; any other set-option is irrelevant here.
2287
+ if (args[k + 1]?.text === SHELL_NO_EXEC_SET_OPTION)
2288
+ noExec = t.startsWith('-');
2289
+ k++;
2290
+ continue;
2291
+ }
2292
+ if (SHELL_LONG_VALUE_OPTIONS.has(t)) {
2293
+ k++;
2294
+ continue;
2295
+ } // `--rcfile FILE`: same, long spelling
2296
+ if (/^[-+][A-Za-z]+$/.test(t)) {
2297
+ const minus = t.startsWith('-');
2298
+ if (minus && t.includes(SHELL_DUMP_LETTER))
2299
+ terminal = true;
2300
+ if (t.includes(SHELL_NO_EXEC_LETTER))
2301
+ noExec = minus;
2302
+ // A CLUSTERED set-option letter still takes the next word as its value, and it does so
2303
+ // BEFORE the `-c` string is located. MEASURED: `bash -co posix '<cmd>'` deletes, and
2304
+ // `bash -co posix '<argv-printer> one two'` prints `<one><two>` — `posix` went to `-o`, and
2305
+ // the string is the word after it. Reading the `c` first returned `posix` as the script and
2306
+ // the deletion was allowed (cross-family review, gpt-5.6-sol, round 12). Letter order inside
2307
+ // the cluster does not matter: `-oc posix` behaves identically.
2308
+ const values = (t.match(/[oO]/g) ?? []).length;
2309
+ for (let v = 1; v <= values; v++) {
2310
+ if (args[k + v]?.text === SHELL_NO_EXEC_SET_OPTION)
2311
+ noExec = minus;
2312
+ }
2313
+ // `+c` runs the string exactly like `-c` — MEASURED on bash, sh, dash and ksh, all GONE.
2314
+ // Seeing `c` does NOT stop option processing; the first non-option does. Once that word is
2315
+ // reached, later words are `$0`, `$1`, ... and cannot change the option state (F48-guard).
2316
+ if (t.includes('c'))
2317
+ commandRequested = true;
2318
+ k += values;
2319
+ continue;
2320
+ }
2321
+ if ((t.startsWith('-') || t.startsWith('+')) && t.length > 1)
2322
+ continue;
2323
+ return commandRequested && !noExec && !terminal ? args[k] ?? null : null;
2324
+ }
2325
+ return null;
2326
+ }
2327
+ // -------------------------------------------------------------------------------------------
2328
+ // Stage 3 — the rules. A literal path into a protected store, or a literal database file.
2329
+ // -------------------------------------------------------------------------------------------
2330
+ /**
2331
+ * The path as a list of segments, with `.` dropped and `..` cancelled LEXICALLY.
2332
+ *
2333
+ * WHY `..` HAD TO JOIN `.`. Dropping only `.` left `rm -rf .dz/../ordinary` matching on the segment
2334
+ * `.dz` in a path that leads OUT of the store, i.e. a refusal naming a store the command does not
2335
+ * touch — the false-refusal class this feature exists to avoid (cross-family review, gpt-5.6-sol,
2336
+ * round 2). Cancelling is safe in the other direction too: `ordinary/../.dz` still ends in `.dz`.
2337
+ *
2338
+ * LEXICAL, not resolved: a LEADING `..` has nothing to cancel against and is KEPT, because where it
2339
+ * points is exactly the guard's second printed limit — a relative path we do not resolve. No
2340
+ * filesystem is touched, so a symlink in the middle of the path can still make the lexical answer
2341
+ * differ from the real one; that is the same limit, not a new one.
2342
+ *
2343
+ * HONEST NOTE ON THE `push('..')` BRANCH. It is deliberately NOT claimed to be covered by a test,
2344
+ * because no test can cover it by outcome: a `..` segment matches neither PROTECTED_SEGMENTS nor
2345
+ * DATABASE_FILE, so keeping it and dropping it give the SAME verdict on every input (MEASURED
2346
+ * 2026-09-05, both normalisers run side by side over `../.dz`, `../../.agentic-qe`, `a/../../.dz`,
2347
+ * `../.dz/../x`, `../../x/../.dz`, `.dz/..`, `.dz/../..`, `../ordinary` — eight identical verdicts).
2348
+ * It is kept because it is the lexically correct answer, and because it is what stops this function
2349
+ * from silently becoming wrong if a future rule ever counts segments. The branch that IS observable
2350
+ * — cancelling `..` against a real segment — is pinned by F12 and F12-guard.
2351
+ */
2352
+ function normalisedSegments(path) {
2353
+ const out = [];
2354
+ for (const s of path.split('/')) {
2355
+ if (s === '' || s === '.')
2356
+ continue;
2357
+ if (s === '..') {
2358
+ const last = out[out.length - 1];
2359
+ if (last !== undefined && last !== '..')
2360
+ out.pop();
2361
+ else
2362
+ out.push('..');
2363
+ continue;
2364
+ }
2365
+ out.push(s);
2366
+ }
2367
+ return out;
2368
+ }
2369
+ /** How many words one brace expansion may produce before the guard gives up on reading it. */
2370
+ const MAX_BRACE_EXPANSIONS = 1000;
2371
+ /** The alternatives of a `{a..b}` / `{a..b..step}` range, or null when the body is not a range. */
2372
+ /**
2373
+ * `bodyMask` is the quoting mask of the body — a range is syntax only when ALL of it is bare.
2374
+ *
2375
+ * MEASURED: bash passes `.d{y..z}` UNCHANGED for `.d{y"."."z"}`, `.d{y".."z}`, `.d"{y..z}"` and
2376
+ * `.d{"y"..z}`, while the bare `.d{y..z}` expands to `<.dy><.dz>`. Reading only the dequeued body
2377
+ * manufactured `.dy`/`.dz` and refused a command that never touches the store (cross-family review,
2378
+ * gpt-5.6-sol, round 12). Note the contrast with a COMMA group, where a quoted ALTERNATIVE still
2379
+ * expands (`{"a",.dz}` is `<a><.dz>`) — that is a different rule, and it is unchanged.
2380
+ */
2381
+ function braceRange(body, bodyMask) {
2382
+ if (bodyMask.length === body.length && /[^0]/.test(bodyMask))
2383
+ return null;
2384
+ const parts = body.split('..');
2385
+ if (parts.length < 2 || parts.length > 3)
2386
+ return null;
2387
+ const [rawFrom, rawTo, rawStep] = parts;
2388
+ const step = rawStep === undefined ? null : Number(rawStep);
2389
+ if (rawStep !== undefined && (!Number.isInteger(step) || step === 0))
2390
+ return null;
2391
+ if (/^-?\d+$/.test(rawFrom) && /^-?\d+$/.test(rawTo)) {
2392
+ const from = Number(rawFrom);
2393
+ const to = Number(rawTo);
2394
+ // A leading zero on either endpoint pads every result to the wider of the two spellings.
2395
+ const pad = /^-?0\d/.test(rawFrom) || /^-?0\d/.test(rawTo)
2396
+ ? Math.max(rawFrom.length, rawTo.length)
2397
+ : 0;
2398
+ const delta = Math.abs(step ?? 1) * (to >= from ? 1 : -1);
2399
+ const out = [];
2400
+ for (let v = from; delta > 0 ? v <= to : v >= to; v += delta) {
2401
+ const digits = String(Math.abs(v));
2402
+ const sign = v < 0 ? '-' : '';
2403
+ out.push(pad > 0 ? sign + digits.padStart(pad - sign.length, '0') : String(v));
2404
+ if (out.length > MAX_BRACE_EXPANSIONS)
2405
+ return null;
2406
+ }
2407
+ return out;
2408
+ }
2409
+ if (/^[A-Za-z]$/.test(rawFrom) && /^[A-Za-z]$/.test(rawTo)) {
2410
+ const from = rawFrom.charCodeAt(0);
2411
+ const to = rawTo.charCodeAt(0);
2412
+ const delta = Math.abs(step ?? 1) * (to >= from ? 1 : -1);
2413
+ const out = [];
2414
+ for (let v = from; delta > 0 ? v <= to : v >= to; v += delta) {
2415
+ out.push(String.fromCharCode(v));
2416
+ if (out.length > MAX_BRACE_EXPANSIONS)
2417
+ return null;
2418
+ }
2419
+ return out;
2420
+ }
2421
+ return null;
2422
+ }
2423
+ function findBraceGroup(word, mask) {
2424
+ /** A character the shell reads as SYNTAX — quoted or escaped ones are part of the name. */
2425
+ const bare = (at) => mask.charAt(at) !== '1';
2426
+ for (let i = 0; i < word.length; i++) {
2427
+ if (word.charAt(i) !== '{' || !bare(i))
2428
+ continue;
2429
+ let depth = 0;
2430
+ const commas = [];
2431
+ for (let j = i; j < word.length; j++) {
2432
+ const c = word.charAt(j);
2433
+ if (!bare(j))
2434
+ continue;
2435
+ if (c === '{') {
2436
+ depth++;
2437
+ continue;
2438
+ }
2439
+ if (c === ',' && depth === 1) {
2440
+ commas.push(j);
2441
+ continue;
2442
+ }
2443
+ if (c !== '}')
2444
+ continue;
2445
+ depth--;
2446
+ if (depth > 0)
2447
+ continue;
2448
+ const body = word.slice(i + 1, j);
2449
+ if (commas.length > 0) {
2450
+ const alternatives = [];
2451
+ const altStarts = [];
2452
+ let from = i + 1;
2453
+ for (const at of commas) {
2454
+ altStarts.push(from);
2455
+ alternatives.push(word.slice(from, at));
2456
+ from = at + 1;
2457
+ }
2458
+ altStarts.push(from);
2459
+ alternatives.push(word.slice(from, j));
2460
+ return { start: i, end: j, alternatives, altStarts };
2461
+ }
2462
+ const range = braceRange(body, mask.slice(i + 1, j));
2463
+ // A range's words are GENERATED, not lifted out of the text, so they carry no quoting: the
2464
+ // mask slice for them is empty and `walk` pads it to bare.
2465
+ if (range !== null)
2466
+ return { start: i, end: j, alternatives: range, altStarts: range.map(() => j) };
2467
+ break; // `{bar}`: not expandable — look for a later group instead
2468
+ }
2469
+ }
2470
+ return null;
2471
+ }
2472
+ function expandBraces(word, literalMask) {
2473
+ const out = [];
2474
+ // The mask travels WITH the text through every substitution: an alternative lifted out of a bare
2475
+ // group keeps its own quoting, so `{"a",b}/.dz` still expands while `pre"{a,.dz}"` still does not.
2476
+ const walk = (w, m) => {
2477
+ const group = findBraceGroup(w, m);
2478
+ if (group === null) {
2479
+ out.push({ text: w, mask: m });
2480
+ return out.length <= MAX_BRACE_EXPANSIONS;
2481
+ }
2482
+ const pre = w.slice(0, group.start);
2483
+ const post = w.slice(group.end + 1);
2484
+ const preMask = m.slice(0, group.start);
2485
+ const postMask = m.slice(group.end + 1);
2486
+ for (let k = 0; k < group.alternatives.length; k++) {
2487
+ const alt = group.alternatives[k];
2488
+ const at = group.altStarts[k];
2489
+ const altMask = (m.slice(at, at + alt.length) + '0'.repeat(alt.length)).slice(0, alt.length);
2490
+ if (!walk(pre + alt + post, preMask + altMask + postMask))
2491
+ return false;
2492
+ }
2493
+ return true;
2494
+ };
2495
+ const mask = literalMask !== undefined && literalMask.length === word.length
2496
+ ? literalMask
2497
+ : '0'.repeat(word.length);
2498
+ return walk(word, mask) ? out : null;
2499
+ }
2500
+ function matchProtectedPath(token) {
2501
+ const trimmed = token.replace(/\/+$/, '');
2502
+ if (trimmed === '')
2503
+ return null;
2504
+ const segments = normalisedSegments(trimmed);
2505
+ for (const segment of segments) {
2506
+ const rule = PROTECTED_SEGMENTS.get(segment);
2507
+ if (rule)
2508
+ return { rule, what: describeRule(rule) };
2509
+ }
2510
+ const leaf = segments[segments.length - 1] ?? '';
2511
+ if (DATABASE_FILE.test(leaf))
2512
+ return { rule: 'database-file', what: describeRule('database-file') };
2513
+ return null;
2514
+ }
2515
+ const describeRule = (id) => DESTRUCTIVE_RULES.find((r) => r.id === id)?.what ?? id;
2516
+ /** Whether shell expansion of this source word could produce an exact terminal-mode option. */
2517
+ function dynamicWordCouldBeTerminalOption(word) {
2518
+ const mask = word.literalMask ?? '';
2519
+ const firstDynamic = mask.indexOf('d');
2520
+ if (firstDynamic === -1)
2521
+ return false;
2522
+ // Parameter expansion keeps its source spelling in `text`, while only `$` is marked dynamic.
2523
+ // The name characters are syntax, not output, so only the fixed prefix before `$` constrains the
2524
+ // resulting word. A non-dash prefix can never become either exact terminal option.
2525
+ const dollar = word.text.indexOf('$');
2526
+ if (dollar !== -1 && mask.charAt(dollar) === 'd') {
2527
+ const fixedPrefix = word.text.slice(0, dollar);
2528
+ return [...TERMINAL_MODE_OPTIONS].some((option) => option.startsWith(fixedPrefix));
2529
+ }
2530
+ // For glob/substitution masks, ask the narrower question we actually care about: can the
2531
+ // pattern produce exactly `--help` or `--version`? A late `.dz/backup-*` is dynamic, but it can
2532
+ // never become an option and therefore must not downgrade a real protected deletion.
2533
+ let pattern = '^';
2534
+ for (let i = 0; i < word.text.length; i++) {
2535
+ const ch = word.text.charAt(i);
2536
+ if (mask.charAt(i) === 'd')
2537
+ pattern += ch === '?' ? '.' : '.*';
2538
+ else
2539
+ pattern += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2540
+ }
2541
+ pattern += '$';
2542
+ const possible = new RegExp(pattern);
2543
+ return [...TERMINAL_MODE_OPTIONS].some((option) => possible.test(option));
2544
+ }
2545
+ /** Analyze one deletion under one explicit option grammar. */
2546
+ function analyzeDeleteInvocation(name, args, arity, posixlyCorrect) {
2547
+ let terminal = null;
2548
+ for (let k = 0; k < args.length; k++) {
2549
+ const arg = args[k];
2550
+ if (arg.text === '--')
2551
+ break;
2552
+ if (TERMINAL_MODE_OPTIONS.has(arg.text)) {
2553
+ terminal = arg.text;
2554
+ break;
2555
+ }
2556
+ if (arg.text.startsWith('-') && arg.text.length > 1) {
2557
+ if (consumesNextWord(arity, arg.text))
2558
+ k++;
2559
+ continue;
2560
+ }
2561
+ if (posixlyCorrect)
2562
+ break;
2563
+ }
2564
+ if (terminal !== null)
2565
+ return { kind: 'terminal', option: terminal };
2566
+ // A shell-built word in a live option slot might become a terminal option. GNU option grammar
2567
+ // keeps scanning after operands; POSIXLY_CORRECT ends option parsing at the first operand.
2568
+ let dynamicOption = false;
2569
+ for (let k = 0; k < args.length; k++) {
2570
+ const arg = args[k];
2571
+ if (arg.text === '--')
2572
+ break;
2573
+ if (dynamicWordCouldBeTerminalOption(arg)) {
2574
+ dynamicOption = true;
2575
+ break;
2576
+ }
2577
+ if (arg.text.startsWith('-') && arg.text.length > 1) {
2578
+ if (consumesNextWord(arity, arg.text))
2579
+ k++;
2580
+ continue;
2581
+ }
2582
+ if (posixlyCorrect)
2583
+ break;
2584
+ }
2585
+ const protectedOperands = [];
2586
+ let flagsEnded = false;
2587
+ for (let k = 0; k < args.length; k++) {
2588
+ const arg = args[k];
2589
+ if (!flagsEnded && arg.text === '--') {
2590
+ flagsEnded = true;
2591
+ continue;
2592
+ }
2593
+ if (!flagsEnded && arg.text.startsWith('-') && arg.text.length > 1) {
2594
+ if (consumesNextWord(arity, arg.text))
2595
+ k++;
2596
+ continue;
2597
+ }
2598
+ if (posixlyCorrect)
2599
+ flagsEnded = true;
2600
+ if (arg.dynamic)
2601
+ continue;
2602
+ const hit = matchProtectedPath(arg.text);
2603
+ if (hit !== null)
2604
+ protectedOperands.push({ path: arg.text, ...hit });
2605
+ }
2606
+ if (dynamicOption && protectedOperands.length > 0) {
2607
+ return {
2608
+ kind: 'undecidable',
2609
+ why: `«${name}» получает аргумент, собранный оболочкой, там, где ещё возможна опция: он мог бы оказаться терминальным режимом, и тогда ${protectedOperands[0]?.path ?? 'цель'} не удаляется`,
2610
+ };
2611
+ }
2612
+ return { kind: 'delete', protected: protectedOperands };
2613
+ }
2614
+ /**
2615
+ * Decide whether a shell command must be refused BEFORE it runs.
2616
+ *
2617
+ * Refuses only when it can name a concrete literal path and the rule that path broke. Everything
2618
+ * else is allowed, and every verdict prints the guard's limits (LIMITS) so it is never read as a
2619
+ * total guarantee.
2620
+ */
2621
+ export function classifyDestructive(command) {
2622
+ try {
2623
+ return classifyAtDepth(command, 0);
2624
+ }
2625
+ catch (err) {
2626
+ // TOTALITY IS A SAFETY PROPERTY, not tidiness. The consuming hooks wrap this call in a catch
2627
+ // and treat an exception as "no verdict", i.e. they fail OPEN — so a single unhandled input
2628
+ // disarms the guard for the command that carries it, which is exactly how
2629
+ // `rm -rf $'\Uffffffff'; rm -rf .dz` got through (cross-family review, gpt-5.6-sol, round 9).
2630
+ // The known cause is fixed at its source in decodeAnsiC; this is the belt, and it answers
2631
+ // `undecidable` — never `allow`, which would dress a crash up as a clean review.
2632
+ // Pinned by F39-total: 10 000 fuzzed strings over the metacharacters this lexer gives meaning
2633
+ // to, no throw and a well-formed verdict every time.
2634
+ return {
2635
+ outcome: 'undecidable',
2636
+ path: null,
2637
+ rule: null,
2638
+ reason: `разобрать не удалось: классификатор не смог вынести вердикт (${String(err?.message ?? err)}); вердикт «проверить не удалось», потребитель обязан ПРОПУСТИТЬ`,
2639
+ limits: LIMITS,
2640
+ };
2641
+ }
2642
+ }
2643
+ function classifyAtDepth(command, depth, expandsBraces = true, inheritedOptionMode = 'unknown') {
2644
+ const verdict = (outcome, reason, path = null, rule = null) => ({ outcome, path, rule, reason, limits: LIMITS });
2645
+ if (typeof command !== 'string') {
2646
+ return verdict('undecidable', 'разобрать не удалось: вход не является строкой; вердикт «проверить не удалось», потребитель обязан ПРОПУСТИТЬ');
2647
+ }
2648
+ if (command.trim() === '') {
2649
+ return verdict('allow', 'пустая команда — классифицировать нечего');
2650
+ }
2651
+ const lexed = lex(command);
2652
+ if (lexed.failure !== null && !lexed.prefixRuns) {
2653
+ // NEVER `refuse` here, even if a protected path is plainly visible in the raw text. A refusal
2654
+ // derived from an inability to read the command is a false guarantee (AC-10).
2655
+ return verdict('undecidable', `разобрать не удалось: ${lexed.failure}; вердикт «проверить не удалось», потребитель обязан ПРОПУСТИТЬ`);
2656
+ }
2657
+ let sawDelete = false;
2658
+ let sawTool = null;
2659
+ let sawExternalScript = null;
2660
+ /** A deletion verb that was asked to print its help or version, so it removes nothing. */
2661
+ let sawTerminal = null;
2662
+ let innerAllow = null;
2663
+ /**
2664
+ * The first segment that could not be read, REMEMBERED rather than returned.
2665
+ *
2666
+ * The verdict of a multi-segment command is `refuse` if ANY segment refuses, otherwise
2667
+ * `undecidable` if any segment is unreadable, otherwise `allow` (F17-guard). Returning on the
2668
+ * first unreadable segment broke the first half of that: the hook exits 0 on `undecidable`
2669
+ * (AC-10), so in `sh -c "$CMD"; <verb> -rf .dz` the literal deletion behind the unreadable
2670
+ * segment was never examined and ran (cross-family review, gpt-5.6-sol, round 4; F17). A
2671
+ * `refuse` may still return at once — it is the top of the order and nothing later can beat it.
2672
+ */
2673
+ let unreadable = null;
2674
+ const cannotRead = (why) => {
2675
+ unreadable = unreadable ?? verdict('undecidable', `разобрать не удалось: ${why}; вердикт «проверить не удалось», потребитель обязан ПРОПУСТИТЬ`);
2676
+ };
2677
+ // The prefix in front of an unterminated heredoc RUNS, so it is classified — but the tail was
2678
+ // never read, and "could not check" must not decay into "checked and fine". A refusal found in
2679
+ // the prefix still wins, by the same precedence as F17.
2680
+ if (lexed.failure !== null)
2681
+ cannotRead(lexed.failure);
2682
+ // A function DEFINITION binds a name and executes nothing, so its body is classified only when
2683
+ // the same command uses that name again — see liftFunctionBodies for the measurement.
2684
+ const plan = liftFunctionBodies(lexed.lexemes);
2685
+ const streams = [plan.main];
2686
+ if (plan.bodies.length > 0) {
2687
+ const mentioned = new Set();
2688
+ for (const stream of [plan.main, ...plan.bodies.map((b) => b.lexemes)]) {
2689
+ // Function invocation happens after brace expansion. Comparing a bound name to the raw word
2690
+ // missed `f{,}`, `{f,f}` and `f{,,}` even though bash turns each into one or more calls to f.
2691
+ for (const rawWords of reachableSegments(stream)) {
2692
+ const expanded = expandSegment(rawWords, expandsBraces);
2693
+ if (expanded.overflowed) {
2694
+ cannotRead(`раскрытие имени функции даёт больше ${MAX_BRACE_EXPANSIONS} слов`);
2695
+ }
2696
+ for (const word of expanded.words)
2697
+ mentioned.add(word.text);
2698
+ }
2699
+ }
2700
+ for (const body of plan.bodies)
2701
+ if (mentioned.has(body.name))
2702
+ streams.push(body.lexemes);
2703
+ }
2704
+ for (const rawWords of streams.flatMap((stream) => reachableSegments(stream))) {
2705
+ const expanded = expandSegment(rawWords, expandsBraces);
2706
+ if (expanded.overflowed) {
2707
+ cannotRead(`раскрытие фигурных скобок даёт больше ${MAX_BRACE_EXPANSIONS} слов`);
2708
+ }
2709
+ const words = expanded.words;
2710
+ const invocation = resolveInvocation(words, inheritedOptionMode);
2711
+ if (invocation.kind === 'tool') {
2712
+ sawTool = sawTool ?? invocation.name;
2713
+ continue;
2714
+ }
2715
+ if (invocation.kind === 'external-script') {
2716
+ sawExternalScript = sawExternalScript ?? invocation;
2717
+ continue;
2718
+ }
2719
+ if (invocation.kind === 'opaque') {
2720
+ cannotRead(invocation.why);
2721
+ continue;
2722
+ }
2723
+ // Every table row whose execution is `shell` converges here: a shell binary locates its `-c`
2724
+ // string, while npm/npx/pnpm already supplied the string from their carrier rule.
2725
+ if (invocation.kind === 'shell' || invocation.kind === 'shell-command') {
2726
+ const inline = invocation.kind === 'shell'
2727
+ ? inlineShellCommand(invocation.args)
2728
+ : invocation.command;
2729
+ if (inline === null)
2730
+ continue;
2731
+ if (inline.dynamic) {
2732
+ cannotRead(`«${invocation.name}» получает команду, собранную оболочкой`);
2733
+ continue;
2734
+ }
2735
+ if (depth >= MAX_SHELL_DEPTH) {
2736
+ cannotRead(`вложенная оболочка глубже ${MAX_SHELL_DEPTH} уровня`);
2737
+ continue;
2738
+ }
2739
+ const inner = classifyAtDepth(inline.text, depth + 1, !SHELLS_WITHOUT_BRACE_EXPANSION.has(invocation.name), invocation.optionMode);
2740
+ if (inner.outcome === 'refuse')
2741
+ return inner;
2742
+ if (inner.outcome === 'undecidable') {
2743
+ unreadable = unreadable ?? inner;
2744
+ continue;
2745
+ }
2746
+ innerAllow = innerAllow ?? inner;
2747
+ continue;
2748
+ }
2749
+ if (invocation.kind !== 'delete')
2750
+ continue;
2751
+ const arity = DELETE_OPTIONS.get(invocation.name) ?? NO_VALUE_OPTIONS;
2752
+ const analyses = invocation.optionMode === 'unknown'
2753
+ ? [
2754
+ analyzeDeleteInvocation(invocation.name, invocation.args, arity, false),
2755
+ analyzeDeleteInvocation(invocation.name, invocation.args, arity, true),
2756
+ ]
2757
+ : [analyzeDeleteInvocation(invocation.name, invocation.args, arity, invocation.optionMode === 'enabled')];
2758
+ if (analyses.some((analysis) => analysis.kind === 'undecidable')) {
2759
+ const uncertain = analyses.find((analysis) => analysis.kind === 'undecidable');
2760
+ cannotRead(uncertain?.why ?? `«${invocation.name}» получает неоднозначные аргументы`);
2761
+ continue;
2762
+ }
2763
+ const terminals = analyses.filter((analysis) => analysis.kind === 'terminal');
2764
+ if (terminals.length === analyses.length) {
2765
+ sawTerminal = sawTerminal ?? `${invocation.name} ${terminals[0]?.option ?? '--help'}`;
2766
+ continue;
2767
+ }
2768
+ if (terminals.length > 0) {
2769
+ cannotRead(`эффективный режим разбора опций «${invocation.name}» зависит от невидимого стражу POSIXLY_CORRECT: один режим завершает команду, другой обрабатывает операнды`);
2770
+ continue;
2771
+ }
2772
+ const deletes = analyses;
2773
+ const certain = deletes[0]?.protected.find((candidate) => deletes.every((analysis) => analysis.protected.some((other) => other.path === candidate.path && other.rule === candidate.rule)));
2774
+ if (certain !== undefined) {
2775
+ return verdict('refuse', `Отказ [${certain.rule}]: ${certain.path} — ${certain.what}`, certain.path, certain.rule);
2776
+ }
2777
+ if (deletes.some((analysis) => analysis.protected.length > 0)) {
2778
+ cannotRead(`эффективный режим разбора опций «${invocation.name}» зависит от невидимого стражу POSIXLY_CORRECT: режимы расходятся в том, какие слова являются операндами`);
2779
+ continue;
2780
+ }
2781
+ sawDelete = true;
2782
+ }
2783
+ // No segment refused. An unreadable one now decides, because "could not check" must never be
2784
+ // reported as "checked and fine" — the second step of the refuse > undecidable > allow order.
2785
+ if (unreadable !== null)
2786
+ return unreadable;
2787
+ if (sawTool !== null) {
2788
+ const how = VCS_TOOLS.has(sawTool)
2789
+ ? `«${sawTool} rm» — операция индекса (index operation), а не файловой системы`
2790
+ : `«${sawTool} rm» — подкоманда инструмента, а не удаление файлов`;
2791
+ return verdict('allow', `${how}; правила удаления к ней не применяются`);
2792
+ }
2793
+ if (sawDelete) {
2794
+ return verdict('allow', 'удаление, но ни один аргумент не является буквальным путём в защищаемое хранилище');
2795
+ }
2796
+ if (sawTerminal !== null) {
2797
+ return verdict('allow', `«${sawTerminal}» печатает текст и завершается — операнды не удаляются`);
2798
+ }
2799
+ // The reason of the unpacked command, so the verdict says what was actually read, not that an
2800
+ // `sh -c` wrapper "is not a deletion".
2801
+ if (innerAllow !== null)
2802
+ return innerAllow;
2803
+ if (sawExternalScript !== null) {
2804
+ return verdict('allow', `«${sawExternalScript.name} ${sawExternalScript.subcommand}» запускает именованный сценарий, чьё тело находится вне строки команды; чистый классификатор его не читает`);
2805
+ }
2806
+ return verdict('allow', 'не команда удаления файлов');
2807
+ }
2808
+ //# sourceMappingURL=destructive-guard.js.map