session-orchestrator 3.17.0 → 3.20.0

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 (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -13,6 +13,10 @@
13
13
  * invariant for all exports below — see #554 A2.
14
14
  */
15
15
 
16
+ // Deliberately import-lean (hook hot path): node:path is the ONLY import —
17
+ // pure string manipulation, no I/O. Do not add further imports here.
18
+ import path from 'node:path';
19
+
16
20
  /**
17
21
  * Shell interpreters whose QUOTED argument text is still executed as a command.
18
22
  * When a command segment's argv[0] (or `command <verb>` / `env … <verb>`) is one
@@ -27,6 +31,10 @@ const SHELL_EXEC_INTERPRETERS = new Set([
27
31
  'eval', 'xargs', 'env', 'command',
28
32
  'psql', 'mysql', 'sqlite3',
29
33
  'find',
34
+ // `su -c 'payload'` executes its quoted payload as a shell command (#982).
35
+ // Deliberately an interpreter, NOT a WRAPPER_UNWRAP entry: unwrapping `su`
36
+ // would resolve past it and could only loosen the quoted-payload guard.
37
+ 'su',
30
38
  ]);
31
39
 
32
40
  function matchIfsWhitespaceExpansion(command, index) {
@@ -142,6 +150,15 @@ function normalizeShellWhitespaceExpansions(command, options = {}) {
142
150
  if (expandSingleQuoted) {
143
151
  const end = matchShellWhitespaceExpansion(command, i);
144
152
  if (end !== -1) { out += ' '; i = end; continue; }
153
+ // Line continuation inside SINGLE quotes (#992). The OUTER shell keeps
154
+ // `\<LF>` literal here — but under `expandSingleQuoted` the caller is
155
+ // looking at a string a LATER shell parses, and that inner shell joins
156
+ // the lines. Measured: `bash -c 'set -- rm -rf\<LF>/; …'` → argv
157
+ // `[rm][-rf/]`, i.e. the continuation is gone by the time the inner
158
+ // shell splits words. Gated on the flag for exactly that reason: the
159
+ // default (outer-shell) reading must keep the pair, or a literal
160
+ // `printf 'a\<LF>b'` would be misread.
161
+ if (ch === '\\' && command[i + 1] === '\n') { i += 2; continue; }
145
162
  }
146
163
  out += ch;
147
164
  i++;
@@ -155,6 +172,14 @@ function normalizeShellWhitespaceExpansions(command, options = {}) {
155
172
  if (end !== -1) { out += ' '; i = end; continue; }
156
173
  }
157
174
  if (ch === '\\' && i + 1 < command.length) {
175
+ // Line continuation inside DOUBLE quotes (#992). bash removes the pair
176
+ // here exactly as it does unquoted — measured `set -- "rm -rf\<LF>/"`
177
+ // → argv `[rm -rf/]`, the two characters leave no trace. Keeping them
178
+ // was the #981 scope cut ("no verdict there depends on it"); one did:
179
+ // commandMatchesBlocked's fast path tests this string, so
180
+ // `bash -c "git push \<LF>--force origin main"` never reached the lexer
181
+ // and the force-push was ALLOWED while the unquoted spelling denied.
182
+ if (command[i + 1] === '\n') { i += 2; continue; }
158
183
  out += ch + command[i + 1];
159
184
  i += 2;
160
185
  continue;
@@ -167,6 +192,20 @@ function normalizeShellWhitespaceExpansions(command, options = {}) {
167
192
  const end = matchShellWhitespaceExpansion(command, i);
168
193
  if (end !== -1) { out += ' '; i = end; continue; }
169
194
  if (ch === '\\' && i + 1 < command.length) {
195
+ // Line continuation (#981): bash JOINS the two lines, so the pair must
196
+ // disappear here too — not only in the lexer. commandMatchesBlocked's
197
+ // fast path tests this normalized string and bails out before any
198
+ // tokenization; its invariant ("if the regex cannot match the raw string,
199
+ // no tokenization can produce a match") is FALSE while a continuation is
200
+ // still present, because eliding it JOINS text that the regex then spans.
201
+ // Measured: `git push \<LF>--force origin main` never reached the lexer —
202
+ // the fast path returned false and the force-push was ALLOWED.
203
+ // All three states elide now (#992). The #981 claim that "no verdict
204
+ // depends on" the quoted branches was false: the fast path above tests
205
+ // this very string, so a continuation surviving inside quotes bailed the
206
+ // whole match out. See the `single` / `double` branches for their gating
207
+ // (single only under `expandSingleQuoted` — there the INNER shell joins).
208
+ if (command[i + 1] === '\n') { i += 2; continue; }
170
209
  out += ch + command[i + 1];
171
210
  i += 2;
172
211
  continue;
@@ -181,14 +220,84 @@ function normalizeShellWhitespaceExpansions(command, options = {}) {
181
220
  return out;
182
221
  }
183
222
 
223
+ /** Unquoted characters that terminate a here-doc delimiter word. */
224
+ const WORD_END_CHARS = new Set([';', '|', '&', '<', '>', '(', ')', '\n']);
225
+
226
+ /**
227
+ * Read ONE shell word starting at `i`, resolving quotes and backslash escapes to
228
+ * the logical value bash would pass. Used only for a here-doc DELIMITER
229
+ * (`<<EOF`, `<<'EOF'`, `<<"EOF"`, `<<\EOF`), which is syntax rather than an
230
+ * argument and therefore never becomes a token of its own.
231
+ *
232
+ * @param {string} command
233
+ * @param {number} i
234
+ * @returns {{ value: string, end: number }}
235
+ */
236
+ function readDelimiterWord(command, i) {
237
+ let value = '';
238
+ let state = 'normal';
239
+ while (i < command.length) {
240
+ const ch = command[i];
241
+ if (state === 'single') {
242
+ if (ch === "'") { state = 'normal'; i++; continue; }
243
+ value += ch; i++; continue;
244
+ }
245
+ if (state === 'double') {
246
+ if (ch === '"') { state = 'normal'; i++; continue; }
247
+ if (ch === '\\' && i + 1 < command.length) { value += command[i + 1]; i += 2; continue; }
248
+ value += ch; i++; continue;
249
+ }
250
+ if (/\s/.test(ch) || WORD_END_CHARS.has(ch)) break;
251
+ if (ch === '\\' && i + 1 < command.length) { value += command[i + 1]; i += 2; continue; }
252
+ if (ch === "'") { state = 'single'; i++; continue; }
253
+ if (ch === '"') { state = 'double'; i++; continue; }
254
+ value += ch; i++;
255
+ }
256
+ return { value, end: i };
257
+ }
258
+
259
+ /**
260
+ * Read a here-doc BODY starting at `from` (the first character after the newline
261
+ * that opened it) up to — and excluding — its terminator line.
262
+ *
263
+ * `terminated` is false when the delimiter line never arrived. That is NOT a
264
+ * body — it is a malformed command (or, more often, a `<<` that was never a
265
+ * here-doc operator at all), and the caller MUST NOT emit the swallowed text as
266
+ * an inert quoted token. See the `terminated === false` branch in
267
+ * {@link tokenizeCommand}.
268
+ *
269
+ * @param {string} command
270
+ * @param {number} from
271
+ * @param {string} delim
272
+ * @param {boolean} stripTabs — `<<-` form: leading tabs are ignored on every line
273
+ * @returns {{ body: string, end: number, terminated: boolean }} end = index just past the terminator line
274
+ */
275
+ function readHeredocBody(command, from, delim, stripTabs) {
276
+ const lines = [];
277
+ let i = from;
278
+ while (i < command.length) {
279
+ let lineEnd = command.indexOf('\n', i);
280
+ if (lineEnd === -1) lineEnd = command.length;
281
+ const raw = command.slice(i, lineEnd);
282
+ const line = stripTabs ? raw.replace(/^\t+/, '') : raw;
283
+ i = lineEnd + 1;
284
+ if (line === delim) {
285
+ return { body: lines.join('\n'), end: Math.min(i, command.length), terminated: true };
286
+ }
287
+ lines.push(line);
288
+ }
289
+ // Terminator never arrived. The caller decides what to do; it is NOT a body.
290
+ return { body: lines.join('\n'), end: command.length, terminated: false };
291
+ }
292
+
184
293
  /**
185
294
  * Hand-rolled quote-aware command lexer.
186
295
  *
187
- * Splits a command string into tokens on UNQUOTED whitespace, tracking single- and
188
- * double-quote state and backslash escapes. Each token records whether ANY of its
189
- * characters originated inside quotes (`quoted: true`). Quote characters and the
190
- * escaping backslash are consumed (not part of the token text), so the returned
191
- * token text is the logical argument value a shell would pass.
296
+ * Splits a command string into tokens on UNQUOTED whitespace, tracking quote
297
+ * state and backslash escapes. Each token records whether ANY of its characters
298
+ * originated inside quotes (`quoted: true`). Quote characters and the escaping
299
+ * backslash are consumed (not part of the token text), so the returned token text
300
+ * is the logical argument value a shell would pass.
192
301
  *
193
302
  * This is deliberately NOT node:util.parseArgs — parseArgs operates on an already-
194
303
  * tokenized argv array and does not lex raw shell strings with quote semantics.
@@ -197,12 +306,75 @@ function normalizeShellWhitespaceExpansions(command, options = {}) {
197
306
  * Notes / scope (sufficient for the guard, not a full POSIX shell parser):
198
307
  * - Single quotes: literal, no escapes inside (POSIX).
199
308
  * - Double quotes: backslash escapes the next char.
200
- * - Outside quotes: backslash escapes the next char (incl. whitespace → same token).
309
+ * - ANSI-C quotes `$'…'`: like single quotes but `\` escapes the next char, so
310
+ * `$'a\'b'` is ONE token `a'b` (#965). Whitespace-only bodies (`$'\t'`) never
311
+ * reach here — normalizeShellWhitespaceExpansions folded them to a space first.
312
+ * - Outside quotes: backslash escapes the next char (incl. whitespace → same token),
313
+ * EXCEPT before a newline: that is a line continuation and both characters
314
+ * are removed, exactly as bash joins the lines (#981 — see the branch).
315
+ * - An unquoted newline that is neither continued nor part of a here-doc body
316
+ * is a command SEPARATOR and emits `{ text: ';', quoted: false,
317
+ * operator: 'newline' }` (#981). See the branch for why the text is `;`.
201
318
  * - A token that mixes quoted + unquoted runs (e.g. foo"bar") is `quoted: true`
202
319
  * because part of it came from a quoted run — conservative for the guard.
320
+ * - Redirect operators (#983) are emitted as standalone tokens carrying a
321
+ * `redirect` field: `{ fd: number|null, mode: 'truncate'|'append'|'read'|
322
+ * 'dup'|'heredoc'|'herestring' }`. Recognised (longest-match-first, BEFORE
323
+ * the chain-operator branch so `&>` wins over `&`): `&>>`, `&>`, `>|`,
324
+ * `>>`, `>`, `<<<`, `<<`, `<`, and the fd-prefixed forms `N>`, `N>>`,
325
+ * `N>&M` (fd digits are consumed from the in-progress token, so
326
+ * `2>/dev/null` lexes as fd-2 redirect + `/dev/null` operand instead of
327
+ * one glued token). The `redirect` field appears ONLY on redirect tokens —
328
+ * ordinary tokens keep the exact `{ text, quoted }` shape.
329
+ *
330
+ * ## Comments, here-docs and redirects (#965)
331
+ *
332
+ * Before #965 the lexer knew none of these, and one apostrophe in ordinary
333
+ * English prose (`# don't`) left it stuck in "single" for the rest of the input:
334
+ * everything downstream collapsed into a single `quoted: true` token whose verb
335
+ * resolved to `#`, so NO rule matched. That was a measured, complete bypass of
336
+ * 8 of the 9 `block`-severity rules — `# don't\nrm -rf src/` was ALLOWED.
337
+ *
338
+ * - `#` that STARTS a word outside quotes begins a comment running to
339
+ * end-of-line. The comment text produces no tokens: a comment is not a
340
+ * command, so `ls -la # rm -rf src/` no longer matches (deliberate, tested).
341
+ * - `<<EOF` / `<<'EOF'` / `<<-EOF` bodies are DATA, not command text. The body
342
+ * becomes ONE token with `quoted: true`, which routes it into the existing
343
+ * quoted-payload guard (#641) rather than a second rule: inert for
344
+ * `cat <<EOF`, still matched for `bash <<EOF` because `bash` is in
345
+ * SHELL_EXEC_INTERPRETERS. The delimiter word itself is syntax and emits no
346
+ * token; the `<<` operator does.
347
+ *
348
+ * TWO gates keep that inert-body path from swallowing real command text —
349
+ * both are load-bearing, and each catches inputs the other misses (#970):
350
+ *
351
+ * 1. **Operator position.** `<<` is only a here-doc when it is a REDIRECT.
352
+ * Inside arithmetic it is the left-shift operator, so `arithDepth`
353
+ * tracks `$((`/`((` … `))` and the branch is skipped while depth > 0.
354
+ * A delimiter word immediately followed by `)` is likewise rejected.
355
+ * Without this, `echo $((1<<2))` opened a phantom here-doc whose
356
+ * delimiter was the fragment `2`, and everything after the next newline
357
+ * became one inert `quoted: true` token under the verb `echo`.
358
+ * 2. **Terminator required.** When the delimiter line never arrives, the
359
+ * swallowed text is NOT data — it is either a malformed command or, far
360
+ * more often, proof that gate 1 mis-read the `<<` (`let x=1<<2`, an
361
+ * indented `EOF` without `<<-`). The body is then NOT emitted as an
362
+ * inert token: lexing resumes at the body's first character so the text
363
+ * is read as the commands it is. That is the conservative direction —
364
+ * the unterminated-QUOTE path may fail open (a wedged lexer that blocks
365
+ * every Bash call is worse than a missed enforcement), but a body that
366
+ * swallowed real commands may not.
367
+ * - Redirect operators (`>`, `>>`, `>|`, `N>`, `<`, `<<<`) become standalone
368
+ * tokens so a consumer can tell a redirect target apart from an argument.
369
+ * NOTE for consumers: the redirect TARGET is still emitted as an ordinary
370
+ * token — dropping it here would have silently changed rm-allowlist verdicts
371
+ * from a module that cannot see the allowlist. `&>` deliberately lexes as the
372
+ * existing `&` operator followed by `>`, preserving today's segment split (and
373
+ * with it `rm -rf /tmp/x &> log` staying ALLOWED); the `;`/`|`/`&` branch runs
374
+ * first and is never shadowed.
203
375
  *
204
376
  * @param {string} command
205
- * @returns {Array<{ text: string, quoted: boolean }>}
377
+ * @returns {Array<{ text: string, quoted: boolean, redirect?: { fd: number|null, mode: string } }>}
206
378
  */
207
379
  export function tokenizeCommand(command) {
208
380
  const tokens = [];
@@ -212,7 +384,9 @@ export function tokenizeCommand(command) {
212
384
  let text = '';
213
385
  let started = false; // a token is in progress
214
386
  let sawQuote = false; // any char of the current token came from inside quotes
215
- let state = 'normal'; // 'normal' | 'single' | 'double'
387
+ let state = 'normal'; // 'normal' | 'single' | 'double' | 'ansi'
388
+ let arithDepth = 0; // open `$((` / `((` levels — inside them `<<` is a shift
389
+ const pendingHeredocs = [];
216
390
 
217
391
  const flush = () => {
218
392
  if (started) {
@@ -223,6 +397,19 @@ export function tokenizeCommand(command) {
223
397
  }
224
398
  };
225
399
 
400
+ // A leading all-digit word is the fd of a redirect (`2> log`), not an argument.
401
+ // Returns the digits to prefix onto the operator token; flushes otherwise.
402
+ const takeFdPrefix = () => {
403
+ if (started && !sawQuote && /^\d+$/.test(text)) {
404
+ const digits = text;
405
+ text = '';
406
+ started = false;
407
+ return digits;
408
+ }
409
+ flush();
410
+ return '';
411
+ };
412
+
226
413
  for (let i = 0; i < command.length; i++) {
227
414
  const ch = command[i];
228
415
 
@@ -232,12 +419,13 @@ export function tokenizeCommand(command) {
232
419
  continue;
233
420
  }
234
421
 
235
- if (state === 'double') {
236
- if (ch === '"') { state = 'normal'; continue; }
422
+ if (state === 'double' || state === 'ansi') {
423
+ if (ch === (state === 'double' ? '"' : "'")) { state = 'normal'; continue; }
237
424
  if (ch === '\\' && i + 1 < command.length) {
238
425
  const next = command[i + 1];
239
- // In double quotes, backslash only escapes a small set; keep it simple:
240
- // consume the backslash and take the next char literally.
426
+ // In double / ANSI-C quotes, backslash escapes the next char; keep it
427
+ // simple and take that char literally (this is what makes `$'a\'b'`
428
+ // one token instead of an unbalanced quote — #965).
241
429
  text += next; started = true; sawQuote = true; i++;
242
430
  continue;
243
431
  }
@@ -246,17 +434,200 @@ export function tokenizeCommand(command) {
246
434
  }
247
435
 
248
436
  // state === 'normal'
437
+
438
+ // A `#` in WORD position comments out the rest of the line. Leave the newline
439
+ // itself for the heredoc/whitespace handling below.
440
+ if (ch === '#' && !started) {
441
+ while (i + 1 < command.length && command[i + 1] !== '\n') i++;
442
+ continue;
443
+ }
444
+
445
+ // A newline with here-docs pending: their bodies start here and are DATA —
446
+ // but ONLY while each one actually finds its terminator (gate 2, #970). The
447
+ // first unterminated body abandons here-doc mode: `j` still points at that
448
+ // body's first character, so lexing resumes there and the text is read as
449
+ // the commands it is instead of collapsing into one inert quoted token.
450
+ if (ch === '\n' && pendingHeredocs.length > 0) {
451
+ flush();
452
+ let j = i + 1;
453
+ let lastTerm = false;
454
+ while (pendingHeredocs.length > 0) {
455
+ const { delim, stripTabs } = pendingHeredocs.shift();
456
+ const { body, end, terminated } = readHeredocBody(command, j, delim, stripTabs);
457
+ // The reset is load-bearing: a first here-doc may terminate (lastTerm
458
+ // true) while a SECOND one on the same line does not — leaving lastTerm
459
+ // set would rewind onto the unterminated body's first char instead of a
460
+ // real newline (#999).
461
+ if (!terminated) { lastTerm = false; pendingHeredocs.length = 0; break; }
462
+ lastTerm = true;
463
+ if (body.length > 0) tokens.push({ text: body, quoted: true });
464
+ j = end;
465
+ }
466
+ // `readHeredocBody` returns `end` pointing PAST the newline that closes the
467
+ // terminator line, so `j` sits at the first char of the NEXT command and
468
+ // the separating newline is swallowed. Rewind onto it (when there is one —
469
+ // a terminator at input-end has none) so the newline reaches the separator
470
+ // branch below and `cat <<EOF\nbody\nEOF\nrm -rf /tmp/ok` splits into two
471
+ // segments. Without this the trailing command glued into the here-doc
472
+ // verb's segment and an allowlisted rm target failed closed (#999 FP).
473
+ if (lastTerm && command[j - 1] === '\n') j -= 1;
474
+ i = j - 1;
475
+ continue;
476
+ }
477
+
249
478
  if (ch === "'") { state = 'single'; started = true; continue; }
250
479
  if (ch === '"') { state = 'double'; started = true; continue; }
480
+ if (ch === '$' && command[i + 1] === "'") { state = 'ansi'; started = true; i++; continue; }
481
+
482
+ // Arithmetic context (gate 1, #970). `$((`/`((` open a level, `))` closes
483
+ // one. The characters are still appended verbatim — the ONLY effect is that
484
+ // the here-doc branch below stands down while depth > 0, because there `<<`
485
+ // is the left-shift operator, not a redirect.
486
+ if (ch === '$' && command[i + 1] === '(' && command[i + 2] === '(') {
487
+ arithDepth++; text += '$(('; started = true; i += 2;
488
+ continue;
489
+ }
490
+ if (ch === '(' && command[i + 1] === '(' && !started) {
491
+ arithDepth++; text += '(('; started = true; i++;
492
+ continue;
493
+ }
494
+ if (arithDepth > 0 && ch === ')' && command[i + 1] === ')') {
495
+ arithDepth--; text += '))'; started = true; i++;
496
+ continue;
497
+ }
498
+
251
499
  if (ch === '\\' && i + 1 < command.length) {
500
+ // Backslash-NEWLINE is a LINE CONTINUATION, not an escape (#981). bash
501
+ // joins the lines and BOTH characters vanish before word splitting, so
502
+ // this branch must leave no trace: no text, and `started` untouched (a
503
+ // trailing continuation must not flush a phantom empty token).
504
+ //
505
+ // Pre-#981 the newline was appended as literal text and the guard saw a
506
+ // phantom `"\n"` token. Measured consequences, both wrong in a different
507
+ // direction: `rm -rf \<LF> /tmp/ok` (argv `rm -rf /tmp/ok`, allowlisted)
508
+ // was DENIED because `"\n"` read as a second, non-allowlisted target; and
509
+ // `git push \<LF>--force` was ALLOWED because `"\n--force"` is not the
510
+ // `--force` flag any rule looks for. Eliding converges the token stream
511
+ // on bash's argv, which is the only defensible reference.
512
+ if (command[i + 1] === '\n') { i++; continue; }
252
513
  text += command[i + 1]; started = true; i++;
253
514
  continue;
254
515
  }
516
+
517
+ // A REAL (non-continued) newline is a command separator, exactly like `;`
518
+ // in the POSIX grammar (#981). MUST stay below the pending-here-doc branch
519
+ // above: a newline that opens or ends a here-doc body is consumed there and
520
+ // never reaches this point, so a body line can never become a separator.
521
+ //
522
+ // Token shape: `text: ';'` is the canonical spelling of its separator class
523
+ // — every text-keyed consumer (splitSegments here, the ledger guard,
524
+ // scope-gate's SHELL_SEPARATOR_OPS) then classifies it correctly without a
525
+ // per-consumer edit, which a `text: '\n'` would silently NOT do (it would
526
+ // land in scope-gate's word stream and could displace a `sed -i` file
527
+ // argument). `operator: 'newline'` keeps the origin distinguishable for
528
+ // consumers that care, mirroring how `redirect` marks redirect tokens.
529
+ if (ch === '\n') {
530
+ flush();
531
+ tokens.push({ text: ';', quoted: false, operator: 'newline' });
532
+ continue;
533
+ }
534
+
255
535
  if (/\s/.test(ch)) { flush(); continue; }
256
536
 
537
+ // Inside `$(( … ))` / `(( … ))` every `<` / `>` is a shift or comparison
538
+ // operator, never a redirect (gate 1, #970) — append verbatim so the
539
+ // redirect branches below never tear an arithmetic expression apart.
540
+ if (arithDepth > 0 && (ch === '<' || ch === '>')) { text += ch; started = true; continue; }
541
+
542
+ // Redirect operators (#983) — longest-match-first, and BEFORE the
543
+ // chain-operator branch below so `&>` / `&>>` win over the `&` operator.
544
+ if (ch === '&' && command[i + 1] === '>') {
545
+ flush();
546
+ if (command[i + 2] === '>') {
547
+ tokens.push({ text: '&>>', quoted: false, redirect: { fd: null, mode: 'append' } });
548
+ i += 2;
549
+ } else {
550
+ tokens.push({ text: '&>', quoted: false, redirect: { fd: null, mode: 'truncate' } });
551
+ i += 1;
552
+ }
553
+ continue;
554
+ }
555
+ if (ch === '<') {
556
+ if (command[i + 1] === '<' && command[i + 2] === '<') {
557
+ flush();
558
+ tokens.push({ text: '<<<', quoted: false, redirect: { fd: null, mode: 'herestring' } });
559
+ i += 2;
560
+ continue;
561
+ }
562
+ if (command[i + 1] === '<') {
563
+ // Here-doc `<<WORD` / `<<-WORD` — merged #965/#970 machinery: the
564
+ // delimiter is SYNTAX (consumed, never a token of its own); the body
565
+ // is consumed at the next newline as ONE quoted token, but ONLY while
566
+ // every pending here-doc finds its terminator (gate 2 — see the `\n`
567
+ // branch above). A delimiter butted against `)` is rejected
568
+ // (belt-and-braces for an arithmetic form the depth counter did not
569
+ // see): the operator token is still emitted, no body is queued.
570
+ let j = i + 2;
571
+ let stripTabs = false;
572
+ if (command[j] === '-') { stripTabs = true; j++; }
573
+ let k = j;
574
+ while (command[k] === ' ' || command[k] === '\t') k++;
575
+ const { value, end } = readDelimiterWord(command, k);
576
+ const fdDigits = takeFdPrefix();
577
+ const fdNum = fdDigits === '' ? null : Number.parseInt(fdDigits, 10);
578
+ const opText = `${fdDigits}<<${stripTabs ? '-' : ''}`;
579
+ tokens.push({ text: opText, quoted: false, redirect: { fd: fdNum, mode: 'heredoc' } });
580
+ if (value && command[end] !== ')') {
581
+ pendingHeredocs.push({ delim: value, stripTabs });
582
+ i = end - 1;
583
+ } else {
584
+ i = j - 1;
585
+ }
586
+ continue;
587
+ }
588
+ const fdDigits = takeFdPrefix();
589
+ const fdNum = fdDigits === '' ? null : Number.parseInt(fdDigits, 10);
590
+ tokens.push({ text: `${fdDigits}<`, quoted: false, redirect: { fd: fdNum, mode: 'read' } });
591
+ continue;
592
+ }
593
+ if (ch === '>') {
594
+ // fd-prefix (`2>/dev/null`, `2>>log`, `2>&1`): a purely-numeric UNQUOTED
595
+ // in-progress token is the IO_NUMBER — consume it as the fd instead of
596
+ // flushing it as an ordinary word (matches bash IO_NUMBER lexing; a
597
+ // quoted "2" stays a word, exactly as bash treats it).
598
+ let fd = null;
599
+ if (started && !sawQuote && /^[0-9]+$/.test(text)) {
600
+ fd = Number.parseInt(text, 10);
601
+ text = '';
602
+ started = false;
603
+ } else {
604
+ flush();
605
+ }
606
+ const fdText = fd === null ? '' : String(fd);
607
+ if (command[i + 1] === '>') {
608
+ tokens.push({ text: `${fdText}>>`, quoted: false, redirect: { fd, mode: 'append' } });
609
+ i += 1;
610
+ } else if (command[i + 1] === '&' && /[0-9]/.test(command[i + 2] ?? '')) {
611
+ // N>&M / >&M — fd duplication; the target fd is inline, no operand word.
612
+ let j = i + 2;
613
+ let dupTarget = '';
614
+ while (j < command.length && /[0-9]/.test(command[j])) { dupTarget += command[j]; j++; }
615
+ tokens.push({ text: `${fdText}>&${dupTarget}`, quoted: false, redirect: { fd, mode: 'dup' } });
616
+ i = j - 1;
617
+ } else if (command[i + 1] === '|') {
618
+ tokens.push({ text: `${fdText}>|`, quoted: false, redirect: { fd, mode: 'truncate' } });
619
+ i += 1;
620
+ } else {
621
+ tokens.push({ text: `${fdText}>`, quoted: false, redirect: { fd, mode: 'truncate' } });
622
+ }
623
+ continue;
624
+ }
625
+
257
626
  // Unquoted shell control operators become standalone tokens so chain-splitting
258
627
  // and per-segment verb detection work even without surrounding whitespace
259
628
  // (e.g. `/tmp/x;rm -rf src/`). Recognised: ; && || | & — longest match first.
629
+ // The redirect branches above win for `&>` / `&>>` (one token since the
630
+ // #983 redirect token class), so this branch only ever sees a bare chain `&`.
260
631
  if (ch === ';' || ch === '|' || ch === '&') {
261
632
  flush();
262
633
  let op = ch;
@@ -269,8 +640,9 @@ export function tokenizeCommand(command) {
269
640
  }
270
641
 
271
642
  // Unterminated quote → flush whatever accumulated (mark quoted so the guard treats
272
- // the dangling text conservatively).
273
- if (state === 'single' || state === 'double') sawQuote = true;
643
+ // the dangling text conservatively). Deliberately fail-OPEN in the lexer: a wedged
644
+ // guard that blocks every Bash call is strictly worse than a missed enforcement.
645
+ if (state !== 'normal') sawQuote = true;
274
646
  flush();
275
647
 
276
648
  return tokens;
@@ -278,8 +650,20 @@ export function tokenizeCommand(command) {
278
650
 
279
651
  /**
280
652
  * Split a tokenized command into chained segments on shell control operators
281
- * (`;`, `&&`, `||`, `|`, `&`). Only UNQUOTED single-token operators split; an
282
- * operator that arrived inside quotes stays part of its segment.
653
+ * (`;`, `&&`, `||`, `|`, `&`) and on newline separators (#981). Only UNQUOTED
654
+ * single-token operators split; an operator that arrived inside quotes stays
655
+ * part of its segment.
656
+ *
657
+ * The newline separator is checked by its `operator` field as well as its text,
658
+ * so the split survives a future change to that token's spelling. Because a
659
+ * separator token is CONSUMED here, it can never reach a per-segment operand
660
+ * loop — `parseRmTargets` in hooks/pre-bash-destructive-guard.mjs iterates
661
+ * segments, so it never sees a newline token and needed no change for #981.
662
+ *
663
+ * Exported as `splitChainSegments` (see the alias export below):
664
+ * hooks/pre-bash-destructive-guard.mjs consumes it for wrapper-aware rm
665
+ * parsing (#982/#983). The hook previously kept a drift-prone local mirror
666
+ * of this splitter (regex-based operator set vs. this Set) — W4 B1.
283
667
  *
284
668
  * @param {Array<{ text: string, quoted: boolean }>} tokens
285
669
  * @returns {Array<Array<{ text: string, quoted: boolean }>>}
@@ -289,7 +673,7 @@ function splitSegments(tokens) {
289
673
  let current = [];
290
674
  const operators = new Set([';', '&&', '||', '|', '&']);
291
675
  for (const tok of tokens) {
292
- if (!tok.quoted && operators.has(tok.text)) {
676
+ if (!tok.quoted && (tok.operator === 'newline' || operators.has(tok.text))) {
293
677
  segments.push(current);
294
678
  current = [];
295
679
  continue;
@@ -300,37 +684,299 @@ function splitSegments(tokens) {
300
684
  return segments.filter((s) => s.length > 0);
301
685
  }
302
686
 
687
+ // Public alias — the single source of truth for chain-segment splitting shared
688
+ // with hooks/pre-bash-destructive-guard.mjs (W4 B1; internal call sites keep
689
+ // the short name).
690
+ export { splitSegments as splitChainSegments };
691
+
692
+ const ENV_ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
693
+
303
694
  /**
304
- * Resolve the effective argv[0] (the command verb) for a chain segment, skipping
305
- * leading `VAR=value` env assignments and unwrapping `env …`/`command …` prefixes.
306
- * Returns the bare program name (basename, no path) or null.
695
+ * Transparent process-wrapper table for verb resolution (#982), keyed by
696
+ * basename. Each spec describes how to skip a wrapper's own options so the
697
+ * REAL verb it delegates to resolves (`sudo -u root bash -c '…'` → `bash`).
698
+ *
699
+ * Spec fields (all optional):
700
+ * - argFlags: Set of flags that consume a SEPARATE next-token argument.
701
+ * - fileArgFlags: Subset of `argFlags` whose operand is a FILE THE WRAPPER
702
+ * WRITES (`time -o report`), not merely an option value.
703
+ * Entries so flagged are reported with `writesFile: true`
704
+ * in `wrapperArgs` (#992). Membership is per-wrapper and
705
+ * deliberately narrow — measured counter-examples that must
706
+ * NOT be in it: `stdbuf -o 0` (a BUFFERING MODE), `nice -n
707
+ * 10` (a priority), `sudo -u root` (a user name). A blanket
708
+ * "every argFlag operand is a file" rule would block all
709
+ * three; the table is the discriminator.
710
+ * - shellFlags: Set of flags that make the wrapper itself spawn a shell
711
+ * (`sudo -i` / `sudo -s`) — resolution yields the synthetic
712
+ * verb `sh`, so the quoted-payload guard treats the segment
713
+ * as an interpreter.
714
+ * - envAssignments: skip unquoted `VAR=value` tokens among the options.
715
+ * - splitString: `env -S/--split-string <string>` — the string is a shell
716
+ * command line; collected as a recursion payload (same
717
+ * treatment as a `-c` payload).
718
+ * - positionals: number of positional arguments to skip before the verb
719
+ * (`timeout DURATION cmd …`).
720
+ *
721
+ * Unknown single-token flags (`-i`, `--foreground`, attached forms like `-o0`
722
+ * / `-n19`, legacy `nice -10`, `--user=root`) are skipped generically; `--`
723
+ * ends option parsing. Deliberately NOT listed:
724
+ * - `su` — interpreter (SHELL_EXEC_INTERPRETERS), its `-c` payload executes;
725
+ * - `xargs` — interpreter; unwrapping it would LOOSEN the guard;
726
+ * - `flock` / `setsid` / `ionice` — scope cut (#982, operator-approved).
727
+ *
728
+ * `time` IS listed (merge of the #970 here-doc line of work): the here-doc
729
+ * design's safety argument is that a body fed to an interpreter still matches
730
+ * because `bash` is in SHELL_EXEC_INTERPRETERS — that only holds when `bash`
731
+ * is the RESOLVED verb, so `sudo bash <<EOF` / `time bash <<EOF` must unwrap.
732
+ * This table is ALSO the alignment target for the ledger-guard's
733
+ * `VERB_PREFIXES` copy (see #991) — do not fork a second wrapper list.
734
+ *
735
+ * Exported so downstream guards (ledger) can converge on the same table.
736
+ */
737
+ export const WRAPPER_UNWRAP = new Map([
738
+ ['sudo', {
739
+ argFlags: new Set([
740
+ '-u', '-g', '-h', '-p', '-C', '-D', '-R', '-r', '-t', '-T', '-U',
741
+ '--user', '--group', '--host', '--prompt', '--close-from', '--chdir',
742
+ '--chroot', '--role', '--type', '--command-timeout', '--other-user',
743
+ ]),
744
+ shellFlags: new Set(['-i', '-s', '--login', '--shell']),
745
+ envAssignments: true,
746
+ }],
747
+ ['doas', {
748
+ argFlags: new Set(['-u', '-C', '-a']),
749
+ shellFlags: new Set(['-s']),
750
+ }],
751
+ // `-P altpath` is BSD/macOS env(1)'s "search THIS path for the utility"
752
+ // option. It was missing, so `-P` was skipped as a one-token boolean and its
753
+ // operand landed in verb position: `env -P /bin:/usr/bin bash -c 'rm -rf
754
+ // /etc'` resolved to the verb `bin` (basename of `/bin:/usr/bin`), the
755
+ // segment was no longer an interpreter, and the quoted payload went inert —
756
+ // measured ALLOW (#992). Note the contrast with `sudo -P`, which is
757
+ // `--preserve-groups`, a BOOLEAN — the same letter is value-taking for one
758
+ // wrapper and not for the other, which is why this table is per-wrapper.
759
+ ['env', {
760
+ argFlags: new Set(['-u', '--unset', '-C', '--chdir', '-P']),
761
+ envAssignments: true,
762
+ splitString: true,
763
+ }],
764
+ ['command', {}],
765
+ ['nohup', {}],
766
+ // `-o FILE` is the BSD/GNU `time` report destination and it TRUNCATES without
767
+ // `-a` (BSD time(1): "If file exists and the -a flag is not specified, the
768
+ // file will be overwritten"). With an empty spec the operand was read as the
769
+ // verb, so `/usr/bin/time -o LEDGER tee -a X` resolved to `LEDGER` and hid
770
+ // the real write verb (#988 T3). Only the EXTERNAL `time` takes flags — the
771
+ // bash keyword rejects `-o` outright — so consuming them here cannot
772
+ // mis-parse a keyword invocation, which never carries `-o` in the first place.
773
+ // `-f FORMAT` / `--format FORMAT` is GNU time(1) (not BSD/macOS, where the
774
+ // binary rejects it — so this row is Linux-CI-relevant only). Without it the
775
+ // format string is skipped as a boolean and the FOLLOWING token is read as
776
+ // the verb: measured `/usr/bin/time -f %e npm test` → verb `%e` (#992).
777
+ ['time', {
778
+ argFlags: new Set(['-o', '--output', '-f', '--format']),
779
+ fileArgFlags: new Set(['-o', '--output']),
780
+ }],
781
+ ['timeout', {
782
+ argFlags: new Set(['-k', '--kill-after', '-s', '--signal']),
783
+ positionals: 1,
784
+ }],
785
+ ['nice', {
786
+ argFlags: new Set(['-n', '--adjustment']),
787
+ }],
788
+ ['stdbuf', {
789
+ argFlags: new Set(['-i', '-o', '-e', '--input', '--output', '--error']),
790
+ }],
791
+ ]);
792
+
793
+ /**
794
+ * Resolve the effective argv[0] (the command verb) for a chain segment:
795
+ * skips leading `VAR=value` env assignments, then unwraps chained transparent
796
+ * wrappers per WRAPPER_UNWRAP (`sudo env FOO=1 nice -n 10 bash …` → `bash`),
797
+ * collecting any wrapper-level command-string payloads (`env -S '…'`) on the
798
+ * way (#982).
799
+ *
800
+ * Skipping a prefix can only move verb resolution TOWARDS the real command, and
801
+ * a skipped token is either a wrapper name, an option or a duration — never an
802
+ * interpreter — so this cannot turn a match into a miss.
803
+ *
804
+ * Return contract (ADDITIVE — `wrapperArgs` was appended in #988 T3; existing
805
+ * consumers destructuring `{ verb, index, payloads }` are unaffected):
806
+ *
807
+ * DUAL PARSE (#1000). An unknown dash-flag is ambiguous: this table cannot know
808
+ * whether `env -Q x bash -c '…'` means "`-Q` is a boolean, `x` is the verb" or
809
+ * "`-Q` takes `x`, `bash` is the verb". The resolver therefore reads the
810
+ * segment TWICE — parse A treats unknown flags as booleans (byte-identical to
811
+ * the pre-#1000 behaviour, and still the primary result), parse B treats them
812
+ * as value-taking — and reports the second reading as `alt` when it disagrees.
813
+ * Both readings are then judged, so a deny in EITHER is a deny: the safe
814
+ * direction, since guessing wrong in the boolean direction silently hid an
815
+ * interpreter behind an unrecognised flag (the measured #1000 bypass).
816
+ * `alt` is present ONLY when an unknown flag was skipped AND the two readings
817
+ * resolve a different verb or index; it is never nested (no `alt.alt`).
307
818
  *
308
819
  * @param {Array<{ text: string, quoted: boolean }>} segment
309
- * @returns {string|null}
820
+ * @returns {{ verb: string|null, index: number, payloads: string[],
821
+ * wrapperArgs: Array<{ wrapper: string, flag: string, value: string|null,
822
+ * writesFile?: true }>,
823
+ * alt?: { verb: string|null, index: number, payloads: string[],
824
+ * wrapperArgs: Array<{ wrapper: string, flag: string,
825
+ * value: string|null, writesFile?: true }> } }}
826
+ * verb — bare program basename (or synthetic `sh` for `sudo -i`/`-s`), null
827
+ * when the segment exhausts in wrappers; index — token index of the resolved
828
+ * verb (-1 when null); payloads — command strings a wrapper will execute;
829
+ * wrapperArgs — the value-taking wrapper flags consumed on the way to the
830
+ * verb, in encounter order. `wrapper` is the wrapper's basename (`time`),
831
+ * `flag` the option as written (`-o`, `--output`), `value` its operand
832
+ * (`null` when the flag ended the segment). Both the separated (`-o FILE`)
833
+ * and the attached long form (`--output=FILE`) are reported.
834
+ *
835
+ * `writesFile: true` (#992) marks the entries whose operand is a FILE THE
836
+ * WRAPPER WRITES, per the spec's `fileArgFlags`. The key is present ONLY when
837
+ * true — an entry without it keeps the exact pre-#992 `{ wrapper, flag,
838
+ * value }` shape, so a `toEqual` on a non-file entry is unaffected. This is
839
+ * the answer to the question the caller actually has ("is this operand a
840
+ * write target?"), which used to be re-derived from a second table
841
+ * (`WRAPPER_FILE_FLAGS` in hooks/pre-bash-sessions-ledger-guard.mjs). That
842
+ * copy was replaced by `wa.writesFile` in #996.1 — the knowledge lives here,
843
+ * next to the grammar it belongs to.
844
+ * `/usr/bin/time -o <ledger> npm test` truncates `<ledger>` while the verb is
845
+ * `npm`; extractRedirectTargets surfaces exactly these entries so the
846
+ * redirect denylist sees them too.
847
+ *
848
+ * `alt` (#1000) — the value-taking reading of the same segment, present only
849
+ * under the conditions above. Consumers that judge (matchSegments,
850
+ * collectRedirectTargets) must consider BOTH readings; consumers that need a
851
+ * single token position (`parseRmTargets`, the scope gate) keep reading the
852
+ * primary `verb`/`index` only — `alt.index` addresses a DIFFERENT reading of
853
+ * the token stream and must never be fed to a positional walk.
310
854
  */
311
- function segmentVerb(segment) {
855
+ function resolveCore(segment, unknownFlagsTakeValue) {
856
+ const payloads = [];
857
+ const wrapperArgs = [];
858
+ let sawUnknownFlag = false;
312
859
  let i = 0;
313
860
  // Skip leading FOO=bar env assignments (unquoted).
314
- while (i < segment.length && !segment[i].quoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(segment[i].text)) {
861
+ while (i < segment.length && !segment[i].quoted && ENV_ASSIGN_RE.test(segment[i].text)) {
315
862
  i++;
316
863
  }
317
- // Unwrap `env [VAR=val …]` and `command` prefixes that delegate to a real verb.
318
864
  while (i < segment.length) {
319
- const raw = segment[i].text;
320
- const verb = raw.replace(/^.*\//, ''); // basename
321
- if (verb === 'env') {
322
- i++;
323
- // env may carry its own VAR=val assignments before the real command
324
- while (i < segment.length && !segment[i].quoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(segment[i].text)) {
865
+ const wrapper = segment[i].text.replace(/^.*\//, ''); // basename
866
+ const spec = WRAPPER_UNWRAP.get(wrapper);
867
+ if (!spec) break;
868
+ i++; // consume the wrapper word
869
+ let sawShellFlag = false;
870
+ while (i < segment.length) {
871
+ const tok = segment[i];
872
+ const text = tok.text;
873
+ if (spec.envAssignments && !tok.quoted && ENV_ASSIGN_RE.test(text)) { i++; continue; }
874
+ if (text === '--') { i++; break; }
875
+ if (!text.startsWith('-') || text === '-') break;
876
+ if (spec.splitString
877
+ && (text === '-S' || text === '--split-string'
878
+ || text.startsWith('--split-string=') || text.startsWith('-S'))) {
879
+ if (text.startsWith('--split-string=')) {
880
+ payloads.push(text.slice('--split-string='.length));
881
+ } else if (text !== '-S' && text !== '--split-string') {
882
+ payloads.push(text.slice(2)); // attached form: -S'string'
883
+ } else if (i + 1 < segment.length) {
884
+ payloads.push(segment[i + 1].text);
885
+ i++;
886
+ }
325
887
  i++;
888
+ continue;
326
889
  }
327
- continue;
890
+ if (spec.shellFlags && spec.shellFlags.has(text)) { sawShellFlag = true; i++; continue; }
891
+ if (spec.argFlags && spec.argFlags.has(text)) {
892
+ // Separated form `-o FILE`: record the operand, then skip BOTH tokens
893
+ // exactly as before (token accounting unchanged — recording only).
894
+ const entry = {
895
+ wrapper,
896
+ flag: text,
897
+ value: i + 1 < segment.length ? segment[i + 1].text : null,
898
+ };
899
+ if (spec.fileArgFlags?.has(text)) entry.writesFile = true;
900
+ wrapperArgs.push(entry);
901
+ i += 2;
902
+ continue;
903
+ }
904
+ if (spec.argFlags) {
905
+ // Attached long form `--output=FILE`. Consumes ONE token either way —
906
+ // this branch only records the operand the fall-through would drop.
907
+ const eq = text.indexOf('=');
908
+ if (eq > 0 && spec.argFlags.has(text.slice(0, eq))) {
909
+ const flag = text.slice(0, eq);
910
+ const entry = { wrapper, flag, value: text.slice(eq + 1) };
911
+ if (spec.fileArgFlags?.has(flag)) entry.writesFile = true;
912
+ wrapperArgs.push(entry);
913
+ }
914
+ }
915
+ // Unknown / boolean / attached-value flag. A dash token that survived
916
+ // envAssignments, `--`, the non-dash break, splitString, shellFlags,
917
+ // argFlags AND the attached-`=` form is one this table does not know —
918
+ // the ONLY place the two readings differ (#1000). No wrapperArgs entry is
919
+ // recorded in the value-taking reading: an unknown flag is by
920
+ // construction absent from fileArgFlags, so `writesFile` can never be
921
+ // invented for it.
922
+ sawUnknownFlag = true;
923
+ i += (unknownFlagsTakeValue && i + 1 < segment.length) ? 2 : 1;
328
924
  }
329
- if (verb === 'command') { i++; continue; }
330
- break;
925
+ for (let p = spec.positionals ?? 0; p > 0 && i < segment.length; p--) i++;
926
+ if (sawShellFlag) return { verb: 'sh', index: i, payloads, wrapperArgs, sawUnknownFlag };
927
+ }
928
+ if (i >= segment.length) return { verb: null, index: -1, payloads, wrapperArgs, sawUnknownFlag };
929
+ return {
930
+ verb: segment[i].text.replace(/^.*\//, ''),
931
+ index: i,
932
+ payloads,
933
+ wrapperArgs,
934
+ sawUnknownFlag,
935
+ };
936
+ }
937
+
938
+ /**
939
+ * Strip the internal `sawUnknownFlag` marker from a resolveCore result, leaving
940
+ * the public shape. The key must be ABSENT (not undefined-valued) so a strict
941
+ * `toEqual` on an unambiguous resolution keeps passing.
942
+ *
943
+ * @param {{ verb: string|null, index: number, payloads: string[],
944
+ * wrapperArgs: object[], sawUnknownFlag: boolean }} r
945
+ * @returns {{ verb: string|null, index: number, payloads: string[], wrapperArgs: object[] }}
946
+ */
947
+ function stripCore(r) {
948
+ return { verb: r.verb, index: r.index, payloads: r.payloads, wrapperArgs: r.wrapperArgs };
949
+ }
950
+
951
+ export function resolveSegmentVerb(segment) {
952
+ const a = resolveCore(segment, false); // parse A — byte-identical to pre-#1000
953
+ if (!a.sawUnknownFlag) return stripCore(a); // unambiguous → no `alt` key at all
954
+ const b = resolveCore(segment, true); // parse B — unknown flags take a value
955
+ if (b.verb === a.verb && b.index === a.index) return stripCore(a);
956
+ return { ...stripCore(a), alt: stripCore(b) };
957
+ }
958
+
959
+ /**
960
+ * Shell verbs whose `-c <payload>` argument is a command line the shell will
961
+ * execute — recursion candidates for matchSegments (#982). `su` participates
962
+ * (`su root -c '…'`); the payload token is the one following `-c` or a bundled
963
+ * short-flag group ending in `c` (`-lc`, `-ec`).
964
+ */
965
+ const DASH_C_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'su']);
966
+
967
+ /**
968
+ * Collect `-c`-style payload strings after the verb within a segment.
969
+ *
970
+ * @param {Array<{ text: string, quoted: boolean }>} segment
971
+ * @param {number} verbIndex
972
+ * @returns {string[]}
973
+ */
974
+ function dashCPayloads(segment, verbIndex) {
975
+ const payloads = [];
976
+ for (let i = verbIndex + 1; i < segment.length - 1; i++) {
977
+ if (/^-[A-Za-z]*c$/.test(segment[i].text)) payloads.push(segment[i + 1].text);
331
978
  }
332
- if (i >= segment.length) return null;
333
- return segment[i].text.replace(/^.*\//, '');
979
+ return payloads;
334
980
  }
335
981
 
336
982
  /**
@@ -364,19 +1010,599 @@ function quotedTokensMatch(segment, re) {
364
1010
  return false;
365
1011
  }
366
1012
 
1013
+ /**
1014
+ * How far a redirect token's syntax reaches: the index of the LAST token this
1015
+ * redirect owns, starting at the redirect token itself (#1002).
1016
+ *
1017
+ * The grammar rule, stated ONCE (it was coded three times independently before
1018
+ * this export existed):
1019
+ * - `dup` (`2>&1`) carries its target INLINE in the operator token — it owns
1020
+ * no following word.
1021
+ * - `heredoc` (`<<EOF`) consumes its delimiter as SYNTAX inside the lexer. A
1022
+ * terminated body arrives as a QUOTED token; an UNTERMINATED here-doc
1023
+ * leaves real command tokens behind, which must stay visible to the caller
1024
+ * — skipping a word here would eat the next real command (#970).
1025
+ * - EVERY other mode — `truncate` (`>`), `append` (`>>`), `read` (`<`) and
1026
+ * `herestring` (`<<<`) — owns the next word. `herestring` deliberately so:
1027
+ * in `rm -rf /tmp/x <<< /etc/passwd` the word after `<<<` is inline data
1028
+ * for the redirect, not an `rm` operand, and reading it as one would
1029
+ * invent a deletion target the command never had.
1030
+ * - A next token that is ITSELF a redirect is never an operand (`> >> x`):
1031
+ * the dangling redirect owns nothing.
1032
+ *
1033
+ * This does NOT answer "does this redirect name a filesystem target" — that is
1034
+ * a SEPARATE rule, owned by collectRedirectTargets, which additionally excludes
1035
+ * `herestring` (inline data names no file). Operand OWNERSHIP and target
1036
+ * REPORTABILITY are different questions with different answers for `<<<`.
1037
+ *
1038
+ * @param {Array<{ text: string, quoted: boolean, redirect?: { mode: string } }>} segment
1039
+ * @param {number} i — index of the redirect token
1040
+ * @returns {number} `i` when the redirect owns no operand word, else `i + 1`
1041
+ */
1042
+ export function redirectSpanEnd(segment, i) {
1043
+ const tok = segment[i];
1044
+ if (!tok || !tok.redirect) return i;
1045
+ const hasOperandWord = tok.redirect.mode !== 'dup' && tok.redirect.mode !== 'heredoc';
1046
+ if (hasOperandWord && i + 1 < segment.length && !segment[i + 1].redirect) {
1047
+ return i + 1;
1048
+ }
1049
+ return i;
1050
+ }
1051
+
367
1052
  /**
368
1053
  * Test whether a blocked pattern occurs OUTSIDE quoted tokens within a segment.
369
1054
  * Reconstructs the unquoted skeleton (quoted tokens replaced by a single space
370
1055
  * placeholder so they cannot bridge an adjacent-token match) and applies the
371
1056
  * boundary regex.
372
1057
  *
373
- * @param {Array<{ text: string, quoted: boolean }>} segment
1058
+ * Redirect tokens (#983) and their operand word are ALSO replaced by space
1059
+ * placeholders (same no-bridging treatment as quoted tokens): a redirect
1060
+ * target is a filename argument to the shell, not part of the command verb
1061
+ * surface — `rm -rf /tmp/ok > out.log` must not feed `>` / `out.log` into
1062
+ * the pattern skeleton. `dup`-mode redirects (`2>&1`) carry their target
1063
+ * inline and consume no operand.
1064
+ *
1065
+ * @param {Array<{ text: string, quoted: boolean, redirect?: object }>} segment
374
1066
  * @param {RegExp} re
375
1067
  * @returns {boolean}
376
1068
  */
377
1069
  function unquotedSegmentMatch(segment, re) {
378
- const skeleton = segment.map((t) => (t.quoted ? ' ' : t.text)).join(' ');
379
- return re.test(skeleton);
1070
+ const parts = [];
1071
+ for (let i = 0; i < segment.length; i++) {
1072
+ const tok = segment[i];
1073
+ if (tok.redirect) {
1074
+ parts.push(' ');
1075
+ // Operand-span rule lives in redirectSpanEnd (#1002) — one grammar, one
1076
+ // place. `end > i` is exactly the old inline predicate.
1077
+ const end = redirectSpanEnd(segment, i);
1078
+ if (end > i) { parts.push(' '); i = end; } // operand word belongs to the redirect
1079
+ continue;
1080
+ }
1081
+ parts.push(tok.quoted ? ' ' : tok.text);
1082
+ }
1083
+ return re.test(parts.join(' '));
1084
+ }
1085
+
1086
+ /**
1087
+ * Payload-recursion bounds (#982). Depth counts nested payload evaluations
1088
+ * (top-level command = depth 0); the budget caps TOTAL payload evaluations per
1089
+ * commandMatchesBlocked call so a hostile deeply-chained command cannot turn
1090
+ * the hook hot-path into an amplification vector.
1091
+ */
1092
+ const MAX_PAYLOAD_DEPTH = 3;
1093
+ const MAX_PAYLOAD_EVALUATIONS = 32;
1094
+
1095
+ /**
1096
+ * Build the DE-DUPLICATED payload set for a segment: the UNION of the primary
1097
+ * reading's payloads and the ambiguous alt reading's payloads (#1000).
1098
+ *
1099
+ * The dual-parse union MUST be de-duped BEFORE budget accounting so an ambiguous
1100
+ * segment is charged against the shared MAX_PAYLOAD_EVALUATIONS budget ONCE per
1101
+ * DISTINCT payload — never the (primary + alt) sum (HIGH-1 remediation (a)). The
1102
+ * pre-#1000 zero-charge for a non-interpreter primary verb is preserved
1103
+ * naturally: when the alt reading adds no new distinct payload the set stays
1104
+ * empty and no budget is consumed.
1105
+ *
1106
+ * Both matchSegments AND collectRedirectTargets consume this single helper so the
1107
+ * union is applied IDENTICALLY on the match surface and the redirect surface. A
1108
+ * redirect-recursion that walked parse A only was a denylist bypass
1109
+ * (`env -Q x bash -c 'echo pwned > CLAUDE.md'` resolved to non-interpreter `x` in
1110
+ * parse A, so its redirect target was never collected — HIGH-2).
1111
+ *
1112
+ * @param {Array<{ text: string, quoted: boolean }>} segment
1113
+ * @param {{ verb: string|null, index: number, payloads: string[],
1114
+ * alt?: { verb: string|null, index: number, payloads: string[] } }} resolved
1115
+ * @returns {string[]} distinct payload strings (insertion-ordered)
1116
+ */
1117
+ function dedupedSegmentPayloads(segment, resolved) {
1118
+ const payloadSet = new Set(resolved.payloads);
1119
+ if (resolved.verb && DASH_C_SHELLS.has(resolved.verb)) {
1120
+ for (const p of dashCPayloads(segment, resolved.index)) payloadSet.add(p);
1121
+ }
1122
+ if (resolved.alt) {
1123
+ for (const p of resolved.alt.payloads) payloadSet.add(p);
1124
+ if (resolved.alt.verb && DASH_C_SHELLS.has(resolved.alt.verb)) {
1125
+ for (const p of dashCPayloads(segment, resolved.alt.index)) payloadSet.add(p);
1126
+ }
1127
+ }
1128
+ return [...payloadSet];
1129
+ }
1130
+
1131
+ /**
1132
+ * Match a blocked-pattern regex against tokenized chain segments — the shared
1133
+ * core of commandMatchesBlocked, recursion-capable for `-c` payloads (#982).
1134
+ *
1135
+ * Per segment, in order:
1136
+ * 1) Unquoted occurrence → match.
1137
+ * 2) Quoted occurrence + interpreter verb (after wrapper unwrap) → match.
1138
+ * 3) ADDITIVE payload recursion: when the resolved verb is a `-c`-taking
1139
+ * shell (or a wrapper collected a command-string payload, `env -S`),
1140
+ * re-tokenize each payload and match it recursively. Strictly additive —
1141
+ * never replaces check 2 (removing 2 would LOOSEN quoted-containment
1142
+ * cases like `bash -c 'echo "rm -rf is dangerous"'`).
1143
+ *
1144
+ * @param {Array<Array<{ text: string, quoted: boolean }>>} segments
1145
+ * @param {RegExp} re
1146
+ * @param {number} depth — current payload-nesting depth (entry check < MAX)
1147
+ * @param {{ remaining: number }} budget — shared across the whole recursion
1148
+ * @returns {boolean}
1149
+ */
1150
+ function matchSegments(segments, re, depth, budget) {
1151
+ for (const segment of segments) {
1152
+ // 1) Unquoted occurrence anywhere in the segment → always a match.
1153
+ if (unquotedSegmentMatch(segment, re)) return true;
1154
+
1155
+ const resolved = resolveSegmentVerb(segment);
1156
+
1157
+ // 2) Quoted occurrence → only a match when the segment verb is an interpreter
1158
+ // that executes its quoted payload. EITHER reading of an ambiguous
1159
+ // unknown flag counts (#1000): `env -Q x bash -c 'rm -rf /etc'` resolves
1160
+ // to the non-interpreter `x` in parse A and to `bash` in parse B.
1161
+ if (quotedTokensMatch(segment, re)) {
1162
+ const isInterp = (v) => Boolean(v) && SHELL_EXEC_INTERPRETERS.has(v);
1163
+ if (isInterp(resolved.verb) || isInterp(resolved.alt?.verb)) return true;
1164
+ // else: inert literal inside quotes for a non-interpreter verb → no match
1165
+ // for THIS segment; keep scanning other segments.
1166
+ }
1167
+
1168
+ // 3) `-c`/`env -S` payload recursion (depth-capped, budgeted). The payload
1169
+ // set is the de-duplicated UNION over both readings (dedupedSegmentPayloads)
1170
+ // so an ambiguous segment cannot double-charge the shared evaluation budget.
1171
+ if (depth < MAX_PAYLOAD_DEPTH) {
1172
+ for (const payload of dedupedSegmentPayloads(segment, resolved)) {
1173
+ // FAIL-VISIBLE cut-off (HIGH-1 remediation (b)). A silent `break` here
1174
+ // was fail-OPEN: an attacker prepends 32 inert `env -Q x sh -c 'echo N'`
1175
+ // filler segments — each charging one alt-reading payload against the
1176
+ // shared 32-eval budget (#1000) — then the real deny-capable payload
1177
+ // (`env -S 'rm -rf /'`) arrives with budget exhausted and the old `break`
1178
+ // returned false → ALLOW (coordinator-measured deny-loss vs 1be450a).
1179
+ // This matcher returns a boolean with no unresolved channel, so an
1180
+ // UNJUDGED deny-capable payload is treated as a MATCH (fail-closed),
1181
+ // mirroring collectRedirectTargets' budget-exhausted marker (#988 T2).
1182
+ // A realistic benign command never approaches the budget (depth-capped
1183
+ // at 3); only pathological width reaches exhaustion, and denying a
1184
+ // command that also carries a blocked pattern in the raw string is the
1185
+ // safe direction.
1186
+ if (budget.remaining <= 0) return true;
1187
+ budget.remaining -= 1;
1188
+ const subTokens = tokenizeCommand(
1189
+ normalizeShellWhitespaceExpansions(payload, { expandSingleQuoted: true }),
1190
+ );
1191
+ if (matchSegments(splitSegments(subTokens), re, depth + 1, budget)) return true;
1192
+ }
1193
+ }
1194
+ }
1195
+ return false;
1196
+ }
1197
+
1198
+ /**
1199
+ * Recursive collector behind extractRedirectTargets (#983). Walks each
1200
+ * segment's redirect tokens, then re-tokenizes `-c` / `env -S` payloads with
1201
+ * the same depth/budget caps matchSegments uses.
1202
+ *
1203
+ * @param {Array<Array<{ text: string, quoted: boolean, redirect?: object }>>} segments
1204
+ * @param {Array<object>} out — accumulator
1205
+ * @param {number} depth
1206
+ * @param {{ remaining: number }} budget
1207
+ */
1208
+ function collectRedirectTargets(segments, out, depth, budget) {
1209
+ for (const segment of segments) {
1210
+ for (let i = 0; i < segment.length; i++) {
1211
+ const tok = segment[i];
1212
+ if (!tok.redirect) continue;
1213
+ const { fd, mode } = tok.redirect;
1214
+ // Deliberate boundary: only file-operand modes are reported. `dup`
1215
+ // (`2>&1`) targets a file descriptor, `heredoc`/`herestring` operands
1216
+ // are inline data/delimiters — none names a filesystem target.
1217
+ // NOTE: this mode filter is a SEPARATE rule from operand ownership
1218
+ // (redirectSpanEnd) — `herestring` OWNS its next word but names no file,
1219
+ // so it is excluded here and included there. Do not merge the two.
1220
+ if (mode === 'dup' || mode === 'heredoc' || mode === 'herestring') continue;
1221
+ // Past the filter `hasOperandWord` is unconditionally true, so the shared
1222
+ // span rule reduces to the old inline predicate (#1002).
1223
+ const end = redirectSpanEnd(segment, i);
1224
+ const operand = end > i ? segment[end] : null;
1225
+ if (operand) i = end; // operand word belongs to this redirect
1226
+ if (!operand || /[$`]/.test(operand.text)) {
1227
+ // Variable indirection (`> "$X"`), command substitution (`> $(cmd)` /
1228
+ // backticks), or a missing operand: fail-visible, never guess (#983).
1229
+ out.push({ target: null, mode, fd, unresolved: true });
1230
+ } else {
1231
+ out.push({ target: operand.text, mode, fd });
1232
+ }
1233
+ }
1234
+
1235
+ const resolved = resolveSegmentVerb(segment);
1236
+
1237
+ // A wrapper can truncate a file WITHOUT any redirect operator and without
1238
+ // being the verb: `/usr/bin/time -o CLAUDE.md npm test` empties CLAUDE.md
1239
+ // while the verb is `npm` (BSD time(1): "If file exists and the -a flag is
1240
+ // not specified, the file will be overwritten"). Measured pre-#992 against
1241
+ // the real 14-rule policy: `> CLAUDE.md` DENY, `/usr/bin/time -o CLAUDE.md
1242
+ // npm test` ALLOW. `writesFile` — never the bare `argFlags` membership — is
1243
+ // the discriminator: `stdbuf -o 0`, `nice -n 10`, `sudo -u root` all carry
1244
+ // an argFlag operand that is NOT a file, and all three stay unreported.
1245
+ //
1246
+ // Mode is `truncate` unconditionally, including under `time -a` (append).
1247
+ // Deliberate, safe-direction over-report: reading `-a` would mean tracking
1248
+ // the wrapper's BOOLEAN flags too — widening the contract a sibling guard
1249
+ // consumes — to buy back a false-positive class that is empty in practice
1250
+ // (nobody appends a timing report to a policy-protected file). An
1251
+ // under-report here is a bypass; this over-report is a nuisance at worst.
1252
+ //
1253
+ // Both readings of an ambiguous unknown flag contribute (#1000), UNIONED
1254
+ // and never replaced: `env -Q x /usr/bin/time -o CLAUDE.md npm test` hides
1255
+ // the operand from parse A (which reads `x` as the verb) and surfaces it in
1256
+ // parse B, while `sudo -n /usr/bin/time -o report.txt npm test` is the
1257
+ // mirror case — parse B swallows `/usr/bin/time` as `-n`'s operand and
1258
+ // reports nothing. Replacement would lose one of the two.
1259
+ const seenWrapperTargets = new Set();
1260
+ const altWrapperArgs = resolved.alt ? resolved.alt.wrapperArgs : [];
1261
+ for (const wa of [...resolved.wrapperArgs, ...altWrapperArgs]) {
1262
+ if (wa.writesFile !== true) continue;
1263
+ if (typeof wa.value !== 'string') continue;
1264
+ // De-dup key is the OPERAND plus the mode, which for a resolved entry is
1265
+ // exactly `target + ':' + mode`; using the operand keeps two distinct
1266
+ // unresolved spellings (`"$OUT"` vs `"$X"`) distinct rather than
1267
+ // collapsing them onto a shared `null` target.
1268
+ const key = `${wa.value}:truncate`;
1269
+ if (seenWrapperTargets.has(key)) continue;
1270
+ seenWrapperTargets.add(key);
1271
+ if (/[$`]/.test(wa.value)) {
1272
+ // Same fail-visible rule as a redirect operand (#983): never guess at a
1273
+ // variable or a command substitution, but never silently drop it either.
1274
+ out.push({ target: null, mode: 'truncate', fd: null, unresolved: true });
1275
+ continue;
1276
+ }
1277
+ out.push({ target: wa.value, mode: 'truncate', fd: null });
1278
+ }
1279
+
1280
+ // Both readings of an ambiguous unknown flag contribute payloads (#1000),
1281
+ // UNIONED and de-duped (HIGH-2). Walking parse A only let
1282
+ // `env -Q x bash -c 'echo pwned > CLAUDE.md'` — which resolves to the
1283
+ // non-interpreter `x` in parse A — bypass the redirect denylist entirely
1284
+ // (coordinator-measured: `bash -c '… > CLAUDE.md'` DENY, `env -Q x bash -c
1285
+ // '… > CLAUDE.md'` ALLOW). The same helper matchSegments uses guarantees the
1286
+ // deduped-before-charging rule holds on this surface too, so the redirect
1287
+ // recursion cannot be starved any differently than the match recursion.
1288
+ const payloads = dedupedSegmentPayloads(segment, resolved);
1289
+ if (payloads.length === 0) continue;
1290
+
1291
+ // A cap that drops payloads SILENTLY is a bypass, not a cap: 33 filler
1292
+ // `-c` segments exhausted the budget and `> CLAUDE.md` in the 34th came
1293
+ // back as an EMPTY target list, so the guard saw nothing (#988 T2,
1294
+ // probe-measured). Both cut-offs now emit an unresolved marker — the
1295
+ // DoS ceiling is unchanged, its effect is merely visible.
1296
+ if (depth >= MAX_PAYLOAD_DEPTH) {
1297
+ out.push({ target: null, mode: null, fd: null, unresolved: true, reason: 'depth-exceeded' });
1298
+ continue;
1299
+ }
1300
+ for (const payload of payloads) {
1301
+ if (budget.remaining <= 0) {
1302
+ out.push({ target: null, mode: null, fd: null, unresolved: true, reason: 'budget-exhausted' });
1303
+ break;
1304
+ }
1305
+ budget.remaining -= 1;
1306
+ const subTokens = tokenizeCommand(
1307
+ normalizeShellWhitespaceExpansions(payload, { expandSingleQuoted: true }),
1308
+ );
1309
+ collectRedirectTargets(splitSegments(subTokens), out, depth + 1, budget);
1310
+ }
1311
+ }
1312
+ }
1313
+
1314
+ /**
1315
+ * Extract every filesystem redirect target from a command string (#983).
1316
+ *
1317
+ * Traverses all chain segments AND (read-only, via the existing payload
1318
+ * mechanics) every `-c` / `env -S` shell payload, depth-capped at
1319
+ * MAX_PAYLOAD_DEPTH with the shared MAX_PAYLOAD_EVALUATIONS budget.
1320
+ *
1321
+ * Entry shapes:
1322
+ * - `{ target: string, mode: 'truncate'|'append'|'read', fd: number|null }`
1323
+ * — resolved target; quoted targets are reported WITHOUT their quotes
1324
+ * (the tokenizer strips them).
1325
+ * - `{ target: null, mode, fd, unresolved: true }` — the operand contains a
1326
+ * variable (`> "$X"`), a command substitution, or is missing. Reported
1327
+ * fail-visible so the consuming guard can LOG it; deliberately NOT a
1328
+ * match candidate for redirectRuleMatches (see there — #641 FP class).
1329
+ * - `{ target: null, mode: null, fd: null, unresolved: true,
1330
+ * reason: 'budget-exhausted'|'depth-exceeded' }` — a payload subtree was
1331
+ * NOT traversed because a recursion cap cut it off (#988 T2). `mode` is
1332
+ * null: no redirect was parsed, so the entry belongs to no mode class and
1333
+ * a mode filter must not silently drop it.
1334
+ *
1335
+ * `dup` (`2>&1`), `heredoc` (`<<`), and `herestring` (`<<<`) redirects name
1336
+ * no filesystem target and are omitted (deliberate boundary, documented).
1337
+ *
1338
+ * WIDENED CONTRACT (#992): the traversal ALSO reports wrapper file operands —
1339
+ * `resolveSegmentVerb` entries carrying `writesFile: true`, i.e. today
1340
+ * `/usr/bin/time -o FILE` / `--output=FILE`. Such a write has no redirect
1341
+ * operator at all, so the pre-#992 traversal saw nothing and
1342
+ * `/usr/bin/time -o CLAUDE.md npm test` truncated a denylisted file while
1343
+ * `> CLAUDE.md` was blocked. They are emitted as `mode: 'truncate'`, `fd: null`
1344
+ * — indistinguishable from a `>` entry by design, because the EFFECT on the
1345
+ * named file is indistinguishable.
1346
+ *
1347
+ * @param {string} command
1348
+ * @returns {Array<{ target: string|null, mode: string|null, fd: number|null,
1349
+ * unresolved?: boolean, reason?: string }>}
1350
+ */
1351
+ export function extractRedirectTargets(command) {
1352
+ if (typeof command !== 'string' || command.length === 0) return [];
1353
+ const out = [];
1354
+ const segments = splitSegments(tokenizeCommand(normalizeShellWhitespaceExpansions(command)));
1355
+ collectRedirectTargets(segments, out, 0, { remaining: MAX_PAYLOAD_EVALUATIONS });
1356
+ return out;
1357
+ }
1358
+
1359
+ /**
1360
+ * Minimal glob-to-RegExp for redirect target-denylist matching (#983).
1361
+ * Same shape as the picomatch-absent fallback in scripts/lib/rule-loader.mjs
1362
+ * (`**` = any path segments, `*` = within-segment, `?` = single char) —
1363
+ * duplicated locally because this module is hook-hot-path pure (no imports
1364
+ * beyond node built-ins, no I/O at import time; see header invariant).
1365
+ *
1366
+ * CASE-INSENSITIVE (#994 R2): the returned RegExp carries the `i` flag
1367
+ * UNCONDITIONALLY — no platform detection. Detection is unsafe in both
1368
+ * directions (macOS can run case-sensitive APFS; Linux can mount
1369
+ * case-insensitive volumes via ext4 casefold / ciopfs / CIFS), and a correct
1370
+ * per-path answer would need filesystem I/O this module forbids (header
1371
+ * invariant). The cost is asymmetric: a MISS truncates CLAUDE.md
1372
+ * unrecoverably, whereas an over-block is a visible deny with two escapes
1373
+ * (`>>`, the Write tool). So `> claude.md` / `> Claude.md` — the same inode as
1374
+ * `CLAUDE.md` on a case-insensitive volume — deny like the canonical spelling.
1375
+ *
1376
+ * @param {string} pattern
1377
+ * @returns {RegExp}
1378
+ */
1379
+ function redirectGlobToRegExp(pattern) {
1380
+ const p = String(pattern).replace(/\\/g, '/');
1381
+ let re = '';
1382
+ let i = 0;
1383
+ while (i < p.length) {
1384
+ const c = p[i];
1385
+ if (c === '*' && p[i + 1] === '*') {
1386
+ re += '.*';
1387
+ i += 2;
1388
+ if (p[i] === '/') i++;
1389
+ } else if (c === '*') {
1390
+ re += '[^/]*';
1391
+ i++;
1392
+ } else if (c === '?') {
1393
+ re += '[^/]';
1394
+ i++;
1395
+ } else if (c === '.') {
1396
+ re += '\\.';
1397
+ i++;
1398
+ } else {
1399
+ re += c.replace(/[$()+[\]^{|}]/g, '\\$&');
1400
+ i++;
1401
+ }
1402
+ }
1403
+ return new RegExp(`^${re}$`, 'i');
1404
+ }
1405
+
1406
+ /**
1407
+ * Collapse the macOS same-inode path aliases so one location has one spelling.
1408
+ *
1409
+ * Two independent alias classes, applied in order:
1410
+ * 1. `/System/Volumes/Data/...` — the firmlink onto the Data volume: on APFS
1411
+ * `/System/Volumes/Data/repo` and `/repo` are the SAME inode. Stripped
1412
+ * FIRST so a `/System/Volumes/Data/private/tmp/...` spelling then also runs
1413
+ * through the `/private` strip below.
1414
+ * 2. `/private/{tmp,var,etc}/...` — the short forms `/tmp`, `/var`, `/etc` are
1415
+ * symlinks into `/private`. Without this, a repo checked out under
1416
+ * `/tmp/...` (CI runners, worktrees) would not recognise its own root in a
1417
+ * command that spells it `/private/tmp/...`.
1418
+ *
1419
+ * A strip that would empty the path (`/System/Volumes/Data` itself) is guarded
1420
+ * with `|| '/'` so the result stays an absolute path.
1421
+ *
1422
+ * Deliberately STATIC: no `realpathSync` on user input. Resolving an
1423
+ * attacker-supplied path at guard time is its own risk class, and this module
1424
+ * is I/O-free by header invariant. Only the known aliases collapse; every other
1425
+ * path is returned byte-identical.
1426
+ *
1427
+ * @param {string} p — an absolute, already-normalized path
1428
+ * @returns {string}
1429
+ */
1430
+ function stripPathAliases(p) {
1431
+ const dataStripped =
1432
+ (/^\/System\/Volumes\/Data(?:\/|$)/.test(p) ? p.slice('/System/Volumes/Data'.length) : p) ||
1433
+ '/';
1434
+ return /^\/private\/(?:tmp|var|etc)(?:\/|$)/.test(dataStripped)
1435
+ ? dataStripped.slice('/private'.length) || '/'
1436
+ : dataStripped;
1437
+ }
1438
+
1439
+ /**
1440
+ * Expand a LEADING `~` / `~/` in a redirect target to the operator's home dir —
1441
+ * the shell substitution the hook never gets to see, because a PreToolUse gate
1442
+ * receives the raw, UNEXPANDED command string. Same motivation as
1443
+ * `expandTmpdirToken` in hooks/pre-bash-destructive-guard.mjs (which does the
1444
+ * `$TMPDIR` half for rm operands); deliberately NOT merged with it — that one
1445
+ * expands an env VAR reference for the rm-allowlist, this one expands the
1446
+ * tilde WORD for the redirect denylist, and folding two token grammars into one
1447
+ * expander would widen both.
1448
+ *
1449
+ * `~user/...` is left untouched: another account's home is not this repo.
1450
+ *
1451
+ * Tilde expansion is applied REGARDLESS of the operand's quoting. A fully
1452
+ * quoted `> "~/x/CLAUDE.md"` is a literal `~` directory in the real shell, so
1453
+ * matching it is a (harmless, safe-direction) over-block; the partially quoted
1454
+ * `> ~/"My Docs"/CLAUDE.md` — which the tokenizer also reports as quoted, and
1455
+ * which the shell DOES expand — would otherwise be a real bypass.
1456
+ *
1457
+ * @param {string} target
1458
+ * @param {string|undefined} home
1459
+ * @returns {string}
1460
+ */
1461
+ function expandLeadingHome(target, home) {
1462
+ if (target !== '~' && !target.startsWith('~/')) return target;
1463
+ if (!home || !path.isAbsolute(home)) return target;
1464
+ return home + target.slice(1);
1465
+ }
1466
+
1467
+ /**
1468
+ * Relativize an ABSOLUTE target against an absolute repoRoot to the repo-relative
1469
+ * POSIX form the denylist globs use, or `null` when the target names the root
1470
+ * itself or lies outside it.
1471
+ *
1472
+ * Both paths are alias-collapsed (`stripPathAliases`) and case-folded before the
1473
+ * containment comparison (#994 R3): a case-insensitive volume makes `/REPO` and
1474
+ * `/repo` the same directory, so the comparison that decides in-vs-out must fold
1475
+ * too — otherwise an absolute spelling in the wrong case escapes the root and is
1476
+ * silently allowed. Folding uses `toLocaleLowerCase('en-US')` explicitly (same
1477
+ * rationale as scripts/lib/path-utils.mjs:46 — avoids the Turkish-İ divergence a
1478
+ * locale-default `toLowerCase` carries). The returned path is therefore
1479
+ * lowercased; it is matched against the denylist regexes, which carry the `i`
1480
+ * flag (#994 R2), so the fold and the match agree.
1481
+ *
1482
+ * Shared by BOTH branches of `repoRelativeRedirectTarget` (absolute + relative)
1483
+ * so the two can never drift again (#994 R1 structural fix).
1484
+ *
1485
+ * @param {string} absTarget — absolute, resolved target path
1486
+ * @param {string} repoRoot — absolute repo root
1487
+ * @returns {string|null}
1488
+ */
1489
+ function relativizeAgainstRoot(absTarget, repoRoot) {
1490
+ const fold = (p) => p.toLocaleLowerCase('en-US');
1491
+ const rel = path.relative(
1492
+ fold(stripPathAliases(path.normalize(repoRoot))),
1493
+ fold(stripPathAliases(path.normalize(absTarget))),
1494
+ );
1495
+ // '' = the root itself (a directory, not a file target); '..'-prefixed or
1496
+ // absolute = outside the repo, which the repo-relative denylist never covers.
1497
+ if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
1498
+ return null;
1499
+ }
1500
+ return path.posix.normalize(rel).replace(/^(\.\/)+/, '');
1501
+ }
1502
+
1503
+ /**
1504
+ * Reduce a raw redirect target to the repo-relative POSIX form the denylist
1505
+ * globs are written in, or `null` when it cannot name a file inside the repo.
1506
+ *
1507
+ * A RELATIVE target (#994 R1) is interpreted as REPO-ROOT-relative, not
1508
+ * shell-cwd-relative: after lexical normalization, a target that stays inside
1509
+ * the root (no leading `..`) is already the repo-relative form and returned
1510
+ * as-is; a target that climbs out (`../repo/CLAUDE.md`, `./a/../../repo/…`) is
1511
+ * resolved against `repoRoot` and re-relativized through the SAME
1512
+ * `relativizeAgainstRoot` the absolute branch uses. Without a usable `repoRoot`
1513
+ * the pre-#994 lexical form is returned verbatim — byte-identical for
1514
+ * optionless callers.
1515
+ *
1516
+ * @param {string} raw — resolved target text (quotes already stripped)
1517
+ * @param {string|null} repoRoot — absolute repo root, or null (no resolution)
1518
+ * @param {string|undefined} home
1519
+ * @returns {string|null}
1520
+ */
1521
+ function repoRelativeRedirectTarget(raw, repoRoot, home) {
1522
+ const expanded = expandLeadingHome(raw, home);
1523
+
1524
+ if (!path.isAbsolute(expanded)) {
1525
+ const lexical = path.posix.normalize(expanded).replace(/^(\.\/)+/, '');
1526
+ // Post-normalization, a leading `..` is the ONLY way a relative target
1527
+ // leaves the root; anything else is already the repo-relative form (this
1528
+ // also preserves the `~other/...` another-account spelling untouched).
1529
+ if (!lexical.startsWith('../')) return lexical;
1530
+ // A relative target that climbs out — resolve it against repoRoot. Without a
1531
+ // usable root, return the pre-#994 lexical form (byte-identical contract).
1532
+ if (!repoRoot || !path.isAbsolute(repoRoot)) return lexical;
1533
+ return relativizeAgainstRoot(path.resolve(repoRoot, expanded), repoRoot);
1534
+ }
1535
+ // Absolute target: only judgeable against a known repo root. Without one the
1536
+ // pre-#988 behaviour stands (no match) rather than a guess.
1537
+ if (!repoRoot || !path.isAbsolute(repoRoot)) return null;
1538
+ return relativizeAgainstRoot(expanded, repoRoot);
1539
+ }
1540
+
1541
+ /**
1542
+ * Match a `redirect-truncate` policy rule against a command (#983).
1543
+ *
1544
+ * For every extractRedirectTargets entry whose mode is in `rule.modes`
1545
+ * (default `['truncate']` — `truncate` covers `>`, `>|`, `&>`, and `N>`;
1546
+ * append `>>` stays allowed by design), the target is matched against the
1547
+ * rule's `target-denylist` globs (`**` / `*` / `?`). Targets are
1548
+ * POSIX-normalized before matching (`path.posix.normalize` collapses `.//`
1549
+ * and `sub/..` spellings — `> .//CLAUDE.md` and `> ./sub/../CLAUDE.md` (same
1550
+ * for the AGENTS.md alias) were silently ALLOWED pre-normalization, W4 F1a)
1551
+ * and a leading `./` is stripped.
1552
+ *
1553
+ * Absolute and `~` spellings (#988 T1). The denylist globs are repo-relative,
1554
+ * so `> /abs/path/to/repo/CLAUDE.md` and `> ~/repo/CLAUDE.md` matched NOTHING
1555
+ * and were silently allowed (probe-measured `rule abs: false`, `rule tilde:
1556
+ * false` against `rule rel: true`). Pass `{ repoRoot }` and such a target is
1557
+ * tilde-expanded, alias-collapsed and made repo-relative before the globs run;
1558
+ * a target outside the repo yields no match. WITHOUT `repoRoot` (the default)
1559
+ * an absolute target still never matches — identical to the pre-#988 contract,
1560
+ * so existing callers keep their exact behaviour. This function stays I/O-free:
1561
+ * `~` resolves from `process.env.HOME` (overridable via `home`), never via a
1562
+ * filesystem lookup.
1563
+ *
1564
+ * Relative spellings and case (#994). A RELATIVE target is interpreted as
1565
+ * REPO-ROOT-relative, NOT shell-cwd-relative: one that climbs out with `..`
1566
+ * (`> ../repo/CLAUDE.md`) is resolved against `repoRoot` and re-relativized, so
1567
+ * it is caught when it lands back inside the root and allowed when it does not
1568
+ * (without `repoRoot`, such a target keeps its pre-#994 lexical form). The
1569
+ * denylist globs and the absolute-containment comparison are BOTH
1570
+ * case-insensitive, because a case-insensitive volume makes `> claude.md` the
1571
+ * same inode as `CLAUDE.md`; the alias-collapse also covers `/System/Volumes/Data`
1572
+ * and `/private/etc` alongside `/private/{tmp,var}`.
1573
+ *
1574
+ * Deliberate boundary (#641 FP class): `unresolved: true` entries (variable
1575
+ * indirection, command substitution) are NEVER matched — blocking on a guess
1576
+ * would reintroduce the false-positive class #641 removed. They remain
1577
+ * visible via extractRedirectTargets; the consuming guard hook (W3-A, #983)
1578
+ * decides whether to log them.
1579
+ *
1580
+ * The guard-hook branch dispatching on `rule.type === 'redirect-truncate'`
1581
+ * lives in hooks/pre-bash-destructive-guard.mjs and consumes this export.
1582
+ *
1583
+ * @param {{ modes?: string[], 'target-denylist'?: string[] }} rule
1584
+ * @param {string} command
1585
+ * @param {{ repoRoot?: string|null, home?: string|undefined }} [opts]
1586
+ * repoRoot — absolute repo root; enables absolute/`~` target resolution.
1587
+ * home — `~` expansion base; defaults to `process.env.HOME`.
1588
+ * @returns {boolean}
1589
+ */
1590
+ export function redirectRuleMatches(rule, command, opts = {}) {
1591
+ const { repoRoot = null, home = process.env.HOME } = opts;
1592
+ if (!rule || typeof command !== 'string' || command.length === 0) return false;
1593
+ const denylist = Array.isArray(rule['target-denylist']) ? rule['target-denylist'] : [];
1594
+ if (denylist.length === 0) return false;
1595
+ const modes = new Set(Array.isArray(rule.modes) && rule.modes.length > 0 ? rule.modes : ['truncate']);
1596
+ const regexes = denylist.map(redirectGlobToRegExp);
1597
+
1598
+ for (const entry of extractRedirectTargets(command)) {
1599
+ if (entry.unresolved) continue;
1600
+ if (!modes.has(entry.mode)) continue;
1601
+ const target = repoRelativeRedirectTarget(entry.target, repoRoot, home);
1602
+ if (target === null) continue;
1603
+ if (regexes.some((re) => re.test(target))) return true;
1604
+ }
1605
+ return false;
380
1606
  }
381
1607
 
382
1608
  /**
@@ -389,10 +1615,14 @@ function unquotedSegmentMatch(segment, re) {
389
1615
  *
390
1616
  * Quoted-payload guard: a pattern whose ONLY occurrences are wholly inside quoted
391
1617
  * tokens is treated as inert literal text (no match) UNLESS the enclosing chain
392
- * segment's verb (argv[0], after skipping env-assignments and unwrapping
393
- * `env`/`command`) is a shell-exec interpreter (`bash -c "rm -rf /"`,
394
- * `eval "…"`, `psql -c "DROP TABLE …"`, `find … -exec …`). The guard is applied
395
- * PER chain segment: a quoted pattern in segment N is judged against segment N's verb.
1618
+ * segment's verb (argv[0], after skipping env-assignments and unwrapping the
1619
+ * transparent wrappers in WRAPPER_UNWRAP `env`, `command`, `sudo`, `doas`,
1620
+ * `nohup`, `timeout`, `nice`, `stdbuf` (#982)) is a shell-exec interpreter
1621
+ * (`bash -c "rm -rf /"`, `sudo bash -c "…"`, `eval "…"`, `psql -c "DROP TABLE …"`,
1622
+ * `find … -exec …`). The guard is applied PER chain segment: a quoted pattern in
1623
+ * segment N is judged against segment N's verb. Shell `-c` payloads (and
1624
+ * `env -S` strings) are additionally re-tokenized and matched recursively,
1625
+ * depth-capped at 3 with a total evaluation budget (#982).
396
1626
  *
397
1627
  * Boundary characters: whitespace, shell operators (`;`, `|`, `&`, `(`, `)`,
398
1628
  * `{`, `}`, backtick), or string quotes (`'`, `"`). Case-sensitive.
@@ -417,22 +1647,7 @@ export function commandMatchesBlocked(command, pattern) {
417
1647
  if (!re.test(normalizedCommand) && !re.test(payloadNormalizedCommand)) return false;
418
1648
 
419
1649
  const segments = splitSegments(tokenizeCommand(normalizedCommand));
420
-
421
- for (const segment of segments) {
422
- // 1) Unquoted occurrence anywhere in the segment → always a match.
423
- if (unquotedSegmentMatch(segment, re)) return true;
424
-
425
- // 2) Quoted occurrence → only a match when the segment verb is an interpreter
426
- // that executes its quoted payload.
427
- if (quotedTokensMatch(segment, re)) {
428
- const verb = segmentVerb(segment);
429
- if (verb && SHELL_EXEC_INTERPRETERS.has(verb)) return true;
430
- // else: inert literal inside quotes for a non-interpreter verb → no match
431
- // for THIS segment; keep scanning other segments.
432
- }
433
- }
434
-
435
- return false;
1650
+ return matchSegments(segments, re, 0, { remaining: MAX_PAYLOAD_EVALUATIONS });
436
1651
  }
437
1652
 
438
1653
  /**