session-orchestrator 3.16.0 → 3.19.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 (220) 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 +130 -412
  6. package/README.md +17 -12
  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/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  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 +97 -0
  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/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. 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,186 @@ 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
+ while (pendingHeredocs.length > 0) {
454
+ const { delim, stripTabs } = pendingHeredocs.shift();
455
+ const { body, end, terminated } = readHeredocBody(command, j, delim, stripTabs);
456
+ if (!terminated) { pendingHeredocs.length = 0; break; }
457
+ if (body.length > 0) tokens.push({ text: body, quoted: true });
458
+ j = end;
459
+ }
460
+ i = j - 1;
461
+ continue;
462
+ }
463
+
249
464
  if (ch === "'") { state = 'single'; started = true; continue; }
250
465
  if (ch === '"') { state = 'double'; started = true; continue; }
466
+ if (ch === '$' && command[i + 1] === "'") { state = 'ansi'; started = true; i++; continue; }
467
+
468
+ // Arithmetic context (gate 1, #970). `$((`/`((` open a level, `))` closes
469
+ // one. The characters are still appended verbatim — the ONLY effect is that
470
+ // the here-doc branch below stands down while depth > 0, because there `<<`
471
+ // is the left-shift operator, not a redirect.
472
+ if (ch === '$' && command[i + 1] === '(' && command[i + 2] === '(') {
473
+ arithDepth++; text += '$(('; started = true; i += 2;
474
+ continue;
475
+ }
476
+ if (ch === '(' && command[i + 1] === '(' && !started) {
477
+ arithDepth++; text += '(('; started = true; i++;
478
+ continue;
479
+ }
480
+ if (arithDepth > 0 && ch === ')' && command[i + 1] === ')') {
481
+ arithDepth--; text += '))'; started = true; i++;
482
+ continue;
483
+ }
484
+
251
485
  if (ch === '\\' && i + 1 < command.length) {
486
+ // Backslash-NEWLINE is a LINE CONTINUATION, not an escape (#981). bash
487
+ // joins the lines and BOTH characters vanish before word splitting, so
488
+ // this branch must leave no trace: no text, and `started` untouched (a
489
+ // trailing continuation must not flush a phantom empty token).
490
+ //
491
+ // Pre-#981 the newline was appended as literal text and the guard saw a
492
+ // phantom `"\n"` token. Measured consequences, both wrong in a different
493
+ // direction: `rm -rf \<LF> /tmp/ok` (argv `rm -rf /tmp/ok`, allowlisted)
494
+ // was DENIED because `"\n"` read as a second, non-allowlisted target; and
495
+ // `git push \<LF>--force` was ALLOWED because `"\n--force"` is not the
496
+ // `--force` flag any rule looks for. Eliding converges the token stream
497
+ // on bash's argv, which is the only defensible reference.
498
+ if (command[i + 1] === '\n') { i++; continue; }
252
499
  text += command[i + 1]; started = true; i++;
253
500
  continue;
254
501
  }
502
+
503
+ // A REAL (non-continued) newline is a command separator, exactly like `;`
504
+ // in the POSIX grammar (#981). MUST stay below the pending-here-doc branch
505
+ // above: a newline that opens or ends a here-doc body is consumed there and
506
+ // never reaches this point, so a body line can never become a separator.
507
+ //
508
+ // Token shape: `text: ';'` is the canonical spelling of its separator class
509
+ // — every text-keyed consumer (splitSegments here, the ledger guard,
510
+ // scope-gate's SHELL_SEPARATOR_OPS) then classifies it correctly without a
511
+ // per-consumer edit, which a `text: '\n'` would silently NOT do (it would
512
+ // land in scope-gate's word stream and could displace a `sed -i` file
513
+ // argument). `operator: 'newline'` keeps the origin distinguishable for
514
+ // consumers that care, mirroring how `redirect` marks redirect tokens.
515
+ if (ch === '\n') {
516
+ flush();
517
+ tokens.push({ text: ';', quoted: false, operator: 'newline' });
518
+ continue;
519
+ }
520
+
255
521
  if (/\s/.test(ch)) { flush(); continue; }
256
522
 
523
+ // Inside `$(( … ))` / `(( … ))` every `<` / `>` is a shift or comparison
524
+ // operator, never a redirect (gate 1, #970) — append verbatim so the
525
+ // redirect branches below never tear an arithmetic expression apart.
526
+ if (arithDepth > 0 && (ch === '<' || ch === '>')) { text += ch; started = true; continue; }
527
+
528
+ // Redirect operators (#983) — longest-match-first, and BEFORE the
529
+ // chain-operator branch below so `&>` / `&>>` win over the `&` operator.
530
+ if (ch === '&' && command[i + 1] === '>') {
531
+ flush();
532
+ if (command[i + 2] === '>') {
533
+ tokens.push({ text: '&>>', quoted: false, redirect: { fd: null, mode: 'append' } });
534
+ i += 2;
535
+ } else {
536
+ tokens.push({ text: '&>', quoted: false, redirect: { fd: null, mode: 'truncate' } });
537
+ i += 1;
538
+ }
539
+ continue;
540
+ }
541
+ if (ch === '<') {
542
+ if (command[i + 1] === '<' && command[i + 2] === '<') {
543
+ flush();
544
+ tokens.push({ text: '<<<', quoted: false, redirect: { fd: null, mode: 'herestring' } });
545
+ i += 2;
546
+ continue;
547
+ }
548
+ if (command[i + 1] === '<') {
549
+ // Here-doc `<<WORD` / `<<-WORD` — merged #965/#970 machinery: the
550
+ // delimiter is SYNTAX (consumed, never a token of its own); the body
551
+ // is consumed at the next newline as ONE quoted token, but ONLY while
552
+ // every pending here-doc finds its terminator (gate 2 — see the `\n`
553
+ // branch above). A delimiter butted against `)` is rejected
554
+ // (belt-and-braces for an arithmetic form the depth counter did not
555
+ // see): the operator token is still emitted, no body is queued.
556
+ let j = i + 2;
557
+ let stripTabs = false;
558
+ if (command[j] === '-') { stripTabs = true; j++; }
559
+ let k = j;
560
+ while (command[k] === ' ' || command[k] === '\t') k++;
561
+ const { value, end } = readDelimiterWord(command, k);
562
+ const fdDigits = takeFdPrefix();
563
+ const fdNum = fdDigits === '' ? null : Number.parseInt(fdDigits, 10);
564
+ const opText = `${fdDigits}<<${stripTabs ? '-' : ''}`;
565
+ tokens.push({ text: opText, quoted: false, redirect: { fd: fdNum, mode: 'heredoc' } });
566
+ if (value && command[end] !== ')') {
567
+ pendingHeredocs.push({ delim: value, stripTabs });
568
+ i = end - 1;
569
+ } else {
570
+ i = j - 1;
571
+ }
572
+ continue;
573
+ }
574
+ const fdDigits = takeFdPrefix();
575
+ const fdNum = fdDigits === '' ? null : Number.parseInt(fdDigits, 10);
576
+ tokens.push({ text: `${fdDigits}<`, quoted: false, redirect: { fd: fdNum, mode: 'read' } });
577
+ continue;
578
+ }
579
+ if (ch === '>') {
580
+ // fd-prefix (`2>/dev/null`, `2>>log`, `2>&1`): a purely-numeric UNQUOTED
581
+ // in-progress token is the IO_NUMBER — consume it as the fd instead of
582
+ // flushing it as an ordinary word (matches bash IO_NUMBER lexing; a
583
+ // quoted "2" stays a word, exactly as bash treats it).
584
+ let fd = null;
585
+ if (started && !sawQuote && /^[0-9]+$/.test(text)) {
586
+ fd = Number.parseInt(text, 10);
587
+ text = '';
588
+ started = false;
589
+ } else {
590
+ flush();
591
+ }
592
+ const fdText = fd === null ? '' : String(fd);
593
+ if (command[i + 1] === '>') {
594
+ tokens.push({ text: `${fdText}>>`, quoted: false, redirect: { fd, mode: 'append' } });
595
+ i += 1;
596
+ } else if (command[i + 1] === '&' && /[0-9]/.test(command[i + 2] ?? '')) {
597
+ // N>&M / >&M — fd duplication; the target fd is inline, no operand word.
598
+ let j = i + 2;
599
+ let dupTarget = '';
600
+ while (j < command.length && /[0-9]/.test(command[j])) { dupTarget += command[j]; j++; }
601
+ tokens.push({ text: `${fdText}>&${dupTarget}`, quoted: false, redirect: { fd, mode: 'dup' } });
602
+ i = j - 1;
603
+ } else if (command[i + 1] === '|') {
604
+ tokens.push({ text: `${fdText}>|`, quoted: false, redirect: { fd, mode: 'truncate' } });
605
+ i += 1;
606
+ } else {
607
+ tokens.push({ text: `${fdText}>`, quoted: false, redirect: { fd, mode: 'truncate' } });
608
+ }
609
+ continue;
610
+ }
611
+
257
612
  // Unquoted shell control operators become standalone tokens so chain-splitting
258
613
  // and per-segment verb detection work even without surrounding whitespace
259
614
  // (e.g. `/tmp/x;rm -rf src/`). Recognised: ; && || | & — longest match first.
615
+ // The redirect branches above win for `&>` / `&>>` (one token since the
616
+ // #983 redirect token class), so this branch only ever sees a bare chain `&`.
260
617
  if (ch === ';' || ch === '|' || ch === '&') {
261
618
  flush();
262
619
  let op = ch;
@@ -269,8 +626,9 @@ export function tokenizeCommand(command) {
269
626
  }
270
627
 
271
628
  // Unterminated quote → flush whatever accumulated (mark quoted so the guard treats
272
- // the dangling text conservatively).
273
- if (state === 'single' || state === 'double') sawQuote = true;
629
+ // the dangling text conservatively). Deliberately fail-OPEN in the lexer: a wedged
630
+ // guard that blocks every Bash call is strictly worse than a missed enforcement.
631
+ if (state !== 'normal') sawQuote = true;
274
632
  flush();
275
633
 
276
634
  return tokens;
@@ -278,8 +636,20 @@ export function tokenizeCommand(command) {
278
636
 
279
637
  /**
280
638
  * 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.
639
+ * (`;`, `&&`, `||`, `|`, `&`) and on newline separators (#981). Only UNQUOTED
640
+ * single-token operators split; an operator that arrived inside quotes stays
641
+ * part of its segment.
642
+ *
643
+ * The newline separator is checked by its `operator` field as well as its text,
644
+ * so the split survives a future change to that token's spelling. Because a
645
+ * separator token is CONSUMED here, it can never reach a per-segment operand
646
+ * loop — `parseRmTargets` in hooks/pre-bash-destructive-guard.mjs iterates
647
+ * segments, so it never sees a newline token and needed no change for #981.
648
+ *
649
+ * Exported as `splitChainSegments` (see the alias export below):
650
+ * hooks/pre-bash-destructive-guard.mjs consumes it for wrapper-aware rm
651
+ * parsing (#982/#983). The hook previously kept a drift-prone local mirror
652
+ * of this splitter (regex-based operator set vs. this Set) — W4 B1.
283
653
  *
284
654
  * @param {Array<{ text: string, quoted: boolean }>} tokens
285
655
  * @returns {Array<Array<{ text: string, quoted: boolean }>>}
@@ -289,7 +659,7 @@ function splitSegments(tokens) {
289
659
  let current = [];
290
660
  const operators = new Set([';', '&&', '||', '|', '&']);
291
661
  for (const tok of tokens) {
292
- if (!tok.quoted && operators.has(tok.text)) {
662
+ if (!tok.quoted && (tok.operator === 'newline' || operators.has(tok.text))) {
293
663
  segments.push(current);
294
664
  current = [];
295
665
  continue;
@@ -300,37 +670,241 @@ function splitSegments(tokens) {
300
670
  return segments.filter((s) => s.length > 0);
301
671
  }
302
672
 
673
+ // Public alias — the single source of truth for chain-segment splitting shared
674
+ // with hooks/pre-bash-destructive-guard.mjs (W4 B1; internal call sites keep
675
+ // the short name).
676
+ export { splitSegments as splitChainSegments };
677
+
678
+ const ENV_ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
679
+
303
680
  /**
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.
681
+ * Transparent process-wrapper table for verb resolution (#982), keyed by
682
+ * basename. Each spec describes how to skip a wrapper's own options so the
683
+ * REAL verb it delegates to resolves (`sudo -u root bash -c '…'` → `bash`).
684
+ *
685
+ * Spec fields (all optional):
686
+ * - argFlags: Set of flags that consume a SEPARATE next-token argument.
687
+ * - fileArgFlags: Subset of `argFlags` whose operand is a FILE THE WRAPPER
688
+ * WRITES (`time -o report`), not merely an option value.
689
+ * Entries so flagged are reported with `writesFile: true`
690
+ * in `wrapperArgs` (#992). Membership is per-wrapper and
691
+ * deliberately narrow — measured counter-examples that must
692
+ * NOT be in it: `stdbuf -o 0` (a BUFFERING MODE), `nice -n
693
+ * 10` (a priority), `sudo -u root` (a user name). A blanket
694
+ * "every argFlag operand is a file" rule would block all
695
+ * three; the table is the discriminator.
696
+ * - shellFlags: Set of flags that make the wrapper itself spawn a shell
697
+ * (`sudo -i` / `sudo -s`) — resolution yields the synthetic
698
+ * verb `sh`, so the quoted-payload guard treats the segment
699
+ * as an interpreter.
700
+ * - envAssignments: skip unquoted `VAR=value` tokens among the options.
701
+ * - splitString: `env -S/--split-string <string>` — the string is a shell
702
+ * command line; collected as a recursion payload (same
703
+ * treatment as a `-c` payload).
704
+ * - positionals: number of positional arguments to skip before the verb
705
+ * (`timeout DURATION cmd …`).
706
+ *
707
+ * Unknown single-token flags (`-i`, `--foreground`, attached forms like `-o0`
708
+ * / `-n19`, legacy `nice -10`, `--user=root`) are skipped generically; `--`
709
+ * ends option parsing. Deliberately NOT listed:
710
+ * - `su` — interpreter (SHELL_EXEC_INTERPRETERS), its `-c` payload executes;
711
+ * - `xargs` — interpreter; unwrapping it would LOOSEN the guard;
712
+ * - `flock` / `setsid` / `ionice` — scope cut (#982, operator-approved).
713
+ *
714
+ * `time` IS listed (merge of the #970 here-doc line of work): the here-doc
715
+ * design's safety argument is that a body fed to an interpreter still matches
716
+ * because `bash` is in SHELL_EXEC_INTERPRETERS — that only holds when `bash`
717
+ * is the RESOLVED verb, so `sudo bash <<EOF` / `time bash <<EOF` must unwrap.
718
+ * This table is ALSO the alignment target for the ledger-guard's
719
+ * `VERB_PREFIXES` copy (see #991) — do not fork a second wrapper list.
720
+ *
721
+ * Exported so downstream guards (ledger) can converge on the same table.
722
+ */
723
+ export const WRAPPER_UNWRAP = new Map([
724
+ ['sudo', {
725
+ argFlags: new Set([
726
+ '-u', '-g', '-h', '-p', '-C', '-D', '-R', '-r', '-t', '-T', '-U',
727
+ '--user', '--group', '--host', '--prompt', '--close-from', '--chdir',
728
+ '--chroot', '--role', '--type', '--command-timeout', '--other-user',
729
+ ]),
730
+ shellFlags: new Set(['-i', '-s', '--login', '--shell']),
731
+ envAssignments: true,
732
+ }],
733
+ ['doas', {
734
+ argFlags: new Set(['-u', '-C', '-a']),
735
+ shellFlags: new Set(['-s']),
736
+ }],
737
+ // `-P altpath` is BSD/macOS env(1)'s "search THIS path for the utility"
738
+ // option. It was missing, so `-P` was skipped as a one-token boolean and its
739
+ // operand landed in verb position: `env -P /bin:/usr/bin bash -c 'rm -rf
740
+ // /etc'` resolved to the verb `bin` (basename of `/bin:/usr/bin`), the
741
+ // segment was no longer an interpreter, and the quoted payload went inert —
742
+ // measured ALLOW (#992). Note the contrast with `sudo -P`, which is
743
+ // `--preserve-groups`, a BOOLEAN — the same letter is value-taking for one
744
+ // wrapper and not for the other, which is why this table is per-wrapper.
745
+ ['env', {
746
+ argFlags: new Set(['-u', '--unset', '-C', '--chdir', '-P']),
747
+ envAssignments: true,
748
+ splitString: true,
749
+ }],
750
+ ['command', {}],
751
+ ['nohup', {}],
752
+ // `-o FILE` is the BSD/GNU `time` report destination and it TRUNCATES without
753
+ // `-a` (BSD time(1): "If file exists and the -a flag is not specified, the
754
+ // file will be overwritten"). With an empty spec the operand was read as the
755
+ // verb, so `/usr/bin/time -o LEDGER tee -a X` resolved to `LEDGER` and hid
756
+ // the real write verb (#988 T3). Only the EXTERNAL `time` takes flags — the
757
+ // bash keyword rejects `-o` outright — so consuming them here cannot
758
+ // mis-parse a keyword invocation, which never carries `-o` in the first place.
759
+ // `-f FORMAT` / `--format FORMAT` is GNU time(1) (not BSD/macOS, where the
760
+ // binary rejects it — so this row is Linux-CI-relevant only). Without it the
761
+ // format string is skipped as a boolean and the FOLLOWING token is read as
762
+ // the verb: measured `/usr/bin/time -f %e npm test` → verb `%e` (#992).
763
+ ['time', {
764
+ argFlags: new Set(['-o', '--output', '-f', '--format']),
765
+ fileArgFlags: new Set(['-o', '--output']),
766
+ }],
767
+ ['timeout', {
768
+ argFlags: new Set(['-k', '--kill-after', '-s', '--signal']),
769
+ positionals: 1,
770
+ }],
771
+ ['nice', {
772
+ argFlags: new Set(['-n', '--adjustment']),
773
+ }],
774
+ ['stdbuf', {
775
+ argFlags: new Set(['-i', '-o', '-e', '--input', '--output', '--error']),
776
+ }],
777
+ ]);
778
+
779
+ /**
780
+ * Resolve the effective argv[0] (the command verb) for a chain segment:
781
+ * skips leading `VAR=value` env assignments, then unwraps chained transparent
782
+ * wrappers per WRAPPER_UNWRAP (`sudo env FOO=1 nice -n 10 bash …` → `bash`),
783
+ * collecting any wrapper-level command-string payloads (`env -S '…'`) on the
784
+ * way (#982).
785
+ *
786
+ * Skipping a prefix can only move verb resolution TOWARDS the real command, and
787
+ * a skipped token is either a wrapper name, an option or a duration — never an
788
+ * interpreter — so this cannot turn a match into a miss.
789
+ *
790
+ * Return contract (ADDITIVE — `wrapperArgs` was appended in #988 T3; existing
791
+ * consumers destructuring `{ verb, index, payloads }` are unaffected):
307
792
  *
308
793
  * @param {Array<{ text: string, quoted: boolean }>} segment
309
- * @returns {string|null}
794
+ * @returns {{ verb: string|null, index: number, payloads: string[],
795
+ * wrapperArgs: Array<{ wrapper: string, flag: string, value: string|null,
796
+ * writesFile?: true }> }}
797
+ * verb — bare program basename (or synthetic `sh` for `sudo -i`/`-s`), null
798
+ * when the segment exhausts in wrappers; index — token index of the resolved
799
+ * verb (-1 when null); payloads — command strings a wrapper will execute;
800
+ * wrapperArgs — the value-taking wrapper flags consumed on the way to the
801
+ * verb, in encounter order. `wrapper` is the wrapper's basename (`time`),
802
+ * `flag` the option as written (`-o`, `--output`), `value` its operand
803
+ * (`null` when the flag ended the segment). Both the separated (`-o FILE`)
804
+ * and the attached long form (`--output=FILE`) are reported.
805
+ *
806
+ * `writesFile: true` (#992) marks the entries whose operand is a FILE THE
807
+ * WRAPPER WRITES, per the spec's `fileArgFlags`. The key is present ONLY when
808
+ * true — an entry without it keeps the exact pre-#992 `{ wrapper, flag,
809
+ * value }` shape, so a `toEqual` on a non-file entry is unaffected. This is
810
+ * the answer to the question the caller actually has ("is this operand a
811
+ * write target?"), which used to be re-derived from a second table
812
+ * (`WRAPPER_FILE_FLAGS` in hooks/pre-bash-sessions-ledger-guard.mjs). That
813
+ * copy is now REDUNDANT and can be replaced by `wa.writesFile` (#991
814
+ * follow-up) — the knowledge lives here, next to the grammar it belongs to.
815
+ * `/usr/bin/time -o <ledger> npm test` truncates `<ledger>` while the verb is
816
+ * `npm`; extractRedirectTargets surfaces exactly these entries so the
817
+ * redirect denylist sees them too.
310
818
  */
311
- function segmentVerb(segment) {
819
+ export function resolveSegmentVerb(segment) {
820
+ const payloads = [];
821
+ const wrapperArgs = [];
312
822
  let i = 0;
313
823
  // 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)) {
824
+ while (i < segment.length && !segment[i].quoted && ENV_ASSIGN_RE.test(segment[i].text)) {
315
825
  i++;
316
826
  }
317
- // Unwrap `env [VAR=val …]` and `command` prefixes that delegate to a real verb.
318
827
  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)) {
828
+ const wrapper = segment[i].text.replace(/^.*\//, ''); // basename
829
+ const spec = WRAPPER_UNWRAP.get(wrapper);
830
+ if (!spec) break;
831
+ i++; // consume the wrapper word
832
+ let sawShellFlag = false;
833
+ while (i < segment.length) {
834
+ const tok = segment[i];
835
+ const text = tok.text;
836
+ if (spec.envAssignments && !tok.quoted && ENV_ASSIGN_RE.test(text)) { i++; continue; }
837
+ if (text === '--') { i++; break; }
838
+ if (!text.startsWith('-') || text === '-') break;
839
+ if (spec.splitString
840
+ && (text === '-S' || text === '--split-string'
841
+ || text.startsWith('--split-string=') || text.startsWith('-S'))) {
842
+ if (text.startsWith('--split-string=')) {
843
+ payloads.push(text.slice('--split-string='.length));
844
+ } else if (text !== '-S' && text !== '--split-string') {
845
+ payloads.push(text.slice(2)); // attached form: -S'string'
846
+ } else if (i + 1 < segment.length) {
847
+ payloads.push(segment[i + 1].text);
848
+ i++;
849
+ }
325
850
  i++;
851
+ continue;
326
852
  }
327
- continue;
853
+ if (spec.shellFlags && spec.shellFlags.has(text)) { sawShellFlag = true; i++; continue; }
854
+ if (spec.argFlags && spec.argFlags.has(text)) {
855
+ // Separated form `-o FILE`: record the operand, then skip BOTH tokens
856
+ // exactly as before (token accounting unchanged — recording only).
857
+ const entry = {
858
+ wrapper,
859
+ flag: text,
860
+ value: i + 1 < segment.length ? segment[i + 1].text : null,
861
+ };
862
+ if (spec.fileArgFlags?.has(text)) entry.writesFile = true;
863
+ wrapperArgs.push(entry);
864
+ i += 2;
865
+ continue;
866
+ }
867
+ if (spec.argFlags) {
868
+ // Attached long form `--output=FILE`. Consumes ONE token either way —
869
+ // this branch only records the operand the fall-through would drop.
870
+ const eq = text.indexOf('=');
871
+ if (eq > 0 && spec.argFlags.has(text.slice(0, eq))) {
872
+ const flag = text.slice(0, eq);
873
+ const entry = { wrapper, flag, value: text.slice(eq + 1) };
874
+ if (spec.fileArgFlags?.has(flag)) entry.writesFile = true;
875
+ wrapperArgs.push(entry);
876
+ }
877
+ }
878
+ i++; // unknown / boolean / attached-value flag — one token
328
879
  }
329
- if (verb === 'command') { i++; continue; }
330
- break;
880
+ for (let p = spec.positionals ?? 0; p > 0 && i < segment.length; p--) i++;
881
+ if (sawShellFlag) return { verb: 'sh', index: i, payloads, wrapperArgs };
882
+ }
883
+ if (i >= segment.length) return { verb: null, index: -1, payloads, wrapperArgs };
884
+ return { verb: segment[i].text.replace(/^.*\//, ''), index: i, payloads, wrapperArgs };
885
+ }
886
+
887
+ /**
888
+ * Shell verbs whose `-c <payload>` argument is a command line the shell will
889
+ * execute — recursion candidates for matchSegments (#982). `su` participates
890
+ * (`su root -c '…'`); the payload token is the one following `-c` or a bundled
891
+ * short-flag group ending in `c` (`-lc`, `-ec`).
892
+ */
893
+ const DASH_C_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'su']);
894
+
895
+ /**
896
+ * Collect `-c`-style payload strings after the verb within a segment.
897
+ *
898
+ * @param {Array<{ text: string, quoted: boolean }>} segment
899
+ * @param {number} verbIndex
900
+ * @returns {string[]}
901
+ */
902
+ function dashCPayloads(segment, verbIndex) {
903
+ const payloads = [];
904
+ for (let i = verbIndex + 1; i < segment.length - 1; i++) {
905
+ if (/^-[A-Za-z]*c$/.test(segment[i].text)) payloads.push(segment[i + 1].text);
331
906
  }
332
- if (i >= segment.length) return null;
333
- return segment[i].text.replace(/^.*\//, '');
907
+ return payloads;
334
908
  }
335
909
 
336
910
  /**
@@ -370,13 +944,405 @@ function quotedTokensMatch(segment, re) {
370
944
  * placeholder so they cannot bridge an adjacent-token match) and applies the
371
945
  * boundary regex.
372
946
  *
373
- * @param {Array<{ text: string, quoted: boolean }>} segment
947
+ * Redirect tokens (#983) and their operand word are ALSO replaced by space
948
+ * placeholders (same no-bridging treatment as quoted tokens): a redirect
949
+ * target is a filename argument to the shell, not part of the command verb
950
+ * surface — `rm -rf /tmp/ok > out.log` must not feed `>` / `out.log` into
951
+ * the pattern skeleton. `dup`-mode redirects (`2>&1`) carry their target
952
+ * inline and consume no operand.
953
+ *
954
+ * @param {Array<{ text: string, quoted: boolean, redirect?: object }>} segment
374
955
  * @param {RegExp} re
375
956
  * @returns {boolean}
376
957
  */
377
958
  function unquotedSegmentMatch(segment, re) {
378
- const skeleton = segment.map((t) => (t.quoted ? ' ' : t.text)).join(' ');
379
- return re.test(skeleton);
959
+ const parts = [];
960
+ for (let i = 0; i < segment.length; i++) {
961
+ const tok = segment[i];
962
+ if (tok.redirect) {
963
+ parts.push(' ');
964
+ // `dup` (2>&1) has its target inline; `heredoc` consumes its delimiter in
965
+ // the lexer and its body arrives as a separate QUOTED token — neither has
966
+ // an operand word to skip, and skipping would eat the next real command.
967
+ const hasOperandWord = tok.redirect.mode !== 'dup' && tok.redirect.mode !== 'heredoc';
968
+ if (hasOperandWord && i + 1 < segment.length && !segment[i + 1].redirect) {
969
+ parts.push(' ');
970
+ i++; // operand word belongs to the redirect — skip it too
971
+ }
972
+ continue;
973
+ }
974
+ parts.push(tok.quoted ? ' ' : tok.text);
975
+ }
976
+ return re.test(parts.join(' '));
977
+ }
978
+
979
+ /**
980
+ * Payload-recursion bounds (#982). Depth counts nested payload evaluations
981
+ * (top-level command = depth 0); the budget caps TOTAL payload evaluations per
982
+ * commandMatchesBlocked call so a hostile deeply-chained command cannot turn
983
+ * the hook hot-path into an amplification vector.
984
+ */
985
+ const MAX_PAYLOAD_DEPTH = 3;
986
+ const MAX_PAYLOAD_EVALUATIONS = 32;
987
+
988
+ /**
989
+ * Match a blocked-pattern regex against tokenized chain segments — the shared
990
+ * core of commandMatchesBlocked, recursion-capable for `-c` payloads (#982).
991
+ *
992
+ * Per segment, in order:
993
+ * 1) Unquoted occurrence → match.
994
+ * 2) Quoted occurrence + interpreter verb (after wrapper unwrap) → match.
995
+ * 3) ADDITIVE payload recursion: when the resolved verb is a `-c`-taking
996
+ * shell (or a wrapper collected a command-string payload, `env -S`),
997
+ * re-tokenize each payload and match it recursively. Strictly additive —
998
+ * never replaces check 2 (removing 2 would LOOSEN quoted-containment
999
+ * cases like `bash -c 'echo "rm -rf is dangerous"'`).
1000
+ *
1001
+ * @param {Array<Array<{ text: string, quoted: boolean }>>} segments
1002
+ * @param {RegExp} re
1003
+ * @param {number} depth — current payload-nesting depth (entry check < MAX)
1004
+ * @param {{ remaining: number }} budget — shared across the whole recursion
1005
+ * @returns {boolean}
1006
+ */
1007
+ function matchSegments(segments, re, depth, budget) {
1008
+ for (const segment of segments) {
1009
+ // 1) Unquoted occurrence anywhere in the segment → always a match.
1010
+ if (unquotedSegmentMatch(segment, re)) return true;
1011
+
1012
+ const resolved = resolveSegmentVerb(segment);
1013
+
1014
+ // 2) Quoted occurrence → only a match when the segment verb is an interpreter
1015
+ // that executes its quoted payload.
1016
+ if (quotedTokensMatch(segment, re)) {
1017
+ if (resolved.verb && SHELL_EXEC_INTERPRETERS.has(resolved.verb)) return true;
1018
+ // else: inert literal inside quotes for a non-interpreter verb → no match
1019
+ // for THIS segment; keep scanning other segments.
1020
+ }
1021
+
1022
+ // 3) `-c`/`env -S` payload recursion (depth-capped, budgeted).
1023
+ if (depth < MAX_PAYLOAD_DEPTH) {
1024
+ const payloads = resolved.payloads;
1025
+ if (resolved.verb && DASH_C_SHELLS.has(resolved.verb)) {
1026
+ payloads.push(...dashCPayloads(segment, resolved.index));
1027
+ }
1028
+ for (const payload of payloads) {
1029
+ if (budget.remaining <= 0) break;
1030
+ budget.remaining -= 1;
1031
+ const subTokens = tokenizeCommand(
1032
+ normalizeShellWhitespaceExpansions(payload, { expandSingleQuoted: true }),
1033
+ );
1034
+ if (matchSegments(splitSegments(subTokens), re, depth + 1, budget)) return true;
1035
+ }
1036
+ }
1037
+ }
1038
+ return false;
1039
+ }
1040
+
1041
+ /**
1042
+ * Recursive collector behind extractRedirectTargets (#983). Walks each
1043
+ * segment's redirect tokens, then re-tokenizes `-c` / `env -S` payloads with
1044
+ * the same depth/budget caps matchSegments uses.
1045
+ *
1046
+ * @param {Array<Array<{ text: string, quoted: boolean, redirect?: object }>>} segments
1047
+ * @param {Array<object>} out — accumulator
1048
+ * @param {number} depth
1049
+ * @param {{ remaining: number }} budget
1050
+ */
1051
+ function collectRedirectTargets(segments, out, depth, budget) {
1052
+ for (const segment of segments) {
1053
+ for (let i = 0; i < segment.length; i++) {
1054
+ const tok = segment[i];
1055
+ if (!tok.redirect) continue;
1056
+ const { fd, mode } = tok.redirect;
1057
+ // Deliberate boundary: only file-operand modes are reported. `dup`
1058
+ // (`2>&1`) targets a file descriptor, `heredoc`/`herestring` operands
1059
+ // are inline data/delimiters — none names a filesystem target.
1060
+ if (mode === 'dup' || mode === 'heredoc' || mode === 'herestring') continue;
1061
+ const operand = (i + 1 < segment.length && !segment[i + 1].redirect)
1062
+ ? segment[i + 1]
1063
+ : null;
1064
+ if (operand) i++; // operand word belongs to this redirect
1065
+ if (!operand || /[$`]/.test(operand.text)) {
1066
+ // Variable indirection (`> "$X"`), command substitution (`> $(cmd)` /
1067
+ // backticks), or a missing operand: fail-visible, never guess (#983).
1068
+ out.push({ target: null, mode, fd, unresolved: true });
1069
+ } else {
1070
+ out.push({ target: operand.text, mode, fd });
1071
+ }
1072
+ }
1073
+
1074
+ const resolved = resolveSegmentVerb(segment);
1075
+
1076
+ // A wrapper can truncate a file WITHOUT any redirect operator and without
1077
+ // being the verb: `/usr/bin/time -o CLAUDE.md npm test` empties CLAUDE.md
1078
+ // while the verb is `npm` (BSD time(1): "If file exists and the -a flag is
1079
+ // not specified, the file will be overwritten"). Measured pre-#992 against
1080
+ // the real 14-rule policy: `> CLAUDE.md` DENY, `/usr/bin/time -o CLAUDE.md
1081
+ // npm test` ALLOW. `writesFile` — never the bare `argFlags` membership — is
1082
+ // the discriminator: `stdbuf -o 0`, `nice -n 10`, `sudo -u root` all carry
1083
+ // an argFlag operand that is NOT a file, and all three stay unreported.
1084
+ //
1085
+ // Mode is `truncate` unconditionally, including under `time -a` (append).
1086
+ // Deliberate, safe-direction over-report: reading `-a` would mean tracking
1087
+ // the wrapper's BOOLEAN flags too — widening the contract a sibling guard
1088
+ // consumes — to buy back a false-positive class that is empty in practice
1089
+ // (nobody appends a timing report to a policy-protected file). An
1090
+ // under-report here is a bypass; this over-report is a nuisance at worst.
1091
+ for (const wa of resolved.wrapperArgs) {
1092
+ if (wa.writesFile !== true) continue;
1093
+ if (typeof wa.value !== 'string') continue;
1094
+ if (/[$`]/.test(wa.value)) {
1095
+ // Same fail-visible rule as a redirect operand (#983): never guess at a
1096
+ // variable or a command substitution, but never silently drop it either.
1097
+ out.push({ target: null, mode: 'truncate', fd: null, unresolved: true });
1098
+ continue;
1099
+ }
1100
+ out.push({ target: wa.value, mode: 'truncate', fd: null });
1101
+ }
1102
+
1103
+ const payloads = [...resolved.payloads];
1104
+ if (resolved.verb && DASH_C_SHELLS.has(resolved.verb)) {
1105
+ payloads.push(...dashCPayloads(segment, resolved.index));
1106
+ }
1107
+ if (payloads.length === 0) continue;
1108
+
1109
+ // A cap that drops payloads SILENTLY is a bypass, not a cap: 33 filler
1110
+ // `-c` segments exhausted the budget and `> CLAUDE.md` in the 34th came
1111
+ // back as an EMPTY target list, so the guard saw nothing (#988 T2,
1112
+ // probe-measured). Both cut-offs now emit an unresolved marker — the
1113
+ // DoS ceiling is unchanged, its effect is merely visible.
1114
+ if (depth >= MAX_PAYLOAD_DEPTH) {
1115
+ out.push({ target: null, mode: null, fd: null, unresolved: true, reason: 'depth-exceeded' });
1116
+ continue;
1117
+ }
1118
+ for (const payload of payloads) {
1119
+ if (budget.remaining <= 0) {
1120
+ out.push({ target: null, mode: null, fd: null, unresolved: true, reason: 'budget-exhausted' });
1121
+ break;
1122
+ }
1123
+ budget.remaining -= 1;
1124
+ const subTokens = tokenizeCommand(
1125
+ normalizeShellWhitespaceExpansions(payload, { expandSingleQuoted: true }),
1126
+ );
1127
+ collectRedirectTargets(splitSegments(subTokens), out, depth + 1, budget);
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Extract every filesystem redirect target from a command string (#983).
1134
+ *
1135
+ * Traverses all chain segments AND (read-only, via the existing payload
1136
+ * mechanics) every `-c` / `env -S` shell payload, depth-capped at
1137
+ * MAX_PAYLOAD_DEPTH with the shared MAX_PAYLOAD_EVALUATIONS budget.
1138
+ *
1139
+ * Entry shapes:
1140
+ * - `{ target: string, mode: 'truncate'|'append'|'read', fd: number|null }`
1141
+ * — resolved target; quoted targets are reported WITHOUT their quotes
1142
+ * (the tokenizer strips them).
1143
+ * - `{ target: null, mode, fd, unresolved: true }` — the operand contains a
1144
+ * variable (`> "$X"`), a command substitution, or is missing. Reported
1145
+ * fail-visible so the consuming guard can LOG it; deliberately NOT a
1146
+ * match candidate for redirectRuleMatches (see there — #641 FP class).
1147
+ * - `{ target: null, mode: null, fd: null, unresolved: true,
1148
+ * reason: 'budget-exhausted'|'depth-exceeded' }` — a payload subtree was
1149
+ * NOT traversed because a recursion cap cut it off (#988 T2). `mode` is
1150
+ * null: no redirect was parsed, so the entry belongs to no mode class and
1151
+ * a mode filter must not silently drop it.
1152
+ *
1153
+ * `dup` (`2>&1`), `heredoc` (`<<`), and `herestring` (`<<<`) redirects name
1154
+ * no filesystem target and are omitted (deliberate boundary, documented).
1155
+ *
1156
+ * WIDENED CONTRACT (#992): the traversal ALSO reports wrapper file operands —
1157
+ * `resolveSegmentVerb` entries carrying `writesFile: true`, i.e. today
1158
+ * `/usr/bin/time -o FILE` / `--output=FILE`. Such a write has no redirect
1159
+ * operator at all, so the pre-#992 traversal saw nothing and
1160
+ * `/usr/bin/time -o CLAUDE.md npm test` truncated a denylisted file while
1161
+ * `> CLAUDE.md` was blocked. They are emitted as `mode: 'truncate'`, `fd: null`
1162
+ * — indistinguishable from a `>` entry by design, because the EFFECT on the
1163
+ * named file is indistinguishable.
1164
+ *
1165
+ * @param {string} command
1166
+ * @returns {Array<{ target: string|null, mode: string|null, fd: number|null,
1167
+ * unresolved?: boolean, reason?: string }>}
1168
+ */
1169
+ export function extractRedirectTargets(command) {
1170
+ if (typeof command !== 'string' || command.length === 0) return [];
1171
+ const out = [];
1172
+ const segments = splitSegments(tokenizeCommand(normalizeShellWhitespaceExpansions(command)));
1173
+ collectRedirectTargets(segments, out, 0, { remaining: MAX_PAYLOAD_EVALUATIONS });
1174
+ return out;
1175
+ }
1176
+
1177
+ /**
1178
+ * Minimal glob-to-RegExp for redirect target-denylist matching (#983).
1179
+ * Same shape as the picomatch-absent fallback in scripts/lib/rule-loader.mjs
1180
+ * (`**` = any path segments, `*` = within-segment, `?` = single char) —
1181
+ * duplicated locally because this module is hook-hot-path pure (no imports
1182
+ * beyond node built-ins, no I/O at import time; see header invariant).
1183
+ *
1184
+ * @param {string} pattern
1185
+ * @returns {RegExp}
1186
+ */
1187
+ function redirectGlobToRegExp(pattern) {
1188
+ const p = String(pattern).replace(/\\/g, '/');
1189
+ let re = '';
1190
+ let i = 0;
1191
+ while (i < p.length) {
1192
+ const c = p[i];
1193
+ if (c === '*' && p[i + 1] === '*') {
1194
+ re += '.*';
1195
+ i += 2;
1196
+ if (p[i] === '/') i++;
1197
+ } else if (c === '*') {
1198
+ re += '[^/]*';
1199
+ i++;
1200
+ } else if (c === '?') {
1201
+ re += '[^/]';
1202
+ i++;
1203
+ } else if (c === '.') {
1204
+ re += '\\.';
1205
+ i++;
1206
+ } else {
1207
+ re += c.replace(/[$()+[\]^{|}]/g, '\\$&');
1208
+ i++;
1209
+ }
1210
+ }
1211
+ return new RegExp(`^${re}$`);
1212
+ }
1213
+
1214
+ /**
1215
+ * Collapse the macOS `/private` alias prefix: `/private/tmp` and `/private/var`
1216
+ * name the SAME directories as `/tmp` and `/var` (the short forms are symlinks
1217
+ * into `/private`). Without this, one location has two spellings that compare
1218
+ * unequal — a repo checked out under `/tmp/...` (CI runners, worktrees) would
1219
+ * not recognise its own root in a command that spells it `/private/tmp/...`.
1220
+ *
1221
+ * Deliberately STATIC: no `realpathSync` on user input. Resolving an
1222
+ * attacker-supplied path at guard time is its own risk class, and this module
1223
+ * is I/O-free by header invariant. Only the two known macOS aliases collapse;
1224
+ * every other path is returned byte-identical.
1225
+ *
1226
+ * @param {string} p — an absolute, already-normalized path
1227
+ * @returns {string}
1228
+ */
1229
+ function stripPrivateAlias(p) {
1230
+ return /^\/private\/(?:tmp|var)(?:\/|$)/.test(p) ? p.slice('/private'.length) : p;
1231
+ }
1232
+
1233
+ /**
1234
+ * Expand a LEADING `~` / `~/` in a redirect target to the operator's home dir —
1235
+ * the shell substitution the hook never gets to see, because a PreToolUse gate
1236
+ * receives the raw, UNEXPANDED command string. Same motivation as
1237
+ * `expandTmpdirToken` in hooks/pre-bash-destructive-guard.mjs (which does the
1238
+ * `$TMPDIR` half for rm operands); deliberately NOT merged with it — that one
1239
+ * expands an env VAR reference for the rm-allowlist, this one expands the
1240
+ * tilde WORD for the redirect denylist, and folding two token grammars into one
1241
+ * expander would widen both.
1242
+ *
1243
+ * `~user/...` is left untouched: another account's home is not this repo.
1244
+ *
1245
+ * Tilde expansion is applied REGARDLESS of the operand's quoting. A fully
1246
+ * quoted `> "~/x/CLAUDE.md"` is a literal `~` directory in the real shell, so
1247
+ * matching it is a (harmless, safe-direction) over-block; the partially quoted
1248
+ * `> ~/"My Docs"/CLAUDE.md` — which the tokenizer also reports as quoted, and
1249
+ * which the shell DOES expand — would otherwise be a real bypass.
1250
+ *
1251
+ * @param {string} target
1252
+ * @param {string|undefined} home
1253
+ * @returns {string}
1254
+ */
1255
+ function expandLeadingHome(target, home) {
1256
+ if (target !== '~' && !target.startsWith('~/')) return target;
1257
+ if (!home || !path.isAbsolute(home)) return target;
1258
+ return home + target.slice(1);
1259
+ }
1260
+
1261
+ /**
1262
+ * Reduce a raw redirect target to the repo-relative POSIX form the denylist
1263
+ * globs are written in, or `null` when it cannot name a file inside the repo.
1264
+ *
1265
+ * @param {string} raw — resolved target text (quotes already stripped)
1266
+ * @param {string|null} repoRoot — absolute repo root, or null (no resolution)
1267
+ * @param {string|undefined} home
1268
+ * @returns {string|null}
1269
+ */
1270
+ function repoRelativeRedirectTarget(raw, repoRoot, home) {
1271
+ const expanded = expandLeadingHome(raw, home);
1272
+
1273
+ if (!path.isAbsolute(expanded)) {
1274
+ return path.posix.normalize(expanded).replace(/^(\.\/)+/, '');
1275
+ }
1276
+ // Absolute target: only judgeable against a known repo root. Without one the
1277
+ // pre-#988 behaviour stands (no match) rather than a guess.
1278
+ if (!repoRoot || !path.isAbsolute(repoRoot)) return null;
1279
+ const rel = path.relative(
1280
+ stripPrivateAlias(path.normalize(repoRoot)),
1281
+ stripPrivateAlias(path.normalize(expanded)),
1282
+ );
1283
+ // '' = the root itself (a directory, not a file target); '..'-prefixed or
1284
+ // absolute = outside the repo, which the repo-relative denylist never covers.
1285
+ if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
1286
+ return null;
1287
+ }
1288
+ return path.posix.normalize(rel).replace(/^(\.\/)+/, '');
1289
+ }
1290
+
1291
+ /**
1292
+ * Match a `redirect-truncate` policy rule against a command (#983).
1293
+ *
1294
+ * For every extractRedirectTargets entry whose mode is in `rule.modes`
1295
+ * (default `['truncate']` — `truncate` covers `>`, `>|`, `&>`, and `N>`;
1296
+ * append `>>` stays allowed by design), the target is matched against the
1297
+ * rule's `target-denylist` globs (`**` / `*` / `?`). Targets are
1298
+ * POSIX-normalized before matching (`path.posix.normalize` collapses `.//`
1299
+ * and `sub/..` spellings — `> .//CLAUDE.md` and `> ./sub/../CLAUDE.md` (same
1300
+ * for the AGENTS.md alias) were silently ALLOWED pre-normalization, W4 F1a)
1301
+ * and a leading `./` is stripped.
1302
+ *
1303
+ * Absolute and `~` spellings (#988 T1). The denylist globs are repo-relative,
1304
+ * so `> /abs/path/to/repo/CLAUDE.md` and `> ~/repo/CLAUDE.md` matched NOTHING
1305
+ * and were silently allowed (probe-measured `rule abs: false`, `rule tilde:
1306
+ * false` against `rule rel: true`). Pass `{ repoRoot }` and such a target is
1307
+ * tilde-expanded, `/private`-alias-collapsed and made repo-relative before the
1308
+ * globs run; a target outside the repo yields no match. WITHOUT `repoRoot`
1309
+ * (the default) an absolute target still never matches — identical to the
1310
+ * pre-#988 contract, so existing callers keep their exact behaviour. This
1311
+ * function stays I/O-free: `~` resolves from `process.env.HOME` (overridable
1312
+ * via `home`), never via a filesystem lookup.
1313
+ *
1314
+ * Deliberate boundary (#641 FP class): `unresolved: true` entries (variable
1315
+ * indirection, command substitution) are NEVER matched — blocking on a guess
1316
+ * would reintroduce the false-positive class #641 removed. They remain
1317
+ * visible via extractRedirectTargets; the consuming guard hook (W3-A, #983)
1318
+ * decides whether to log them.
1319
+ *
1320
+ * The guard-hook branch dispatching on `rule.type === 'redirect-truncate'`
1321
+ * lives in hooks/pre-bash-destructive-guard.mjs and consumes this export.
1322
+ *
1323
+ * @param {{ modes?: string[], 'target-denylist'?: string[] }} rule
1324
+ * @param {string} command
1325
+ * @param {{ repoRoot?: string|null, home?: string|undefined }} [opts]
1326
+ * repoRoot — absolute repo root; enables absolute/`~` target resolution.
1327
+ * home — `~` expansion base; defaults to `process.env.HOME`.
1328
+ * @returns {boolean}
1329
+ */
1330
+ export function redirectRuleMatches(rule, command, opts = {}) {
1331
+ const { repoRoot = null, home = process.env.HOME } = opts;
1332
+ if (!rule || typeof command !== 'string' || command.length === 0) return false;
1333
+ const denylist = Array.isArray(rule['target-denylist']) ? rule['target-denylist'] : [];
1334
+ if (denylist.length === 0) return false;
1335
+ const modes = new Set(Array.isArray(rule.modes) && rule.modes.length > 0 ? rule.modes : ['truncate']);
1336
+ const regexes = denylist.map(redirectGlobToRegExp);
1337
+
1338
+ for (const entry of extractRedirectTargets(command)) {
1339
+ if (entry.unresolved) continue;
1340
+ if (!modes.has(entry.mode)) continue;
1341
+ const target = repoRelativeRedirectTarget(entry.target, repoRoot, home);
1342
+ if (target === null) continue;
1343
+ if (regexes.some((re) => re.test(target))) return true;
1344
+ }
1345
+ return false;
380
1346
  }
381
1347
 
382
1348
  /**
@@ -389,10 +1355,14 @@ function unquotedSegmentMatch(segment, re) {
389
1355
  *
390
1356
  * Quoted-payload guard: a pattern whose ONLY occurrences are wholly inside quoted
391
1357
  * 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.
1358
+ * segment's verb (argv[0], after skipping env-assignments and unwrapping the
1359
+ * transparent wrappers in WRAPPER_UNWRAP `env`, `command`, `sudo`, `doas`,
1360
+ * `nohup`, `timeout`, `nice`, `stdbuf` (#982)) is a shell-exec interpreter
1361
+ * (`bash -c "rm -rf /"`, `sudo bash -c "…"`, `eval "…"`, `psql -c "DROP TABLE …"`,
1362
+ * `find … -exec …`). The guard is applied PER chain segment: a quoted pattern in
1363
+ * segment N is judged against segment N's verb. Shell `-c` payloads (and
1364
+ * `env -S` strings) are additionally re-tokenized and matched recursively,
1365
+ * depth-capped at 3 with a total evaluation budget (#982).
396
1366
  *
397
1367
  * Boundary characters: whitespace, shell operators (`;`, `|`, `&`, `(`, `)`,
398
1368
  * `{`, `}`, backtick), or string quotes (`'`, `"`). Case-sensitive.
@@ -417,22 +1387,7 @@ export function commandMatchesBlocked(command, pattern) {
417
1387
  if (!re.test(normalizedCommand) && !re.test(payloadNormalizedCommand)) return false;
418
1388
 
419
1389
  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;
1390
+ return matchSegments(segments, re, 0, { remaining: MAX_PAYLOAD_EVALUATIONS });
436
1391
  }
437
1392
 
438
1393
  /**