@dougharness/cli 1.0.1

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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ablate.d.ts +96 -0
  3. package/dist/ablate.js +440 -0
  4. package/dist/ablate.js.map +1 -0
  5. package/dist/apply.d.ts +2 -0
  6. package/dist/apply.js +19 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +80 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/board-page.d.ts +15 -0
  12. package/dist/board-page.js +27 -0
  13. package/dist/board-page.js.map +1 -0
  14. package/dist/board-serve-process.d.ts +24 -0
  15. package/dist/board-serve-process.js +227 -0
  16. package/dist/board-serve-process.js.map +1 -0
  17. package/dist/board-serve.d.ts +9 -0
  18. package/dist/board-serve.js +302 -0
  19. package/dist/board-serve.js.map +1 -0
  20. package/dist/board.d.ts +6 -0
  21. package/dist/board.js +515 -0
  22. package/dist/board.js.map +1 -0
  23. package/dist/detect/fs.d.ts +6 -0
  24. package/dist/detect/fs.js +50 -0
  25. package/dist/detect/fs.js.map +1 -0
  26. package/dist/detect/index.d.ts +3 -0
  27. package/dist/detect/index.js +13 -0
  28. package/dist/detect/index.js.map +1 -0
  29. package/dist/detect/node.d.ts +3 -0
  30. package/dist/detect/node.js +179 -0
  31. package/dist/detect/node.js.map +1 -0
  32. package/dist/detect/python.d.ts +2 -0
  33. package/dist/detect/python.js +388 -0
  34. package/dist/detect/python.js.map +1 -0
  35. package/dist/detect/repo.d.ts +2 -0
  36. package/dist/detect/repo.js +102 -0
  37. package/dist/detect/repo.js.map +1 -0
  38. package/dist/detect/types.d.ts +98 -0
  39. package/dist/detect/types.js +2 -0
  40. package/dist/detect/types.js.map +1 -0
  41. package/dist/diff.d.ts +12 -0
  42. package/dist/diff.js +105 -0
  43. package/dist/diff.js.map +1 -0
  44. package/dist/generate/agents.d.ts +19 -0
  45. package/dist/generate/agents.js +184 -0
  46. package/dist/generate/agents.js.map +1 -0
  47. package/dist/generate/claude-md.d.ts +9 -0
  48. package/dist/generate/claude-md.js +111 -0
  49. package/dist/generate/claude-md.js.map +1 -0
  50. package/dist/generate/config.d.ts +51 -0
  51. package/dist/generate/config.js +155 -0
  52. package/dist/generate/config.js.map +1 -0
  53. package/dist/generate/proposal.d.ts +11 -0
  54. package/dist/generate/proposal.js +175 -0
  55. package/dist/generate/proposal.js.map +1 -0
  56. package/dist/generate/settings.d.ts +35 -0
  57. package/dist/generate/settings.js +213 -0
  58. package/dist/generate/settings.js.map +1 -0
  59. package/dist/generate/skills.d.ts +8 -0
  60. package/dist/generate/skills.js +264 -0
  61. package/dist/generate/skills.js.map +1 -0
  62. package/dist/init.d.ts +10 -0
  63. package/dist/init.js +69 -0
  64. package/dist/init.js.map +1 -0
  65. package/dist/trace.d.ts +9 -0
  66. package/dist/trace.js +106 -0
  67. package/dist/trace.js.map +1 -0
  68. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
  69. package/node_modules/@dougharness/flow/LICENSE +21 -0
  70. package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
  71. package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
  72. package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
  73. package/node_modules/@dougharness/flow/agents/lead.md +35 -0
  74. package/node_modules/@dougharness/flow/agents/planner.md +70 -0
  75. package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
  76. package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
  77. package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
  78. package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
  79. package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
  80. package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
  81. package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
  82. package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
  83. package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
  84. package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
  85. package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
  86. package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
  87. package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
  88. package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
  89. package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
  90. package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
  91. package/node_modules/@dougharness/flow/package.json +19 -0
  92. package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
  93. package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
  94. package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
  95. package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
  96. package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
  97. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
  98. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
  99. package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
  100. package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
  101. package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
  102. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
  103. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
  104. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
  105. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
  106. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
  107. package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
  108. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
  109. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
  110. package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
  111. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
  112. package/node_modules/@dougharness/gates/LICENSE +21 -0
  113. package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
  114. package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
  115. package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
  116. package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
  117. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
  118. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
  119. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
  120. package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
  121. package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
  122. package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
  123. package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
  124. package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
  125. package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
  126. package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
  127. package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
  128. package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
  129. package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
  130. package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
  131. package/node_modules/@dougharness/gates/package.json +17 -0
  132. package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
  133. package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
  134. package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
  135. package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
  136. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
  137. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
  138. package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
  139. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
  140. package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
  141. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
  142. package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
  143. package/package.json +33 -0
  144. package/templates/board-app.js +533 -0
@@ -0,0 +1,1522 @@
1
+ // Pure rule functions for the Bash guard so they can be unit-tested without spawning.
2
+
3
+ const PM_INSTALL_VERBS = {
4
+ npm: /^(install|i|add|ci|uninstall|remove|rm|un|update|up|dedupe|link)\b/,
5
+ pnpm: /^(install|i|add|remove|rm|uninstall|un|update|up|dedupe|link)\b/,
6
+ yarn: /^(install|add|remove|up|upgrade|dedupe|link)\b|^$/,
7
+ bun: /^(install|i|add|remove|rm|update|link)\b/,
8
+ };
9
+
10
+ // Card bash-rules-quote-aware-split: splits a compound shell command into simple commands using the shared
11
+ // quote-aware scanner (scanTop, below) instead of a quote-blind regex - a separator-looking character sitting
12
+ // inside a quoted word (a commit message, a node -e program) is no longer mistaken for a real "&&"/"||"/";"/
13
+ // "|"/"&"/"|&"/newline, and the body of every unquoted-or-double-quoted "$(...)" or backtick substitution
14
+ // really executes, so its pieces are split out too, recursively (research Q1.5; design decided by the lead,
15
+ // point 3). Falls back to today's quote-blind split - checking MORE than a real shell would, never less - when
16
+ // the scanner hits an unterminated quote or substitution (a heredoc body looks the same to it, and heredocs are
17
+ // out of scope - point 4) or throws for any other reason (a hook reader must never throw - CLAUDE.md Gotchas).
18
+ // A bare "&" (backgrounds the command before it, redirection forms excepted) and bash's "|&" (a pipe connector,
19
+ // treated the same as a bare "|") joined this set in review round 5 (third review) - see scanTop's comment.
20
+ const ALL_SEPS = new Set(["&&", "||", ";", "|", "&", "|&", "\n"]);
21
+
22
+ // Review round 2 (M1): a substitution body recurses through splitCommands one call per nesting level, and each
23
+ // call re-walks nearly the whole remaining string (cutOnSeparators -> scanTop -> scanParenBody), so an
24
+ // adversarial "$(" nest costs O(depth^2), and - far worse - enough real recursion depth (measured: a few
25
+ // thousand levels) can push commandForms's/splitCommands's own JS call stack to the point where a RangeError
26
+ // thrown mid-scan is caught here, and the catch's OWN quoteBlindSplit call then tries to compile the regex-split
27
+ // machinery while the stack is still that deep: V8 can fail that allocation as a FATAL, UNCATCHABLE process
28
+ // abort (measured: exit 134, "RegExpCompiler Allocation failed - process out of memory"), not a normal thrown
29
+ // exception - so the try/catch below cannot save it, and the whole hook process would die instead of failing
30
+ // open. Capping the recursion depth avoids both: past the cap, the remaining (however deeply the text still
31
+ // looks nested) body text is handed to quoteBlindSplit ONCE - a single linear regex pass, cheap regardless of
32
+ // how it looks - instead of recursing into it, so real call-stack depth never approaches the danger zone and
33
+ // total work stays linear in the input length. The cap is internal only, tracked by splitCommandsAt (below) -
34
+ // the EXPORTED splitCommands(command) takes exactly one parameter (review round 3, minor 4: a second exported
35
+ // parameter with a default, as this used to be, is a footgun - an accidental `somePieces.map(splitCommands)`
36
+ // elsewhere would silently pass Array.prototype.map's own index argument as depth, corrupting the cap). A
37
+ // caller that wants to recurse with a depth passed in calls the internal splitCommandsAt directly instead.
38
+ const SUBSTITUTION_DEPTH_CAP = 25;
39
+
40
+ // Review round 5: the same "&" rule as scanTop (a bare "&" backgrounds the command before it, unless it's part
41
+ // of a redirection - immediately preceded by "<"/">" or immediately followed by ">"), and "|&" as one unit,
42
+ // added to the quote-blind fallback's own split pattern too. The call was made to add it (not leave the
43
+ // fallback quote-blind): a regex split only ever ADDS cut points, and no DESTRUCTIVE row (or checkNoVerify's,
44
+ // checkForcePush's, ...) pattern requires trailing context past its own anchor (each is "^verb ... target" with
45
+ // a "\b" or "(\s|$)" right after the target, never "target and nothing else in the whole piece") - so an extra
46
+ // cut can only ever produce a SMALLER piece that still contains the same anchored match, never lose one HEAD
47
+ // would have found. Verified directly (see the coder's report) against the required no-false-positive set
48
+ // (cmd 2>&1, cmd >&2, cmd &>/dev/null, cmd 1>&2, cmd <&0, make -j4 &, git log 2>&1 | head, a 2>&1 | tee f, a
49
+ // quoted "&") on both the quote-aware AND (by temporarily forcing the fallback) the quote-blind path.
50
+ const QUOTE_BLIND_SEP = /\s*(?:&&|\|&|\|\||;|\|)\s*|\s*(?<![<>])&(?!>)\s*|\n/;
51
+
52
+ function quoteBlindSplit(str) {
53
+ return str
54
+ .split(QUOTE_BLIND_SEP)
55
+ .map((s) => s.trim())
56
+ .filter(Boolean)
57
+ .map(stripUnmatchedTrailingQuote);
58
+ }
59
+
60
+ // Review round 3 (Q18 fixture): quoteBlindSplit alone does not know "$(", "(", ")", or a backtick from any other
61
+ // character, so past the depth cap a body still wrapped in more than one un-recursed "$(...)" layer comes back
62
+ // as ONE piece that still starts with "$(" - checkDestructive's rows all anchor on the start of the piece
63
+ // (^rm, ^git, ^(sudo\s+)?chmod, ...), so a leading "$(" defeats every one of them even though a trailing ")"
64
+ // left over from a stage that DOES get isolated (a real separator inside the nest) does not (no row anchors at
65
+ // the END too - see checkPipeGroups' Q18 case). Turning every "$(", bare "(", ")", and backtick into a separator
66
+ // character first - before the regex split, not instead of it - fixes this: it costs one more linear pass (still
67
+ // O(remaining length), no recursion added), and it can only ever ADD split points, never remove a real one, so a
68
+ // destructive command sitting at the center of an arbitrarily deep bare "$(...)" nest becomes its own clean piece
69
+ // regardless of how many un-recursed layers are left (checks more, never less, same principle as everywhere
70
+ // else in this module).
71
+ function flattenParensForFallback(str) {
72
+ return str.replace(/\$\(|[()`]/g, ";");
73
+ }
74
+
75
+ // Review round 2 (B1 blocker): quoteBlindSplit runs on the WHOLE original string once scanTop gives up on an
76
+ // unterminated quote or substitution - and on THAT path quote pairing across the whole string is unreliable by
77
+ // definition (that is why the quote-aware scan gave up), so a piece the quote-blind regex cuts out can end with
78
+ // a single, unmatched trailing quote character left over from a quote the scanner "closed" far earlier in the
79
+ // string than where it was really opened (e.g. an apostrophe inside a "#" comment - not recognised, a known
80
+ // limit - merges quote state across an intervening real command, so a later, perfectly ordinary 'sh -c "cd x &&
81
+ // rm -rf ~"' gets cut at its OWN "&&" after all, leaving "rm -rf ~'" as its own piece). The DESTRUCTIVE catalogue
82
+ // no longer carries an allowance for that stray quote (Q14 dropped it, since the quote-aware path normally never
83
+ // produces one), so this fallback strips it itself instead, keeping DESTRUCTIVE's rows clean. "Unmatched" = that
84
+ // quote character occurs an odd number of times in the piece; only a TRAILING occurrence is stripped. A leading
85
+ // or embedded unmatched quote (the piece just before this one, e.g. "sh -c 'echo a") is left alone: it is not
86
+ // glued to whatever destructive target follows in a LATER piece, so stripping it would catch nothing, and
87
+ // commandForms's own fallback word-split already recovers that piece's literal text (echo a) well enough to run
88
+ // its per-command checks with nothing of security value lost - see the coder's report for the full trace.
89
+ function stripUnmatchedTrailingQuote(piece) {
90
+ for (const q of ["'", '"']) {
91
+ if (!piece.endsWith(q)) continue;
92
+ let count = 0;
93
+ for (let i = 0; i < piece.length; i += 1) if (piece[i] === q) count += 1;
94
+ if (count % 2 === 1) return piece.slice(0, -1);
95
+ }
96
+ return piece;
97
+ }
98
+
99
+ export function splitCommands(command) {
100
+ return splitCommandsAt(command, 0);
101
+ }
102
+
103
+ function splitCommandsAt(command, depth) {
104
+ try {
105
+ const str = String(command);
106
+ const cut = cutOnSeparators(str, ALL_SEPS);
107
+ if (!cut) return quoteBlindSplit(str);
108
+ const pieces = cut.pieces.slice();
109
+ for (const body of cut.subs) {
110
+ if (depth >= SUBSTITUTION_DEPTH_CAP) {
111
+ pieces.push(...quoteBlindSplit(flattenParensForFallback(body))); // past the cap: checks more, never less
112
+ } else {
113
+ pieces.push(...splitCommandsAt(body, depth + 1));
114
+ }
115
+ }
116
+ return pieces;
117
+ } catch {
118
+ return quoteBlindSplit(String(command));
119
+ }
120
+ }
121
+
122
+ // Plain, quote-blind word split - unchanged, and deliberately left that way: normalizeGit, checkForcePush, and
123
+ // checkPipeToShell's own shell-verb matching (matchesShellStage) all resolve a PREFIX (sudo, env, a flag's own
124
+ // argument, ...), which this card's goal does not ask to make quote-aware, and a quoted argument containing a
125
+ // space there is explicitly out of scope (see checkPipeToShell's comment below). Only commandForms's own word
126
+ // split (wordsQ, further down) is quote-aware, per the design.
127
+ function words(cmd) {
128
+ return cmd.split(/\s+/).filter(Boolean);
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------------------------------------
132
+ // Card bash-rules-quote-aware-split: one small scanner, shared by splitCommands (above), checkPipeToShell, and
133
+ // commandForms's word split (wordsQ). It is a guard: where a reading is ambiguous, it checks MORE text, never
134
+ // less (design decided by the lead).
135
+ //
136
+ // Scanner states (research Q1.1-4, 6): unquoted ("top"); single-quoted ("squote", literal to the next "'",
137
+ // backslash not an escape); double-quoted ("dquote", backslash escapes only $, `, ", \, and a raw newline - any
138
+ // other backslash is left in the word as an ordinary character; a "'" inside is inert); ANSI-C $'...' ("ansic",
139
+ // where \' and \\ do not end it - other backslash escapes are not reproduced here, a known limit, since nothing
140
+ // in this module needs to reconstruct \n/\t/... beyond keeping the quote open); $"..." reads as double-quoted.
141
+ // An unquoted backslash escapes the very next character (so \', \", \;, \|, \& are ordinary text and never open
142
+ // a quote or split - research Q1.3); an unquoted backslash-newline is a continuation, removed rather than
143
+ // treated as a separator or a word break. Closing a quote does not end the word (adjacent quoted/unquoted runs
144
+ // glue into one - research Q1.6).
145
+ //
146
+ // Separators recognised at the top level only: &&, ||, ;, |, and a raw newline - exactly today's set (Left out:
147
+ // a bare "&" is not added by this card).
148
+ //
149
+ // Command substitution executes (research Q1.5): the body of every "$(...)" and backtick pair that is unquoted
150
+ // or inside double quotes (not inside single- or ANSI-C-quoted text, where "$(" and "`" are just ordinary
151
+ // characters) is collected into `subs`, in the order encountered; the caller re-scans each body the same way,
152
+ // recursively, through the SAME exported entry points (splitCommands, checkPipeToShell), not through this
153
+ // function calling itself - so a nested substitution keeps recursing all the way down through ordinary
154
+ // top-level scans. Quotes inside a substitution's body are tracked independently of whatever quote the "$("
155
+ // itself sits inside (a stack, not a boolean: scanParenBody below starts its own body scan fresh in the "top"
156
+ // state). "$((...))" is read as a substitution too (its body is "(...)" verbatim) - over-checking is fine.
157
+ //
158
+ // Known limits, stated as limits, not bugs: a word-initial "#" comment and a heredoc body are not recognised
159
+ // (over-report only, never under-report - research Q1.7); zsh's RC_QUOTES, bash 5.x, and dash's lack of
160
+ // $'...' are unverified or divergent, and the bash/zsh reading is the one this scanner takes; a "(...)" subshell
161
+ // or "{ ...; }" group is not unwrapped (unchanged from today).
162
+ //
163
+ // Returns null when the string ends inside an unterminated quote or substitution - a real shell would run
164
+ // nothing at all (research Q1.8), but with no heredoc state a heredoc body looks the same to this scanner, so
165
+ // the caller falls back to the old quote-blind split for the whole string instead of denying nothing or
166
+ // throwing (checks more, never less).
167
+ function isDquoteEscapable(c) {
168
+ return c === "$" || c === "`" || c === '"' || c === "\\" || c === "\n";
169
+ }
170
+
171
+ // Finds the index of the backtick that closes the one at str[i] (str[i] === "`"): a backslash escapes the next
172
+ // character, so \` and \\ do not end it early. Returns -1 when the string ends first (unterminated).
173
+ function scanBacktickBody(str, i) {
174
+ let j = i + 1;
175
+ while (j < str.length) {
176
+ const c = str[j];
177
+ if (c === "\\") { j += 2; continue; }
178
+ if (c === "`") return j;
179
+ j += 1;
180
+ }
181
+ return -1;
182
+ }
183
+
184
+ // Finds the index of the ")" that closes the "(" at str[i] (str[i] === "("): every unquoted "(" (bare, or the
185
+ // one right after a "$") nests one level deeper and every unquoted ")" closes one, so a paren inside a nested
186
+ // quote or a nested backtick substitution is never mistaken for this one's close; a paren inside single- or
187
+ // double-quoted text does not count at all (research Q1.5's "in ; ner" example: quotes reset at "(" and are
188
+ // tracked independently of the outer context). Known limit (review round 2): a "case" pattern's own unquoted
189
+ // ")" (case $x in a) ... ;; esac inside a "$(...)") is taken as this substitution's close too, since this
190
+ // scanner has no case-statement state - real zsh (and some bash builds) still runs such a body as one
191
+ // substitution past that point, so this is an under-parse of the substitution's true extent, not just an
192
+ // over-report; not fixed here, out of scope for this card. Returns -1 when the string ends first (unterminated).
193
+ function scanParenBody(str, i) {
194
+ let depth = 1;
195
+ let j = i + 1;
196
+ let state = "top";
197
+ while (j < str.length) {
198
+ const c = str[j];
199
+ if (state === "top") {
200
+ if (c === "\\") { j += 2; continue; }
201
+ if (c === "'") { state = "squote"; j += 1; continue; }
202
+ if (c === '"') { state = "dquote"; j += 1; continue; }
203
+ if (c === "$" && str[j + 1] === "'") { state = "ansic"; j += 2; continue; }
204
+ if (c === "`") {
205
+ const close = scanBacktickBody(str, j);
206
+ if (close < 0) return -1;
207
+ j = close + 1;
208
+ continue;
209
+ }
210
+ if (c === "(") { depth += 1; j += 1; continue; }
211
+ if (c === ")") {
212
+ depth -= 1;
213
+ if (depth === 0) return j;
214
+ j += 1;
215
+ continue;
216
+ }
217
+ j += 1;
218
+ continue;
219
+ }
220
+ if (state === "squote") {
221
+ if (c === "'") state = "top";
222
+ j += 1;
223
+ continue;
224
+ }
225
+ if (state === "dquote") {
226
+ if (c === "\\" && isDquoteEscapable(str[j + 1])) { j += 2; continue; }
227
+ if (c === '"') state = "top";
228
+ j += 1;
229
+ continue;
230
+ }
231
+ // ansic
232
+ if (c === "\\") { j += 2; continue; }
233
+ if (c === "'") state = "top";
234
+ j += 1;
235
+ }
236
+ return -1;
237
+ }
238
+
239
+ // The one full pass: walks str once, classifying every character as inside a quote or at the top level, and
240
+ // records - at the top level only - every separator occurrence (&&, ||, ;, |, a bare "&", bash's "|&", or "\n"
241
+ // - see the "&" comment below for why a bare "&" is in this list now), every run of whitespace (so a
242
+ // word-level splitter can use the same pass - see wordsQ), and the raw body text of every live substitution
243
+ // (see the block comment above). Returns null on an unterminated quote or substitution.
244
+ //
245
+ // Review round 5 (third review, blocker of the same shape as the ANSI-C one): the brief's original "Left out:
246
+ // a bare & stays unadded" premise (splitCommands never used to dequote anything, so an escaped-then-bare "&&"
247
+ // inside a -c script string never became a real one) no longer holds once dequoteScript exists - "sh -c "echo
248
+ // \&& git reset --hard"" dequotes to "echo \&& git reset --hard", and a real shell reads that as "echo \&"
249
+ // (backgrounded, the backslash making the FIRST "&" literal) followed by a real, unescaped SECOND "&" that
250
+ // starts "git reset --hard" as its own command. HEAD only denied this by accident (its quote-blind split saw
251
+ // the raw "&&" in the ORIGINAL, still-quoted text); the current (pre-round-5) code parses the quotes correctly,
252
+ // sees one escaped "&" and one literal, unescaped "&" that isn't a separator anywhere in this scanner, and
253
+ // allows it - a real deny->allow regression, confirmed against real bash AND zsh (both run the second command;
254
+ // files run as `bash file` / `zsh file`, not through this session's own shell). A bare "&" is a real command
255
+ // separator (backgrounds the command before it) EXCEPT as part of a redirection: immediately preceded by ">"
256
+ // or "<" (2>&1, >&2, <&0, >&file - the fd-duplication and any-target forms) or immediately followed by ">"
257
+ // (&>file, &>>file - bash's combined stdout+stderr redirect) - confirmed with real bash/zsh probes (files, not
258
+ // this session's shell): `echo a & echo b`, `echo a 2>&1 & echo b`, and `echo a &>/dev/null; echo b` all behave
259
+ // exactly as a shell reader would expect (the first backgrounds and prints out of order; the second the same,
260
+ // the redirection unaffected; the third redirects "a" away with nothing backgrounded). This also closes an old
261
+ // gap the round-1 brief left out on purpose (back when dequoting didn't exist to make it urgent): HEAD allowed
262
+ // both `sleep 1 & rm -rf ~` and `sh -c 'echo a & rm -rf ~'`, and both really run the second command - both are
263
+ // denied now. Bash's `|&` (shorthand for `2>&1 |`) is recognised as ONE unit, a pipe connector, not a bare "&"
264
+ // immediately after a bare "|" (which would otherwise wrongly background "curl url |" - a syntax error - and
265
+ // read "& sh" as a separate, unrelated group, breaking checkPipeToShell's "curl/wget stage followed by a shell
266
+ // stage somewhere later in the SAME chain" logic for `curl url |& sh`; HEAD didn't catch this either - its own
267
+ // quote-blind split had no "&" handling at all and split "curl url |& sh" into ["curl url", "& sh"], and
268
+ // checkPipeToShell returned null on it, same as before this fix for a different, accidental reason).
269
+ function scanTop(str) {
270
+ const seps = [];
271
+ const spaces = [];
272
+ const subs = [];
273
+ let state = "top";
274
+ let i = 0;
275
+ while (i < str.length) {
276
+ const c = str[i];
277
+ if (state === "top") {
278
+ if (c === "\\") {
279
+ if (str[i + 1] === "\n") { i += 2; continue; } // backslash-newline: continuation, removed
280
+ i += 2; // escapes the very next character, whatever it is (never opens a quote, never splits)
281
+ continue;
282
+ }
283
+ if (c === "'") { state = "squote"; i += 1; continue; }
284
+ if (c === '"') { state = "dquote"; i += 1; continue; }
285
+ if (c === "$" && str[i + 1] === "'") { state = "ansic"; i += 2; continue; }
286
+ if (c === "$" && str[i + 1] === '"') { state = "dquote"; i += 2; continue; } // $"..." reads as dquote
287
+ if (c === "$" && str[i + 1] === "(") {
288
+ const close = scanParenBody(str, i + 1);
289
+ if (close < 0) return null;
290
+ subs.push(str.slice(i + 2, close));
291
+ i = close + 1;
292
+ continue;
293
+ }
294
+ if (c === "`") {
295
+ const close = scanBacktickBody(str, i);
296
+ if (close < 0) return null;
297
+ subs.push(str.slice(i + 1, close));
298
+ i = close + 1;
299
+ continue;
300
+ }
301
+ if (c === "\n") { seps.push({ start: i, end: i + 1, text: "\n" }); i += 1; continue; }
302
+ if (c === " " || c === "\t" || c === "\r") {
303
+ let j = i + 1;
304
+ while (j < str.length && (str[j] === " " || str[j] === "\t" || str[j] === "\r")) j += 1;
305
+ spaces.push({ start: i, end: j });
306
+ i = j;
307
+ continue;
308
+ }
309
+ if (c === "&" && str[i + 1] === "&") { seps.push({ start: i, end: i + 2, text: "&&" }); i += 2; continue; }
310
+ if (c === "|" && str[i + 1] === "&") { seps.push({ start: i, end: i + 2, text: "|&" }); i += 2; continue; }
311
+ if (c === "|" && str[i + 1] === "|") { seps.push({ start: i, end: i + 2, text: "||" }); i += 2; continue; }
312
+ if (c === ";") { seps.push({ start: i, end: i + 1, text: ";" }); i += 1; continue; }
313
+ if (c === "|") { seps.push({ start: i, end: i + 1, text: "|" }); i += 1; continue; }
314
+ if (c === "&") {
315
+ // A bare "&" is a separator (backgrounds what precedes it) unless it's part of a redirection - see the
316
+ // block comment above scanTop for the citations and the "|&" note.
317
+ const isRedirect = str[i - 1] === ">" || str[i - 1] === "<" || str[i + 1] === ">";
318
+ if (!isRedirect) seps.push({ start: i, end: i + 1, text: "&" });
319
+ i += 1;
320
+ continue;
321
+ }
322
+ i += 1; // an ordinary top-level character, including a bare "(" or ")" - not unwrapped (Left out)
323
+ continue;
324
+ }
325
+ if (state === "squote") {
326
+ if (c === "'") state = "top";
327
+ i += 1;
328
+ continue;
329
+ }
330
+ if (state === "dquote") {
331
+ if (c === "\\" && isDquoteEscapable(str[i + 1])) { i += 2; continue; }
332
+ if (c === '"') { state = "top"; i += 1; continue; }
333
+ if (c === "$" && str[i + 1] === "(") {
334
+ const close = scanParenBody(str, i + 1);
335
+ if (close < 0) return null;
336
+ subs.push(str.slice(i + 2, close));
337
+ i = close + 1;
338
+ continue;
339
+ }
340
+ if (c === "`") {
341
+ const close = scanBacktickBody(str, i);
342
+ if (close < 0) return null;
343
+ subs.push(str.slice(i + 1, close));
344
+ i = close + 1;
345
+ continue;
346
+ }
347
+ i += 1;
348
+ continue;
349
+ }
350
+ // ansic
351
+ if (c === "\\") { i += 2; continue; }
352
+ if (c === "'") { state = "top"; }
353
+ i += 1;
354
+ }
355
+ if (state !== "top") return null; // unterminated quote or substitution
356
+ return { seps, spaces, subs };
357
+ }
358
+
359
+ // Cuts str into raw pieces at every top-level separator whose text is in `kinds` (splitCommands passes all
360
+ // five; checkPipeToShell passes them in two separate calls - groups at &&/||/;/\n, then each group's stages at
361
+ // | - so a bare "|" is never a group cut and &&/||/;/\n are never a stage cut). Returns null (caller falls back)
362
+ // on an unterminated quote/substitution; otherwise { pieces, subs }, pieces trimmed and empties dropped.
363
+ function cutOnSeparators(str, kinds) {
364
+ const scanned = scanTop(str);
365
+ if (!scanned) return null;
366
+ const cuts = scanned.seps.filter((s) => kinds.has(s.text));
367
+ const pieces = [];
368
+ let start = 0;
369
+ for (const cut of cuts) {
370
+ pieces.push(str.slice(start, cut.start));
371
+ start = cut.end;
372
+ }
373
+ pieces.push(str.slice(start));
374
+ return { pieces: pieces.map((p) => p.trim()).filter(Boolean), subs: scanned.subs };
375
+ }
376
+
377
+ // Strips every leading git global option after "git" so every rule below can anchor on the verb without an
378
+ // option being mistaken for it: a git verb never starts with "-", so ANY leading token starting with "-" is
379
+ // skipped, not just an allowlist (a prior version allowlisted -C, -c, --git-dir, --work-tree, --no-pager, which
380
+ // let every other global option - -P, --paginate, --literal-pathspecs, --no-optional-locks, --exec-path=,
381
+ // --namespace=, etc. - through as if it were the verb). The exact space forms -C, -c, --git-dir, --work-tree
382
+ // still consume the next token as their separate-token argument (their attached forms, e.g. -C<dir>, -c<k=v>,
383
+ // --git-dir=<path>, already carry the argument in the same token and need no extra skip). Every other
384
+ // "-"-prefixed token skips just itself. The walk stops at the first token that doesn't start with "-": that is
385
+ // the verb, so it can never be skipped even if it looks like an option's argument (a two-token option's
386
+ // argument is the one exception, since it's consumed by definition). Commands that don't start with "git" (a
387
+ // bare word followed by whitespace) are returned unchanged.
388
+ export function normalizeGit(cmd) {
389
+ const w = words(cmd);
390
+ if (w[0] !== "git") return cmd;
391
+ let i = 1;
392
+ while (i < w.length) {
393
+ const tok = w[i];
394
+ if (!tok.startsWith("-")) break; // the verb: never eaten
395
+ if (tok === "-C" || tok === "-c" || tok === "--git-dir" || tok === "--work-tree") {
396
+ i += 2; // option + its separate-token argument
397
+ continue;
398
+ }
399
+ i += 1; // every other leading "-" option skips just itself
400
+ }
401
+ return ["git", ...w.slice(i)].join(" ");
402
+ }
403
+
404
+ export function checkNoVerify(cmd) {
405
+ const normalized = normalizeGit(cmd);
406
+ if (/^git\s+(commit|push|merge|rebase|cherry-pick)\b/.test(normalized) && /(^|\s)--no-verify(\s|$)/.test(normalized)) {
407
+ return `"--no-verify" skips the repository's git hooks. Run the hooks; fix what they report.`;
408
+ }
409
+ return null;
410
+ }
411
+
412
+ export function checkForcePush(cmd, protectedBranches, currentBranch) {
413
+ const normalized = normalizeGit(cmd);
414
+ if (!/^git\s+push\b/.test(normalized)) return null;
415
+ const forced = /(^|\s)(-f|--force|--force-with-lease(=\S+)?|--force-if-includes)(\s|$)/.test(normalized) || /\s\+[A-Za-z0-9_./-]+/.test(normalized);
416
+ if (!forced) return null;
417
+ const w = words(normalized).slice(2).filter((x) => !x.startsWith("-"));
418
+ // git push [remote] [refspec]
419
+ const refspec = w[1] || null;
420
+ let target = null;
421
+ if (refspec) {
422
+ const m = refspec.replace(/^\+/, "").split(":");
423
+ target = (m[1] || m[0]).replace(/^refs\/heads\//, "");
424
+ } else {
425
+ target = currentBranch;
426
+ }
427
+ if (target && protectedBranches.includes(target)) {
428
+ return `Force-pushing to "${target}" rewrites shared history. Push a new branch or ask the user.`;
429
+ }
430
+ return null;
431
+ }
432
+
433
+ // Card bash-rules-prefixed-commands: the target alternation also matches a trailing "/" or "/*" on the root,
434
+ // home, "$HOME", and parent-directory forms (./, ./*, /*, ~/, ~/*, $HOME/, $HOME/*, ../, ../*), which real
435
+ // shells treat the same as the bare form (rm -rf ~/* deletes everything under $HOME, same danger as rm -rf ~).
436
+ // An arbitrary absolute path (rm -rf /tmp/x) still isn't one of these tokens, so it stays allowed.
437
+ // Round 2 (R5, since card bash-rules-quote-aware-split: allowance removed, see Q14): the target used to accept
438
+ // one optional trailing "'" or '"' before the end/whitespace boundary, because the old quote-blind splitCommands
439
+ // split "sh -c 'cd x && rm -rf ~'" on the && sitting inside the quotes, leaving "rm -rf ~'" (a stray trailing
440
+ // quote with no matching opener in that piece) as its own split command. On the quote-aware path splitCommands
441
+ // never splits inside the quotes in the first place - there is no stray quote left to allow for THERE - and
442
+ // commandForms re-splits the extracted -c script itself to find the same target as a clean piece (Q8), so the
443
+ // allowance is gone from this row: a real trailing quote right after the target on that path (an actual
444
+ // unterminated word, not an artifact of the old split) is no longer specially permitted either, over-denying
445
+ // that arguably-safe edge the old code let through. That claim does NOT extend to the quote-blind FALLBACK path
446
+ // (an unterminated quote or substitution elsewhere in the string): a stray trailing quote can still land on a
447
+ // piece there, so quoteBlindSplit strips one itself (review round 2, B1: stripUnmatchedTrailingQuote, above)
448
+ // rather than this row growing the allowance back.
449
+ const DESTRUCTIVE = [
450
+ { re: /^rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|--recursive\s+--force|-r\s+-f|-f\s+-r)\s+(\/\*|\/|~\/\*|~\/|~|\$HOME\/\*|\$HOME\/|\$HOME|\.\/\*|\.\/|\.\.\/\*|\.\.\/|\.\.|\.|\*)(\s|$)/, why: "recursive delete of the root, home, current, or parent directory" },
451
+ { re: /^rm\s+-[a-zA-Z]*r[a-zA-Z]*\s+\/(\s|$)/, why: "recursive delete of the filesystem root" },
452
+ { re: /^git\s+reset\s+--hard\b/, why: "git reset --hard discards uncommitted work" },
453
+ { re: /^git\s+clean\s+-[a-zA-Z]*f/, why: "git clean -f deletes untracked files" },
454
+ { re: /^git\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/, why: "reverting the whole working tree discards uncommitted work" },
455
+ { re: /^git\s+branch\s+-D\b/, why: "force-deleting a branch" },
456
+ { re: /^git\s+push\s+.*--delete\b/, why: "deleting a remote branch" },
457
+ { re: /\bDROP\s+(TABLE|DATABASE|SCHEMA)\b/i, why: "dropping a table, schema, or database" },
458
+ { re: /^(sudo\s+)?chmod\s+-R\s+777\s+\//, why: "recursive chmod 777 on the filesystem root" },
459
+ { re: /^(sudo\s+)?mkfs\b|^dd\s+.*of=\/dev\//, why: "formatting or overwriting a block device" },
460
+ ];
461
+
462
+ export function checkDestructive(cmd) {
463
+ const normalized = normalizeGit(cmd);
464
+ for (const { re, why } of DESTRUCTIVE) {
465
+ if (re.test(normalized)) return `Blocked: ${why}. If the user wants this, they can run it themselves or disable bash.denyDestructive in .doug/config.json.`;
466
+ }
467
+ return null;
468
+ }
469
+
470
+ // Runs on the WHOLE command string, before splitCommands (which splits on "|" too and would sever the
471
+ // relationship between a download and the shell it's piped into). Internally splits on the top-level
472
+ // separators (&&, ||, ;, newline) that keep a pipeline's stages together, then walks each pipeline's stages
473
+ // looking for a chain that starts with a curl/wget stage and has a shell stage ANYWHERE later in the chain -
474
+ // not only as the last stage: the download still reaches the shell even when something else (tee, cat, ...)
475
+ // sits after it too. A shell stage's prefix is a loop, not a fixed order: at each position it accepts an env
476
+ // assignment (FOO=1), a "sudo" prefix (matched by basename, so /usr/bin/sudo counts too; accepting flags that
477
+ // take a separate argument such as -u root, -g wheel, --user=root, an attached "--long=value" as a single
478
+ // token, and a bare "--" that ends the option list - and, card bash-rules-sudo-shell-flags, marking the whole
479
+ // stage a shell stage outright when -s/--shell or -i/--login is met with no command word left after sudo's
480
+ // options: see scanSudoOptions and the SUDO_ARG_FLAGS comment below), or an "env" prefix (matched by basename too, so
481
+ // /usr/bin/env counts; accepting its own "--long=value", "--", and flags that take a separate argument such as
482
+ // -u NAME, -C DIR, -S STRING - see ENV_ARG_FLAGS - with the same rules), and keeps looping so any of these can
483
+ // repeat in any order (env before sudo, an assignment after either, sudo again, ...) until a token matches
484
+ // none of them: that token is the candidate verb, matched by its basename (the text after the last "/") so an
485
+ // absolute path like /bin/sh or /usr/local/bin/bash matches too. env's -S/--split-string is unlike every other
486
+ // env option: when its value (in any spelling: a separate token "-S value", an attached "-Svalue", or an
487
+ // attached "--split-string=value") is a non-empty word that doesn't itself start with "-", that value IS the
488
+ // command GNU env runs, so meeting it ends the walk right there: the result is just whether that value's
489
+ // basename is a shell (env -S sh, env -Ssh, and env --split-string=sh all reach a shell), and nothing after
490
+ // the value - a real verb, another prefix - is examined (env -S tee sh runs "tee", with "sh" as tee's own
491
+ // argument, not a shell stage). But real env only treats the value as the command under that condition: an
492
+ // empty value (env --split-string=) or one that looks like another option (env -S -i sh) is not the command,
493
+ // so env keeps parsing options from there and the actual command comes from what follows (env -S -i echo HIT
494
+ // prints HIT) - the walk re-examines that same token (the separate-token form) or moves past it and continues
495
+ // (an attached form, whose value can't be split back into its own token).
496
+ //
497
+ // Card bash-rules-command-wrappers: six more prefixes are taken, each one more branch of the same loop,
498
+ // matched by basename like sudo and env (so /usr/bin/nice, /usr/bin/time, /usr/bin/nohup count too; exec and
499
+ // command are shell builtins with no path form, but basename matching is harmless and keeps one shape) and
500
+ // each walking its options with skipPrefixOptions and its own argument-flag set:
501
+ // - nice: -n and --adjustment take the next token; the legacy negative form (nice -10 sh) is a "-"-prefixed
502
+ // token that isn't a recognised flag, so it falls to the generic "skip just itself" branch, same as any
503
+ // unrecognised short option; --adjustment=N is one token, handled by the existing "--long=value" branch.
504
+ // - time: -f, -o, --format, --output take the next token (bash's keyword and GNU/BSD /usr/bin/time both run
505
+ // the next word regardless); -p, -a, -v take none.
506
+ // - nohup: no flags of its own worth modelling (--help/--version run nothing, but that's not modelled -
507
+ // over-denying is the safe side here, as elsewhere in this module).
508
+ // - exec: -a takes the next token (exec -a NAME sh sets argv0 and still runs sh); -c, -l take none.
509
+ // - command: no flags take a following token, but -v and -V are terminal rather than merely argument-free:
510
+ // real command -v NAME / -V NAME print information and run NOTHING, so meeting either ends the walk right
511
+ // there with "not a shell stage" (see scanCommandOptions) - nothing after it, including a real shell verb,
512
+ // is examined. -p takes no argument. Card bash-rules-combined-short-flags: a combined short-flag cluster
513
+ // (bash's exec -cla NAME, BSD time -po FILE, sudo -Eu root, env -iu PATH) IS modelled, for every wrapper
514
+ // that walks its options with skipPrefixOptions, scanSudoOptions (sudo, same clustering rules plus its own
515
+ // shell-flag tracking - card bash-rules-sudo-shell-flags), or scanEnvOptions - clusterFlag reads the cluster
516
+ // letter by letter, left to right, against the wrapper's argument-flag set, the way getopt does (see
517
+ // clusterFlag for the exact rule). command is the one exception: it keeps its exact -v/-V check unclustered, so a cluster
518
+ // like -pv is not -v and command -pv sh is still caught - over-denying on the safe side, pinned by a test
519
+ // rather than fixed.
520
+ // - busybox: no flags modelled (busybox's own options - --list, --install, --help - run no applet); a
521
+ // "-"-prefixed token after busybox falls to the generic skip and is treated as consuming just itself, which
522
+ // over-denies busybox --list sh (real busybox --list doesn't run sh) - the safe side, as with nohup above.
523
+ // busybox <applet> runs the applet, so busybox sh is a shell stage, busybox env sh continues through the
524
+ // env branch above, and busybox ash is caught too, since card bash-rules-shell-verb-set added ash to
525
+ // SHELL_VERBS below.
526
+ // Out of scope, deliberately: "xargs" (curl url | xargs sudo -u root sh runs the shell with the downloaded
527
+ // bytes as an argument list, not on stdin, and taking xargs would mean modelling xargs's own flags to find
528
+ // where its command starts) and a quoted argument containing a space (sudo -p 'enter pw' sh, env -S 'sh -c x',
529
+ // time -f '%e s' sh): this module parses no quotes, so a quoted token is indistinguishable from several bare
530
+ // ones.
531
+ //
532
+ // Card bash-rules-shell-verb-set: the set was sh, bash, zsh, dash, ksh, fish - missing several real shells
533
+ // (busybox's ash, csh, tcsh, mksh, yash, busybox's hush) and any versioned binary name (bash5, bash-5.2,
534
+ // zsh-5.9), all real ways to run a shell that this module let through. The decision, and why:
535
+ // - ash, csh, tcsh, mksh, yash, hush join the Set outright: each is a real, independently-shipped shell binary
536
+ // (ash is also busybox's default shell, hush is busybox's minimal one), no different in kind from the six
537
+ // already listed.
538
+ // - bash and zsh alone also match a versioned form (VERSIONED_SHELL below): the shape is taken on the card's
539
+ // word, not on confirmed usage - bash5, bash-5.2, and zsh-5.9 are the forms the goal names as the ones to
540
+ // catch. Whether any distribution or manual install actually ships a binary under one of these names is
541
+ // unverified (see the brief's Facts). The acceptance is over-deny either way: a name nobody ships resolves
542
+ // to no binary, so the rule catches nothing real and costs nothing. The suffix is on bash and zsh only, not
543
+ // folded into a regex over every name in the Set, because those are the two names the goal calls out; a
544
+ // version number after "dash" or "fish" (dash-0.5, fish2) is not a form the goal names, and guessing at it
545
+ // would claim more than is known.
546
+ // - Interpreters that are not shells - python, perl, node, ruby - stay out: each reads a script from stdin
547
+ // only under its own flag ("python -" or "-c", "perl -", "node -", "ruby -"), never bare, so catching them
548
+ // needs a different rule shaped around those flags, not a name added to a shell-verb set. That is a
549
+ // separate card if one is wanted; python3 is explicitly out of scope by this card's goal.
550
+ const SHELL_VERBS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "ash", "csh", "tcsh", "mksh", "yash", "hush"]);
551
+
552
+ // Card bash-rules-shell-verb-set: a version suffix is recognized on bash and zsh only (see the comment above) -
553
+ // an optional dash followed by a digit and any run of digits/dots, so "bash5", "bash-5.2", and "zsh-5.9" match
554
+ // but "bashful" and "zsh-" (no digit) do not. The same run-of-digits-and-dots shape also matches strings no
555
+ // real version number takes, such as a trailing dot ("bash5.", "bash-5.2.") or a leading zero ("bash05") -
556
+ // over-deny, the safe side: a name nobody ships this way just resolves to no binary.
557
+ const VERSIONED_SHELL = /^(bash|zsh)-?[0-9][0-9.]*$/;
558
+
559
+ // Single point of truth for "is this basename a shell verb", used at the one call site (matchesShellWords)
560
+ // instead of a bare SHELL_VERBS.has so the versioned-bash/zsh form is checked too.
561
+ function isShellVerb(name) {
562
+ return SHELL_VERBS.has(name) || VERSIONED_SHELL.test(name);
563
+ }
564
+
565
+ // An env-style assignment token (FOO=1, PATH=/x:/y): a bare NAME= prefix, distinct from a "--long=value" flag
566
+ // because it doesn't start with "-".
567
+ const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
568
+
569
+ // sudo flags that consume the next token as their argument (short forms and their long equivalents); a flag
570
+ // not in this set (e.g. -E) takes no argument and leaves the following token alone.
571
+ //
572
+ // Card bash-rules-sudo-shell-flags: -s/--shell and -i/--login are NOT in this set - like -E, they take no
573
+ // argument - but meeting either (bare, as the long form, or as a cluster letter - see below) marks the whole
574
+ // sudo stage a shell stage when nothing but options and env assignments (which sudo sets rather than runs)
575
+ // follows: sudo execs the shell with no -c in that case, and a shell with no operands reads stdin as its
576
+ // script. man sudo, -s: "Run the shell ... If no command is specified, an interactive shell is executed.";
577
+ // -i: "Run the shell ... as a login shell. ... If no command is specified, an interactive shell is executed."
578
+ // POSIX sh: "If there are no operands and the -c option is not specified, the -s option shall be assumed" /
579
+ // "-s Read commands from the standard input." If a command word follows instead, sudo passes it to the shell
580
+ // via -c and that command's stdin is the pipe, not the shell's: man sudo, both entries, "If a command is
581
+ // specified, it is passed to the shell as a simple command using the -c option." So the walk continues from
582
+ // that word exactly as it would without the flag (curl url | sudo -s sh is caught by the verb; curl url |
583
+ // sudo -s tee f is null - see scanSudoOptions, called from matchesShellWords's sudo branch).
584
+ //
585
+ // sudo's getopt string (src/parse_args.c, tag v1.9.17p2: "+Aa:BbC:c:D:Eeg:Hh::iKklNnPp:R:r:SsT:t:U:u:Vv") takes
586
+ // no argument for s or i, so in a cluster (sudo -Es, -su, -sh, -Ei, ...) the letters are read left to right and
587
+ // s/i is looked for only among the letters BEFORE the cluster's first argument-taking letter (all letters when
588
+ // there is none): a letter after that point belongs to the argument-taking flag's value, not to the shell
589
+ // decision (sudo -us: u is first and argument-taking, so its attached value "s" is the username, not a shell
590
+ // flag - the walk continues to whatever follows, unaffected; sudo -su: s comes first, so it marks the stage,
591
+ // and the trailing u takes "sh" as its argument, an over-deny per below). Flags keep their case throughout this
592
+ // module (see basenameOf's comment): -S (capital, --stdin, read the sudo password from stdin) is unrelated and
593
+ // never marks the stage.
594
+ //
595
+ // Two corrections from the research note, both over-deny (the safe side, as elsewhere in this module: a form
596
+ // nobody's real sudo would run this way just costs a needless block):
597
+ // - real `sudo -sh sh` prints usage and runs nothing: sudo's getopt entry for -h is `h::`, an OPTIONAL
598
+ // ATTACHED value only, so inside a cluster -h consumes nothing from the next word at all - it just sets
599
+ // help mode (the separate "-h HOST" meaning "the target host" applies only when the whole token is exactly
600
+ // "-h", per parse_args.c's got_host_flag macro, which a cluster like "-sh" never satisfies). With -s already
601
+ // setting MODE_SHELL, parse_args.c's mode-vs-valid_flags check then fails against the help mode -h set, so
602
+ // real sudo calls usage() and runs nothing. Consuming the next word ("sh") as -h's argument is THIS
603
+ // MODULE's own model of -h, not sudo's - SUDO_ARG_FLAGS treats -h as --host, taking a separate token, the
604
+ // way most of sudo's other argument flags work. So this module still marks curl url | sudo -sh sh as a
605
+ // shell stage: telling the help-mode case apart needs modelling got_host_flag itself, which this card's
606
+ // goal doesn't ask for.
607
+ // - real sudo rejects -i combined with -s or -E ("you may not specify both the -i and -s/-E options" -
608
+ // parse_args.c). This module still marks curl url | sudo -Ei and friends.
609
+ const SUDO_ARG_FLAGS = new Set([
610
+ "-u", "-g", "-h", "-p", "-C", "-D", "-R", "-r", "-t", "-T", "-U",
611
+ "--user", "--group", "--host", "--prompt", "--close-from", "--chdir", "--chroot", "--role", "--type",
612
+ "--command-timeout", "--other-user",
613
+ ]);
614
+
615
+ // env flags that consume a separate following token as their argument (GNU env). -a/--argv0, --block-signal,
616
+ // --default-signal, and --ignore-signal also accept an attached "=value" form, which the "--long=value" branch
617
+ // below already handles by consuming the token whole - fine for these, since only env itself needs the value.
618
+ // -S/--split-string is the exception: it accepts the same attached "=value" form (and its own attached short
619
+ // form, "-Svalue"), but its value is a command line, not a plain argument, so it is examined rather than merely
620
+ // consumed - and only actually consumed (as env's command) when it's a non-empty, non-option word; otherwise
621
+ // env keeps parsing options from there - see scanEnvOptions. -i, -0, -v, --ignore-environment, --null, --debug
622
+ // take no argument.
623
+ const ENV_ARG_FLAGS = new Set([
624
+ "-u", "--unset", "-C", "--chdir", "-S", "--split-string",
625
+ "-a", "--argv0", "--block-signal", "--default-signal", "--ignore-signal",
626
+ ]);
627
+
628
+ // nice: -n/--adjustment take the next token as the adjustment value; a bare "-10"-style legacy adjustment is
629
+ // not in this set, so it falls to skipPrefixOptions's generic "skip just itself" branch.
630
+ const NICE_ARG_FLAGS = new Set(["-n", "--adjustment"]);
631
+
632
+ // time: -f/-o/--format/--output take the next token; -p/-a/-v (not listed here) take none, so they fall to
633
+ // skipPrefixOptions's generic no-argument branch.
634
+ const TIME_ARG_FLAGS = new Set(["-f", "-o", "--format", "--output"]);
635
+
636
+ // exec: -a takes the next token (sets argv0); -c/-l (not listed here) take none.
637
+ const EXEC_ARG_FLAGS = new Set(["-a"]);
638
+
639
+ // nohup and busybox take no flags worth modelling; empty sets keep them the same shape as every other prefix
640
+ // (every "-"-prefixed token they meet falls to skipPrefixOptions's generic "skip just itself" branch).
641
+ const NOHUP_ARG_FLAGS = new Set([]);
642
+ const BUSYBOX_ARG_FLAGS = new Set([]);
643
+
644
+ // Card bash-rules-shell-verb-case: the basename is lower-cased before every comparison against SHELL_VERBS or a
645
+ // prefix name, because macOS's default case-insensitive filesystem resolves SH to /bin/sh and would run the
646
+ // download; on a case-sensitive filesystem this only over-denies a name that wouldn't resolve there anyway (no
647
+ // SH binary), the safe side. Only the basename is lower-cased here - flags (env's -S/-s, command's -v/-V, ...)
648
+ // keep their case and are compared exactly as written, everywhere else in this module.
649
+ function basenameOf(tok) {
650
+ return tok.slice(tok.lastIndexOf("/") + 1).toLowerCase(); // matches an absolute path (/bin/sh, /usr/bin/env) by its tail
651
+ }
652
+
653
+ // Card bash-rules-combined-short-flags: a "cluster" is a single-dash token of two or more letters (bash's
654
+ // exec -cla NAME, BSD time -po FILE, sudo -Eu root, env -iu PATH). `--long`, `--long=value`, `--`, a lone
655
+ // `-x`, a legacy numeric adjustment (nice's -10, -n5), and an attached env -S value (-Ssh: a leading S, not a
656
+ // cluster with S found partway through) are not clusters here and keep their existing handling elsewhere.
657
+ // getopt reads a cluster letter by letter, left to right, against the wrapper's argument-flag set (each
658
+ // letter L looked up as "-L"): the FIRST letter found in the set decides - if it's the LAST letter, the
659
+ // cluster consumes the next token as that flag's argument (-Eu root, -la NAME, -po FILE, -iu PATH); otherwise
660
+ // the rest of the token is that flag's attached value and the cluster is self-contained (-uroot, -fo where o
661
+ // is f's value). No letter in the set (every letter a no-argument flag) is self-contained too - both
662
+ // self-contained cases fall to the caller's existing generic "skip just itself" branch, unchanged.
663
+ //
664
+ // One helper, used from both skipPrefixOptions and scanEnvOptions. scanEnvOptions needs more than a yes/no:
665
+ // its -S/--split-string letter is terminal (the value is a command line, not a plain argument - see
666
+ // scanEnvOptions), so this returns the matched letter and the token's remainder after it (the attached value
667
+ // when the letter isn't last), not just whether the next token is consumed. Returns null when tok isn't a
668
+ // cluster, or is one with no letter in argFlags (both self-contained).
669
+ const CLUSTER_RE = /^-[A-Za-z]{2,}$/;
670
+ function clusterFlag(tok, argFlags) {
671
+ if (!CLUSTER_RE.test(tok)) return null;
672
+ const letters = tok.slice(1);
673
+ for (let j = 0; j < letters.length; j += 1) {
674
+ if (argFlags.has("-" + letters[j])) {
675
+ return { letter: letters[j], last: j === letters.length - 1, rest: letters.slice(j + 1) };
676
+ }
677
+ }
678
+ return null;
679
+ }
680
+
681
+ // Walks an option run starting at i for a prefix command with no special-value flag of its own to track (nice,
682
+ // time, nohup, exec, busybox - each calls this directly): "--" ends the options (consuming itself),
683
+ // "--long=value" is one token, a flag in argFlags consumes a separate following token, a combined short-flag
684
+ // cluster ending in an argument-taking letter consumes a separate following token too (see clusterFlag), and
685
+ // any other "-"-prefixed token consumes just itself. sudo and env share this same option shape but each needs
686
+ // its own extra tracking on top of it (sudo's -s/-i/--shell/--login mark the whole stage a shell stage, env's
687
+ // -S/--split-string value can itself be the command), so each walks its options with its own function built on
688
+ // these rules - scanSudoOptions and scanEnvOptions - rather than calling this one directly.
689
+ // Returns the index just past the options, i.e. where the next prefix token or the verb starts.
690
+ function skipPrefixOptions(w, i, argFlags, onArgFlag) {
691
+ while (i < w.length) {
692
+ const tok = w[i];
693
+ if (tok === "--") {
694
+ i += 1;
695
+ break;
696
+ }
697
+ if (!tok.startsWith("-")) break; // not an option token: an env assignment, another prefix, or the verb
698
+ if (tok.includes("=")) {
699
+ i += 1; // "--long=value" is one token
700
+ continue;
701
+ }
702
+ if (argFlags.has(tok)) {
703
+ if (onArgFlag) onArgFlag(tok, w[i + 1]);
704
+ i += 2; // flag + its separate-token argument
705
+ continue;
706
+ }
707
+ const cluster = clusterFlag(tok, argFlags);
708
+ if (cluster && cluster.last) {
709
+ if (onArgFlag) onArgFlag(tok, w[i + 1]);
710
+ i += 2; // cluster + its separate-token argument (matched letter is last: exec -la NAME, sudo -Eu root)
711
+ continue;
712
+ }
713
+ i += 1; // flag that takes no argument (e.g. -E, -i), or a self-contained cluster (-uroot, -fo)
714
+ }
715
+ return i;
716
+ }
717
+
718
+ // Walks an env prefix's option run starting at i (just past "env"/"/usr/bin/env"). Unlike skipPrefixOptions
719
+ // (used for nice, time, nohup, exec, and busybox, none of which have such a flag) or scanSudoOptions (sudo has
720
+ // no split-string-like flag either), this gives -S/--split-string - in any spelling: separate token
721
+ // "-S value", attached "-Svalue", or attached "--split-string=value" - special handling: real env treats the
722
+ // value as the command to run only when it is a non-empty word that doesn't itself start with "-" (env -S -i
723
+ // echo HIT runs "echo", not "-i"); when it qualifies, that value is env's command, so it (and everything after
724
+ // it) is handed back to the prefix walk to decide - a wrapper value (sudo, nice, nohup, exec, time, command,
725
+ // busybox, env, or an env assignment) walks its own branch there, and a plain word is matched by basename
726
+ // exactly as before. For the separate-token form the value is already its own token, so this returns { next }
727
+ // pointing at it and lets matchesShellWords's loop continue from there. For an attached form (whose value can't
728
+ // be pulled back out as its own token) there is no token to point { next } at, so this returns { splice: value,
729
+ // next } instead: the caller replaces everything up to `next` with the single token `value` and resumes its own
730
+ // loop at index 0 - a rebuild, not a recursive call, so nested attached values (env -Senv -Senv ... sh) cost no
731
+ // call-stack depth no matter how many there are. Otherwise (the value is missing, empty, or itself option-
732
+ // shaped) it isn't the command, so env keeps parsing options from there: for the separate-token form the walk
733
+ // re-examines that same value token as the next option (advancing by 1, not 2, past just the flag), and for an
734
+ // attached form the walk simply moves past that one token, as it would past any other no-argument flag. Returns
735
+ // { splice, next } when an attached form's value qualifies as the command, or { next } with the index of the
736
+ // value (when it qualifies, separate-token form) or of the first non-option token (the next prefix or the verb)
737
+ // otherwise.
738
+ function scanEnvOptions(w, i) {
739
+ while (i < w.length) {
740
+ const tok = w[i];
741
+ if (tok === "-S" || tok === "--split-string") {
742
+ const value = w[i + 1];
743
+ if (value && !value.startsWith("-")) return { next: i + 1 };
744
+ i += 1; // not the command: re-scan the value token itself (or, if there is none, the walk just ends)
745
+ continue;
746
+ }
747
+ const attached = /^-S(.+)$/.exec(tok) || /^--split-string=(.*)$/.exec(tok);
748
+ if (attached) {
749
+ const value = attached[1];
750
+ if (value && !value.startsWith("-")) return { splice: value, next: i + 1 };
751
+ i += 1; // not the command: this token carried no other option to re-scan, so it's simply skipped
752
+ continue;
753
+ }
754
+ if (tok === "--") return { next: i + 1 };
755
+ if (!tok.startsWith("-")) return { next: i }; // not an option token: an env assignment, another prefix, or the verb
756
+ if (tok.includes("=")) {
757
+ i += 1; // "--long=value" is one token
758
+ continue;
759
+ }
760
+ if (ENV_ARG_FLAGS.has(tok)) {
761
+ i += 2; // flag + its separate-token argument
762
+ continue;
763
+ }
764
+ const cluster = clusterFlag(tok, ENV_ARG_FLAGS);
765
+ if (cluster && cluster.letter === "S") {
766
+ // S is the first set-letter in the cluster (e.g. -iS, -iSsh): the same split-string value rule applies,
767
+ // to the next token when S is last (env -iS sh) or to the token's remainder when it isn't (env -iSsh).
768
+ // S-last is the separate-token case (the value is w[i + 1], its own token); S-mid-token is the attached
769
+ // case (the value is the cluster's remainder, not its own token), so it splices the same as -Svalue.
770
+ if (cluster.last) {
771
+ const value = w[i + 1];
772
+ if (value && !value.startsWith("-")) return { next: i + 1 };
773
+ i += 1; // not the command: re-scan the next token
774
+ continue;
775
+ }
776
+ const value = cluster.rest;
777
+ if (value && !value.startsWith("-")) return { splice: value, next: i + 1 };
778
+ i += 1; // not the command: move past this token
779
+ continue;
780
+ }
781
+ if (cluster && cluster.last) {
782
+ i += 2; // cluster + its separate-token argument (matched letter is last: env -iu PATH)
783
+ continue;
784
+ }
785
+ i += 1; // flag that takes no argument (e.g. -i), or a self-contained cluster
786
+ }
787
+ return { next: i };
788
+ }
789
+
790
+ // Walks a "command" prefix's option run starting at i (just past "command"/basename "command"). Unlike every
791
+ // other prefix in this module, "command" has no flag that consumes a following token, but "-v" and "-V" are
792
+ // terminal rather than merely argument-free: real command -v NAME and command -V NAME print information and
793
+ // run NOTHING, so meeting either ends the walk right there - the result is "not a shell stage" regardless of
794
+ // what follows, including a real shell verb (command -v sh prints a path, it doesn't run one). "-p" takes no
795
+ // argument and is walked like any other no-argument flag; combined short forms (e.g. "-pv") are not modelled.
796
+ // Returns { shell: false } when it stopped on -v/-V, or { next } with the index of the first non-option token
797
+ // (the next prefix or the verb) otherwise.
798
+ function scanCommandOptions(w, i) {
799
+ while (i < w.length) {
800
+ const tok = w[i];
801
+ if (tok === "-v" || tok === "-V") return { shell: false };
802
+ if (tok === "--") {
803
+ i += 1;
804
+ break;
805
+ }
806
+ if (!tok.startsWith("-")) break; // not an option token: another prefix, or the verb
807
+ if (tok.includes("=")) {
808
+ i += 1; // "--long=value" is one token
809
+ continue;
810
+ }
811
+ i += 1; // "-p" or any other no-argument flag
812
+ }
813
+ return { next: i };
814
+ }
815
+
816
+ // Card bash-rules-sudo-shell-flags: walks sudo's option run starting at i (just past "sudo"/basename "sudo"),
817
+ // modelled on scanCommandOptions. Uses the same rules as skipPrefixOptions ("--" ends the options, consuming
818
+ // itself; "--long=value" is one token; a flag in SUDO_ARG_FLAGS or a cluster ending in one consumes a separate
819
+ // following token, via clusterFlag; any other "-"-prefixed token consumes just itself), so sudo's argument
820
+ // flags (-u root, --user=root, a cluster like -Eu root, ...) are skipped exactly as before. Additionally tracks
821
+ // whether the stage is shell-flagged: -s, -i, --shell, --login, or (for a cluster) a letter "s" or "i" found
822
+ // among the letters before the cluster's first argument-taking letter (all letters when there is none) - see
823
+ // the SUDO_ARG_FLAGS comment for the getopt-order reasoning and its two over-deny corrections. Returns
824
+ // { next, shell }: { next } is the index just past the options (where the next prefix token, an env assignment,
825
+ // or the candidate verb starts), and { shell } is true iff a shell flag was met anywhere in the run - the caller
826
+ // (matchesShellWords's sudo branch) still has to check whether anything but env assignments remains at `next`
827
+ // before deciding the stage is a shell stage outright, since a command word there is that command's stdin
828
+ // instead (curl url | sudo -s tee f), not the shell's.
829
+ function scanSudoOptions(w, i) {
830
+ let shell = false;
831
+ while (i < w.length) {
832
+ const tok = w[i];
833
+ if (tok === "--") {
834
+ i += 1;
835
+ break;
836
+ }
837
+ if (!tok.startsWith("-")) break; // not an option token: an env assignment, another prefix, or the verb
838
+ if (tok === "--shell" || tok === "--login") {
839
+ shell = true;
840
+ i += 1;
841
+ continue;
842
+ }
843
+ if (tok.includes("=")) {
844
+ i += 1; // "--long=value" (e.g. --user=root) - not a shell flag
845
+ continue;
846
+ }
847
+ if (tok === "-s" || tok === "-i") {
848
+ shell = true;
849
+ i += 1;
850
+ continue;
851
+ }
852
+ if (SUDO_ARG_FLAGS.has(tok)) {
853
+ i += 2; // flag + its separate-token argument
854
+ continue;
855
+ }
856
+ const cluster = clusterFlag(tok, SUDO_ARG_FLAGS);
857
+ if (CLUSTER_RE.test(tok)) {
858
+ const letters = tok.slice(1);
859
+ // The letters before the cluster's first argument-taking letter (clusterFlag found it, if any): that
860
+ // letter's own value (attached, or the next token) is not examined, per the getopt-order reasoning above.
861
+ // No argument-taking letter at all (cluster === null) means every letter is examined.
862
+ const prefix = cluster ? letters.slice(0, letters.length - cluster.rest.length - 1) : letters;
863
+ if (prefix.includes("s") || prefix.includes("i")) shell = true;
864
+ if (cluster && cluster.last) {
865
+ i += 2; // cluster + its separate-token argument (matched letter is last: sudo -Eu root, -sh sh)
866
+ continue;
867
+ }
868
+ i += 1; // self-contained: no argument-taking letter, or its value is attached (sudo -uroot, -us)
869
+ continue;
870
+ }
871
+ i += 1; // flag that takes no argument (e.g. -E, -H), or a bare -S (--stdin; too short to be a cluster)
872
+ }
873
+ return { next: i, shell };
874
+ }
875
+
876
+ // Card bash-rules-normalise-match-text: recognises ONE redirection word - an operator (optionally preceded by a
877
+ // bare fd number, e.g. "2>", and optionally combined with an attached target with no space, e.g. ">f",
878
+ // "2>/dev/null") - checked once per iteration inside resolveVerb's own loop (below), so a redirection is dropped
879
+ // whether it's the very first token or sits between two already-resolved prefixes; by the time resolveVerb
880
+ // returns, `w.slice(i)` never starts with one, which is what lets normalizedForms (further down) skip repeating
881
+ // this same walk (round 3, reviewer M2a - see its own comment). Returns 0 (not a redirection at all), 1 (the
882
+ // word IS the redirection, target attached or none needed), or 2 (the word is a BARE operator whose target is
883
+ // the following, separate word - "> f", "&> f" - which real shells read as this operator's target, never as a
884
+ // prefix name or the verb, so it's consumed too).
885
+ const LEADING_REDIR_RE = /^\d*(&>>|&>|>>|<>|>&|<&|>|<)(.*)$/;
886
+ function redirWordSpan(tok) {
887
+ const m = LEADING_REDIR_RE.exec(tok);
888
+ if (!m) return 0;
889
+ return m[2] === "" ? 2 : 1;
890
+ }
891
+
892
+ // Walks a stage's already-split words from index i, resolving prefixes (env assignments, sudo, env, nice,
893
+ // time, nohup, exec, command, busybox) until it lands on the candidate verb. Split out of matchesShellStage so
894
+ // scanEnvOptions can hand an -S/--split-string value back into this same walk when that value qualifies as
895
+ // env's command. That handoff is a rebuild of `w`, not a recursive call (see the "splice" case below): every
896
+ // handoff replaces one or more consumed tokens with the single value token, so `w` strictly shrinks by at least
897
+ // one token each time, and the loop below runs no deeper than one stack frame no matter how many wrappers are
898
+ // nested (env -Senv -Senv ... sh).
899
+ // Card bash-rules-prefixed-commands: also shared with commandForms, which needs the resolved word array and
900
+ // verb index (not just a shell/not-shell verdict), so the walk itself no longer decides an outcome - it returns
901
+ // { w, i } once it lands on a candidate verb (i may be w.length when nothing followed the prefixes), or
902
+ // { terminal } when a prefix's own option decided the outcome without a verb: "shell" for a sudo stage flagged
903
+ // -s/-i/--shell/--login with nothing but env assignments left after it (sudo execs the shell directly, see the
904
+ // scanSudoOptions call below), or "notShell" for command -v/-V (which prints and runs nothing - see
905
+ // scanCommandOptions). matchesShellWords below turns that into its verb-or-terminal verdict unchanged.
906
+ //
907
+ // Card bash-rules-normalise-match-text (round 2): two things are resolved INSIDE this walk, not just once at
908
+ // its call site, because a redirection or a resolved prefix can each put the NEXT thing this loop needs to
909
+ // recognise out of a plain, exact-text match's reach, and either can recur (a redirection can sit ahead of a
910
+ // prefix, a prefix can consume it and leave another redirection ahead of the verb - `> f sudo rm -rf ~` and
911
+ // `sudo > f rm -rf ~` are both real spellings a real shell runs the same way):
912
+ // - a leading redirection word (redirWordSpan, below) is skipped at the TOP of every iteration, before this
913
+ // token is looked at as anything else - so it's dropped whether it sits before the very first prefix or
914
+ // between two already-resolved ones, not merely once at the start.
915
+ // - `base`/`isBase` (the name this loop compares against "sudo"/"env"/.../ENV_ASSIGN) is read from the
916
+ // token's DEQUOTED text, not its raw text - a quote or a backslash in front of a prefix name ("'sudo'" rm
917
+ // -rf ~, \env FOO=1 sh) must not defeat an exact-string comparison any more than it would defeat a real
918
+ // shell's own lookup of the binary. Only the NAME is dequoted here: every argument-flag set this loop's
919
+ // helpers match below (SUDO_ARG_FLAGS, ENV_ARG_FLAGS, a "--long=value", a cluster via clusterFlag, ...)
920
+ // still reads the RAW token, unchanged - a quoted FLAG is out of this card's scope, and dequoting it too
921
+ // would risk changing which flag a cluster or an attached form is read as, for no fixture that needs it.
922
+ // The returned `{w, i}` itself is unaffected by this: `w` is still the array the caller passed in (or
923
+ // resolveVerb's own env-splice rebuild, unrelated to this round), and `i` is just an index into it - the
924
+ // caller (isShellVerb by way of commandForms/matchesShellWords) dequotes `w[i]` itself before comparing it
925
+ // against a shell name, the same way this loop now does for a prefix name.
926
+ //
927
+ // Round 3 (tester's live probes P3 `$'s\h' -c ...` and P4 `$'sud\o' rm -rf ~`, both confirmed real in zsh 5.9
928
+ // only - bash 3.2.57's ANSI-C reading of an unrecognised escape keeps the backslash, so "s\h"/"sud\o" stay
929
+ // non-matching text there, but zsh's reading drops it, resolving to the real "sh"/"sudo"): `isBase` checks the
930
+ // token's name against BOTH readings (dequoteScript's "bash" and "zsh" - see its own comment for why they can
931
+ // differ), not bash alone, and recognises the prefix (or, at the caller, the shell verb) when EITHER one
932
+ // matches - a spelling only one real shell's ANSI-C reading resolves to a real prefix/verb name is still a real
933
+ // spelling that shell truly runs, so missing it because the OTHER reading didn't resolve would be losing a
934
+ // deny, the one thing this module never does. Only ONE walk still happens, though: which reading matched is
935
+ // never examined past this check, because nothing downstream (SUDO_ARG_FLAGS, a cluster, findScriptCandidates,
936
+ // ...) reads the prefix/verb token's text again - everything after it operates on the RAW words that follow,
937
+ // unaffected by which reading recognised the name in front of them.
938
+ function resolveVerb(w, i) {
939
+ while (i < w.length) {
940
+ const tok = w[i];
941
+ const span = redirWordSpan(tok);
942
+ if (span) {
943
+ i += span; // a leading redirection (and its own separate-word target, when it has one): never the verb
944
+ continue;
945
+ }
946
+ const dqBash = dequoteScript(tok, "bash");
947
+ const dqZsh = dequoteScript(tok, "zsh");
948
+ const baseBash = basenameOf(dqBash);
949
+ const baseZsh = basenameOf(dqZsh);
950
+ const isBase = (name) => baseBash === name || baseZsh === name;
951
+ if (ENV_ASSIGN.test(dqBash) || ENV_ASSIGN.test(dqZsh)) {
952
+ i += 1; // FOO=1, in any position, any number of times
953
+ continue;
954
+ }
955
+ if (isBase("sudo")) {
956
+ const result = scanSudoOptions(w, i + 1);
957
+ // Card bash-rules-sudo-shell-flags: a shell flag (see scanSudoOptions) makes the stage a shell stage
958
+ // outright ONLY when nothing but env assignments (sudo sets these, it doesn't run them) is left after
959
+ // sudo's options - a real command word there is that command's stdin instead, so the walk continues from
960
+ // it exactly as today (the loop below, on the next iteration, matches it as a verb, another prefix, or
961
+ // nothing left). Skipping assignments here (rather than relying on the loop's own ENV_ASSIGN check on its
962
+ // next iteration) is what lets "shell flag, then only assignments, then nothing" resolve to true instead
963
+ // of falling through to "no verb found" -> false.
964
+ let j = result.next;
965
+ while (j < w.length && ENV_ASSIGN.test(w[j])) j += 1;
966
+ if (result.shell && j >= w.length) return { terminal: "shell" };
967
+ i = result.next;
968
+ continue;
969
+ }
970
+ if (isBase("env")) {
971
+ const result = scanEnvOptions(w, i + 1);
972
+ if ("splice" in result) {
973
+ // An attached -S/--split-string value qualified as env's command: rebuild the word array with that
974
+ // value as the new first word, followed by whatever came after it, and resume this same loop at 0 -
975
+ // no recursive call, so this is safe no matter how many attached values are nested.
976
+ w = [result.splice, ...w.slice(result.next)];
977
+ i = 0;
978
+ continue;
979
+ }
980
+ i = result.next; // separate-token -S/--split-string value, or no qualifying value: continue the walk
981
+ continue;
982
+ }
983
+ if (isBase("nice")) {
984
+ i = skipPrefixOptions(w, i + 1, NICE_ARG_FLAGS);
985
+ continue;
986
+ }
987
+ if (isBase("time")) {
988
+ i = skipPrefixOptions(w, i + 1, TIME_ARG_FLAGS);
989
+ continue;
990
+ }
991
+ if (isBase("nohup")) {
992
+ i = skipPrefixOptions(w, i + 1, NOHUP_ARG_FLAGS);
993
+ continue;
994
+ }
995
+ if (isBase("exec")) {
996
+ i = skipPrefixOptions(w, i + 1, EXEC_ARG_FLAGS);
997
+ continue;
998
+ }
999
+ if (isBase("command")) {
1000
+ const result = scanCommandOptions(w, i + 1);
1001
+ if ("shell" in result) return { terminal: "notShell" }; // -v/-V: terminal, see scanCommandOptions
1002
+ i = result.next;
1003
+ continue;
1004
+ }
1005
+ if (isBase("busybox")) {
1006
+ i = skipPrefixOptions(w, i + 1, BUSYBOX_ARG_FLAGS);
1007
+ continue;
1008
+ }
1009
+ break; // none of the above: this token is the candidate verb
1010
+ }
1011
+ return { w, i };
1012
+ }
1013
+
1014
+ // Thin wrapper over resolveVerb's walk: true for a sudo stage terminal-flagged as a shell, false for a
1015
+ // command -v/-V terminal, and otherwise whether the resolved verb's basename is a shell. Unchanged behavior
1016
+ // from before the card bash-rules-prefixed-commands split.
1017
+ function matchesShellWords(w, i) {
1018
+ const r = resolveVerb(w, i);
1019
+ if (r.terminal === "shell") return true;
1020
+ if (r.terminal === "notShell") return false;
1021
+ const verb = r.w[r.i];
1022
+ if (!verb) return false;
1023
+ return isShellVerb(basenameOf(verb));
1024
+ }
1025
+
1026
+ function matchesShellStage(stage) {
1027
+ return matchesShellWords(words(stage), 0);
1028
+ }
1029
+
1030
+ // Review round 5: "&" joins GROUP_SEPS (a real shell ends the command before it, and starts a new job - a
1031
+ // separate group, no different from ";" for this purpose) but NOT STAGE_SEPS (it never appears usefully inside
1032
+ // an already-open pipeline: a "|" or "|&" always ends a stage before any "&" would be reachable there). "|&"
1033
+ // joins STAGE_SEPS (bash's stderr-merging pipe connector, a stage boundary like "|") but NOT GROUP_SEPS (it is
1034
+ // part of one pipeline, not a group ender) - see scanTop's comment for the "|&" reasoning and the HEAD-parity
1035
+ // check for `curl url |& sh`.
1036
+ const GROUP_SEPS = new Set(["&&", "||", ";", "&", "\n"]);
1037
+ const STAGE_SEPS = new Set(["|", "|&"]);
1038
+ const PIPE_REASON =
1039
+ "Blocked: piping a download into a shell runs unreviewed remote code. Download to a file, read it, then run it. If the user wants this, they can run it themselves or disable bash.denyDestructive in .doug/config.json.";
1040
+
1041
+ // Card bash-rules-quote-aware-split: one group's stages, quote-aware (a bare "|" inside a quoted argument - an
1042
+ // unrelated echo, a URL's own ";" or "|" - is never mistaken for a pipe stage boundary, Q12; bash's "|&" is one
1043
+ // stage boundary, not "|" plus a stray "&" - review round 5). Falls back to the old quote-blind split on an
1044
+ // unterminated quote, same as splitCommands.
1045
+ function pipeStages(group) {
1046
+ const cut = cutOnSeparators(group, STAGE_SEPS);
1047
+ if (cut) return cut.pieces;
1048
+ return group.split(/\s*\|\s*/).map((s) => s.trim()).filter(Boolean);
1049
+ }
1050
+
1051
+ // Card bash-rules-quote-aware-split: the group/stage analysis for one command string - quote-aware groups at
1052
+ // &&, ||, ;, a bare "&", and newline (a bare "|" or "|&" is not a group separator - Q12, review round 5), each
1053
+ // group's stages at "|" or "|&" (pipeStages above) - and, design point 5, recursion into every substitution
1054
+ // body's own groups too, so a curl|sh chain hidden inside a "$(...)" or backtick is still caught. Because
1055
+ // scanTop finds a substitution wherever it sits - inside an ordinary quoted argument or inside what looks like
1056
+ // inert text such as a heredoc body (heredocs carry no state of their own here, a known limit) - a "$(... curl
1057
+ // ... | sh)" written inside either is still denied even where a real shell would just treat it as literal
1058
+ // text: an over-denial on the safe side, not a miss. Falls back to the old quote-blind group split (unchanged -
1059
+ // review round 5 did not extend this fallback regex to "&"/"|&", see the coder's report) on an unterminated
1060
+ // quote.
1061
+ function checkPipeGroups(str) {
1062
+ const cut = cutOnSeparators(str, GROUP_SEPS);
1063
+ const groups = cut ? cut.pieces : str.split(/\s*(?:&&|\|\||;)\s*|\n/).map((s) => s.trim()).filter(Boolean);
1064
+ const subs = cut ? cut.subs : [];
1065
+ for (const group of groups) {
1066
+ const stages = pipeStages(group);
1067
+ if (stages.length < 2) continue;
1068
+ if (!/^(curl|wget)\b/.test(stages[0])) continue;
1069
+ if (stages.slice(1).some((s) => matchesShellStage(s))) return PIPE_REASON;
1070
+ }
1071
+ for (const body of subs) {
1072
+ const reason = checkPipeGroups(body);
1073
+ if (reason) return reason;
1074
+ }
1075
+ return null;
1076
+ }
1077
+
1078
+ export function checkPipeToShell(command) {
1079
+ try {
1080
+ // A newline directly after a "|" (optional trailing spaces before it, blank lines, CRLF, and leading spaces
1081
+ // on the next non-blank line) is a shell line continuation, not a command separator - collapse it so the
1082
+ // pipeline stays one group.
1083
+ const collapsed = String(command).replace(/\|[ \t]*(?:\r?\n[ \t]*)+/g, "| ");
1084
+ return checkPipeGroups(collapsed);
1085
+ } catch {
1086
+ return null;
1087
+ }
1088
+ }
1089
+
1090
+ // A hard reset on the current branch when that branch is protected: rewrites local history the team relies
1091
+ // on being stable. Null when the branch isn't protected, or is unknown (no git repo, detached HEAD, etc.).
1092
+ export function checkResetHardOnProtected(cmd, protectedBranches, currentBranch) {
1093
+ if (!/^git\s+reset\s+--hard\b/.test(normalizeGit(cmd))) return null;
1094
+ if (!currentBranch || !protectedBranches.includes(currentBranch)) return null;
1095
+ return `git reset --hard on "${currentBranch}" rewrites the protected branch's local history. Reset on a feature branch, or ask the user.`;
1096
+ }
1097
+
1098
+ // Card bash-rules-quote-aware-split: commandForms's own word split - quote-aware (design point 6), unlike the
1099
+ // plain `words` above, so the prefix forms it joins back are character-for-character what they are today for
1100
+ // already-passing inputs (a dequoted join would turn env git commit -m "x --no-verify y" into a false deny).
1101
+ // Built on the same shared scanner (scanTop) as splitCommands and checkPipeToShell; falls back to the old
1102
+ // quote-blind whitespace split on an unterminated quote.
1103
+ function wordsQ(cmd) {
1104
+ const str = String(cmd);
1105
+ const scanned = scanTop(str);
1106
+ if (!scanned) return str.split(/\s+/).filter(Boolean);
1107
+ const out = [];
1108
+ let start = 0;
1109
+ for (const gap of scanned.spaces) {
1110
+ if (gap.start > start) out.push(str.slice(start, gap.start));
1111
+ start = gap.end;
1112
+ }
1113
+ if (start < str.length) out.push(str.slice(start));
1114
+ return out;
1115
+ }
1116
+
1117
+ // Card bash-rules-quote-aware-split, design point 6: finds which word(s) after a shell verb are candidate -c
1118
+ // scripts (research Q2). Walks the option run starting just after the verb (index i): "--" and zsh's
1119
+ // end-of-options markers "-", "+", "+-" are consumed and never mistaken for the script itself; "--rcfile",
1120
+ // "--init-file", and zsh's "--emulate" (like -o/-O) consume the next word as their own separate-token argument;
1121
+ // any other long option ("--login", "--norc", an attached "--rcfile=x", ...) consumes only itself; a
1122
+ // single-dash-or-plus, letters-only cluster (this subsumes the bare "-c" token, a one-letter cluster) is read
1123
+ // left to right: a "c" anywhere in it means a -c form was seen (the walk CONTINUES past it rather than stopping,
1124
+ // unlike the old code - research Q2.5); an "o" or "O" anywhere in it, if it is the LAST letter, consumes the
1125
+ // next word as that flag's argument and the walk continues past it too ("-co pipefail" reads "pipefail" as -o's
1126
+ // argument, not the script - Q9); an "o"/"O" that is present but NOT the last letter is ambiguous about which
1127
+ // word the shell actually reads next (bash and zsh do not agree here - research Q2.3-4), so BOTH the next word
1128
+ // and the one after it are returned as candidate scripts, and the walk stops there rather than guessing (Q11:
1129
+ // checks more, never less). The first token that is none of these ends the option run: that word is the sole
1130
+ // candidate script, when a -c form was ever seen. Returns { cSeen, candidates }, candidates holding 0, 1, or 2
1131
+ // indices into w; the caller ignores candidates when cSeen is false (a script FILE with no -c, not modelled).
1132
+ function findScriptCandidates(w, i) {
1133
+ let j = i + 1;
1134
+ let cSeen = false;
1135
+ while (j < w.length) {
1136
+ const tok = w[j];
1137
+ if (tok === "--" || tok === "-" || tok === "+" || tok === "+-") { j += 1; continue; }
1138
+ if (tok === "--rcfile" || tok === "--init-file" || tok === "--emulate") { j += 2; continue; }
1139
+ if (/^--[A-Za-z-]+(=.*)?$/.test(tok)) { j += 1; continue; }
1140
+ if (/^[-+][A-Za-z]+$/.test(tok)) {
1141
+ const letters = tok.slice(1);
1142
+ if (letters.includes("c")) cSeen = true;
1143
+ const oIdx = letters.search(/[oO]/);
1144
+ if (oIdx === -1) { j += 1; continue; } // no o/O in this cluster: self-contained, itself only
1145
+ if (oIdx === letters.length - 1) { j += 2; continue; } // ends in o/O: consumes the next word as its argument
1146
+ return { cSeen, candidates: [j + 1, j + 2].filter((k) => k < w.length) }; // mid-cluster o/O: ambiguous (Q11)
1147
+ }
1148
+ break; // not an option token: the script, when a -c form was seen
1149
+ }
1150
+ return { cSeen, candidates: cSeen && j < w.length ? [j] : [] };
1151
+ }
1152
+
1153
+ // Card bash-rules-quote-aware-split, design point 6: dequotes ONE candidate script word (only this word is
1154
+ // dequoted - every other form commandForms builds keeps its raw, quoted text). Walks the word's own quote
1155
+ // states with the same rules as the shared scanner and builds the literal text a real shell would pass as the
1156
+ // argument: single-quoted spans verbatim; double-quoted spans with their backslash escapes of $, `, ", \, and a
1157
+ // line continuation resolved (any other backslash is left as-is, research Q1.2 - S3's FOO=\"a\" case relies on
1158
+ // this); ANSI-C $'...' spans with \\ and \' resolved the same way in both readings, but an OTHER, unrecognised
1159
+ // $'...' escape (\;, \|, ...) diverges: bash's real behaviour is to leave both characters as-is (\X stays \X -
1160
+ // this is the "bash" reading), zsh's is to drop the backslash and keep just the character (X - the "zsh"
1161
+ // reading) - review round 3, reviewer-measured: `sh -c $'echo <sep> X'` with one backslash before the separator
1162
+ // runs X under zsh only, three backslashes runs it under bash only, two (round 2's pin e) runs it under
1163
+ // neither, and a bare separator runs it under both; verified directly against real bash 3.2.57 and zsh 5.9 here
1164
+ // (files run as `bash file` / `zsh file`, not through this session's own shell, so this reader's own escaping
1165
+ // never enters it). `reading` selects which; the caller (commandForms) computes both and only runs the second
1166
+ // one when it differs from the first. Card bash-rules-normalise-match-text closed the gap the paragraph above
1167
+ // this one used to describe here: an ANSI-C escape that DECODES TO a separator character rather than merely
1168
+ // losing its backslash - \n (literal backslash-n, two characters), \xHH (one or two hex digits), \NNN (one to
1169
+ // three octal digits), and (round 3, the tester's live probes P3/P4/P6/P7/P5u; round 4, the reviewer's probes
1170
+ // P12/P13/P17 corrected the digit count) \uHHHH (one to four hex digits - GNU Bash's own documented shape and
1171
+ // zsh 5.9's real short-form behaviour, greedy, stopping at the first non-hex character, exactly like \xHH),
1172
+ // \UHHHHHHHH (one to eight, the same shape), \cX (a control character), and \C-X (round 4: zsh's own spelling of
1173
+ // the identical control character) - is now decoded to the real byte it names (a real newline for \n;
1174
+ // String.fromCharCode/fromCodePoint of the hex value for \x/\u/\U; X's own character code XORed with 0x40 for
1175
+ // \cX and \C-X), so a script hiding a destructive command behind `sh -c $'echo a\x3brm -rf ~'`, `\073`, `;`,
1176
+ // `\U0000003b` (all four ";"), or `\cJ`/`\C-j` (both a real newline) is caught once the decoded text is
1177
+ // re-split by splitCommands, the same as a plain, undisguised separator would be. \n, \xHH, and \NNN are
1178
+ // decoded identically by real bash and real zsh, so decoding them once, unconditionally of `reading`, is simply
1179
+ // correct for both. \u, \U, \cX, and \C-X are NOT: real bash 3.2.57 predates \U and has no \C- form at all
1180
+ // (leaves either inert, its literal characters kept) and real zsh 5.9 leaves \cX inert the same way (confirmed
1181
+ // live, all directions) - but this module decodes ALL EIGHT escapes in BOTH readings regardless, which is still
1182
+ // the safe direction rather than a claim about either shell's real behaviour: commandForms already runs the
1183
+ // bash- and zsh-read candidate scripts as separate, independent forms, so a reading a real shell wouldn't
1184
+ // actually take on THIS card's own escapes is just one more candidate that either matches a row (still a real
1185
+ // spelling the OTHER shell truly runs, so nothing is over-denied) or doesn't (costs nothing) - it can only ever
1186
+ // ADD a candidate, never lose the one the reading-specific behaviour would have produced. All eight run before,
1187
+ // and unconditionally of, the divergent "other, unrecognised escape" branch just below, which is what still
1188
+ // differs by `reading` - the one place bash and zsh genuinely diverge in a way this module cannot simply decode
1189
+ // past (see that branch's own comment). \U guards against an invalid code point (a surrogate half, or past
1190
+ // 0x10FFFF, P15's pin) rather
1191
+ // than letting String.fromCodePoint throw, falling through to the ordinary fallback on either, per this
1192
+ // function's never-throws rule. The module still does not reproduce bash/zsh's full C-style escape table (\t,
1193
+ // \a, \e, and the handful of others neither the tester's probes nor this card's goal named) - decoding one of
1194
+ // those would cost nothing either way (this module has never been asked whether any of them can decode to a
1195
+ // separator in some shell, and does not claim to know), so none is added speculatively.
1196
+ // Double-quoted spans have their backslash escapes of $, `, ", \, and a line continuation resolved (any other
1197
+ // backslash is left as-is, research Q1.2 - S3's FOO=\"a\" case relies on this); unquoted text unchanged (an
1198
+ // unquoted backslash still escapes the next character, so \; stays a literal ; - research Q1.3). A "$(...)" or
1199
+ // backtick substitution's own span is copied through verbatim rather than walked as quotes, the same as the
1200
+ // shared scanner, since it is independently re-parsed once splitCommands sees the dequoted result. Never
1201
+ // throws: an unterminated quote just passes the remaining text through unchanged instead of losing it.
1202
+ function dequoteScript(word, reading = "bash") {
1203
+ let out = "";
1204
+ let i = 0;
1205
+ let state = "top";
1206
+ while (i < word.length) {
1207
+ const c = word[i];
1208
+ if (state === "top" || state === "dquote") {
1209
+ if (c === "$" && word[i + 1] === "(") {
1210
+ const close = scanParenBody(word, i + 1);
1211
+ if (close < 0) return out + word.slice(i);
1212
+ out += word.slice(i, close + 1);
1213
+ i = close + 1;
1214
+ continue;
1215
+ }
1216
+ if (c === "`") {
1217
+ const close = scanBacktickBody(word, i);
1218
+ if (close < 0) return out + word.slice(i);
1219
+ out += word.slice(i, close + 1);
1220
+ i = close + 1;
1221
+ continue;
1222
+ }
1223
+ }
1224
+ if (state === "top") {
1225
+ if (c === "\\") {
1226
+ if (word[i + 1] === "\n") { i += 2; continue; }
1227
+ out += word[i + 1] === undefined ? c : word[i + 1];
1228
+ i += 2;
1229
+ continue;
1230
+ }
1231
+ if (c === "'") { state = "squote"; i += 1; continue; }
1232
+ if (c === '"') { state = "dquote"; i += 1; continue; }
1233
+ if (c === "$" && word[i + 1] === "'") { state = "ansic"; i += 2; continue; }
1234
+ out += c;
1235
+ i += 1;
1236
+ continue;
1237
+ }
1238
+ if (state === "squote") {
1239
+ if (c === "'") { state = "top"; i += 1; continue; }
1240
+ out += c;
1241
+ i += 1;
1242
+ continue;
1243
+ }
1244
+ if (state === "dquote") {
1245
+ if (c === "\\" && isDquoteEscapable(word[i + 1])) {
1246
+ out += word[i + 1] === "\n" ? "" : word[i + 1];
1247
+ i += 2;
1248
+ continue;
1249
+ }
1250
+ if (c === '"') { state = "top"; i += 1; continue; }
1251
+ out += c;
1252
+ i += 1;
1253
+ continue;
1254
+ }
1255
+ // ansic
1256
+ if (c === "\\" && (word[i + 1] === "'" || word[i + 1] === "\\")) { out += word[i + 1]; i += 2; continue; }
1257
+ // Card bash-rules-normalise-match-text: \n, \xHH, and \NNN decode to the real byte they name, identically
1258
+ // for both readings (see the function comment above) - checked before the divergent fallback below so
1259
+ // neither reading's "keep both / drop the backslash" branch ever sees them.
1260
+ if (c === "\\" && word[i + 1] === "n") { out += "\n"; i += 2; continue; }
1261
+ if (c === "\\" && word[i + 1] === "x") {
1262
+ const hex = /^[0-9a-fA-F]{1,2}/.exec(word.slice(i + 2, i + 4));
1263
+ if (hex) { out += String.fromCharCode(parseInt(hex[0], 16)); i += 2 + hex[0].length; continue; }
1264
+ }
1265
+ if (c === "\\" && /^[0-7]/.test(word[i + 1] || "")) {
1266
+ const oct = /^[0-7]{1,3}/.exec(word.slice(i + 1, i + 4));
1267
+ out += String.fromCharCode(parseInt(oct[0], 8) & 0xff);
1268
+ i += 1 + oct[0].length;
1269
+ continue;
1270
+ }
1271
+ // Card bash-rules-normalise-match-text, round 3 (tester's live probes P3/P4/P6/P7/P5u), extended round 4
1272
+ // (reviewer probes P12/P13/P17, all confirmed live in real bash 3.2.57 and zsh 5.9, and P12/P13 additionally
1273
+ // against the GNU Bash Reference Manual's ANSI-C Quoting section, since a real bash 4.2+ install - this
1274
+ // module protects more than just this one test machine's 3.2.57 - reads \u/\U this way too): \uHHHH is ONE
1275
+ // TO FOUR hex digits (not a fixed four - round 3's `{4}` under-matched the manual's own documented shape,
1276
+ // and zsh 5.9's real short form: \u3b decodes the 2 available digits and stops at the first non-hex
1277
+ // character, exactly like \xHH above), \UHHHHHHHH is one to eight the same way (round 3's `{8}` under-matched
1278
+ // it identically), \cX is a control character (X's own character code XORed with 0x40, the standard
1279
+ // control-character derivation: \cJ -> 'J' 0x4A ^ 0x40 -> 0x0A, a real newline; case-insensitive - bash
1280
+ // itself reads \cj and \cJ alike, P16), and \C-X (round 4: zsh's OWN spelling of the identical control
1281
+ // character, decoded the same case-insensitive way - \C-j and \cj both give control-J) - all decode
1282
+ // identically for BOTH readings, same as \n/\xHH/\NNN above - even though real bash 3.2.57 leaves \U and
1283
+ // \C- inert (predates \U; has no \C- form at all) and real zsh 5.9 leaves \cX inert (confirmed live, all
1284
+ // three directions), decoding all of them in BOTH readings is the safe direction: this module already runs
1285
+ // the bash- and zsh-read candidate scripts as SEPARATE, independent forms (commandForms), so a reading a
1286
+ // real shell wouldn't actually take is just an extra candidate that either matches a row (still a real
1287
+ // spelling the OTHER shell really runs, so no over-deny of anything safe) or doesn't (costs nothing) - never
1288
+ // a way to LOSE a deny the correct, reading-specific behaviour would have caught. \U guards against an
1289
+ // invalid code point (a surrogate half, or a value past 0x10FFFF, P15's pin) rather than letting
1290
+ // String.fromCodePoint throw - falls through to the ordinary fallback below on either, per this function's
1291
+ // never-throws rule; the SAME range check applies whether the greedy {1,8} match consumed one hex digit or
1292
+ // eight.
1293
+ if (c === "\\" && word[i + 1] === "u") {
1294
+ const hex = /^[0-9a-fA-F]{1,4}/.exec(word.slice(i + 2, i + 6));
1295
+ if (hex) { out += String.fromCharCode(parseInt(hex[0], 16)); i += 2 + hex[0].length; continue; }
1296
+ }
1297
+ if (c === "\\" && word[i + 1] === "U") {
1298
+ const hex = /^[0-9a-fA-F]{1,8}/.exec(word.slice(i + 2, i + 10));
1299
+ if (hex) {
1300
+ const cp = parseInt(hex[0], 16);
1301
+ if (cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff)) {
1302
+ out += String.fromCodePoint(cp);
1303
+ i += 2 + hex[0].length;
1304
+ continue;
1305
+ }
1306
+ }
1307
+ }
1308
+ if (c === "\\" && word[i + 1] === "c" && word[i + 2] !== undefined) {
1309
+ out += String.fromCharCode(word[i + 2].toUpperCase().charCodeAt(0) ^ 0x40);
1310
+ i += 3;
1311
+ continue;
1312
+ }
1313
+ if (c === "\\" && word[i + 1] === "C" && word[i + 2] === "-" && word[i + 3] !== undefined) {
1314
+ out += String.fromCharCode(word[i + 3].toUpperCase().charCodeAt(0) ^ 0x40);
1315
+ i += 4;
1316
+ continue;
1317
+ }
1318
+ if (c === "\\" && word[i + 1] !== undefined) {
1319
+ // An unrecognised $'...' escape: bash keeps both characters, zsh drops the backslash (see the function
1320
+ // comment - this is the one place the two shells' ANSI-C reading diverges).
1321
+ out += reading === "zsh" ? word[i + 1] : c + word[i + 1];
1322
+ i += 2;
1323
+ continue;
1324
+ }
1325
+ if (c === "'") { state = "top"; i += 1; continue; }
1326
+ out += c;
1327
+ i += 1;
1328
+ }
1329
+ return out;
1330
+ }
1331
+
1332
+ // Card bash-rules-quote-aware-split: the strings evaluateBash's five per-command checks (checkNoVerify,
1333
+ // checkForcePush, checkResetHardOnProtected, checkDestructive, checkPackageManager) run on for one split
1334
+ // command, since each anchors on the start of the string it's given and so misses a denied command sitting
1335
+ // behind a resolvable prefix (sudo, an env assignment, env, nice, time, nohup, exec, command, busybox) or a
1336
+ // shell's -c string. Always includes cmd itself. When resolveVerb's walk consumed or rebuilt anything - i > 0
1337
+ // (an ordinary prefix was skipped) or the returned word array isn't the one passed in (an attached env
1338
+ // -S/--split-string value spliced in a new array and reset i to 0 on it, so i > 0 alone missed a denied command
1339
+ // sitting right behind it, e.g. env -Sgit reset --hard) - also includes the resolved word array from the verb
1340
+ // onward. When that verb's basename is a shell, findScriptCandidates (above) locates 0, 1, or 2 candidate -c
1341
+ // script words; each candidate is dequoted (dequoteScript) - the ONLY word this function dequotes - twice, once
1342
+ // per shell reading (review round 3: bash and zsh decode an unrecognised ANSI-C escape differently, see
1343
+ // dequoteScript's comment), the second one skipped when it comes out identical to the first (no duplicate
1344
+ // forms) - the same "more than one candidate when a reading is ambiguous" shape findScriptCandidates already
1345
+ // uses for a mid-cluster o/O. Each resulting script is re-split at its own top-level separators with the same
1346
+ // quote-aware splitCommands (this is what keeps "sh -c 'cd x && rm -rf ~'" denied once the top-level split no
1347
+ // longer cuts inside the quotes - Q8), and each resulting piece is fed back through this same function one
1348
+ // depth deeper, so a nested shell -c is caught too (bounded at depth 3, not itself a reason to deny). Never
1349
+ // throws: any unexpected shape just falls back to [cmd].
1350
+ //
1351
+ // Card bash-rules-normalise-match-text: every DESTRUCTIVE row, and checkNoVerify, checkForcePush, and
1352
+ // checkPackageManager, anchor on the raw start of the string they're given (^rm, ^git\s+push\b, ...) - the same
1353
+ // shape resolveVerb's prefix-walk above works around for sudo/env/... but not for a spelling that puts a quote
1354
+ // or a redirection in front of the verb itself, or hides the target behind quoting (rm -rf "$HOME", "rm" -rf ~,
1355
+ // git push --force origin "main") or a leading redirection at the piece's own start (> f rm -rf ~, the split's
1356
+ // own left-behind ">f"/"2>/dev/null"). Rather than adding a spelling to every row, normalizedForms (below) builds
1357
+ // ONE further form: dequote every word of the given array (dequoteScript - the same function the -c script path
1358
+ // above already uses, so a quoted or backslashed verb/target/branch reads exactly as a real shell would pass
1359
+ // it) and rejoin with single spaces. Word boundaries are kept - dequoting per word, not the whole piece as one
1360
+ // blob - specifically so a destructive-looking QUOTED ARGUMENT to an unrelated command (echo "rm -rf ~", grep
1361
+ // 'git reset --hard' f) still can't reach the ^-anchor: the real leading word (echo, grep) stays first in the
1362
+ // joined text.
1363
+ //
1364
+ // Round 2 (reviewer + tester, case 9): round 1 built this from either the WHOLE original word array, or - for a
1365
+ // resolved prefix's own slice - not at all, so a quote or a redirection in front of the VERB ITSELF ("bash" -c
1366
+ // ..., \bash -c ..., > f bash -c ..., sudo "rm" -rf ~, env "git" reset --hard, > f sudo rm -rf ~,
1367
+ // sudo > f rm -rf ~) still slipped past both the shell/-c-script path (isShellVerb never saw the dequoted name)
1368
+ // and this one (the flat form kept a resolved prefix, or an un-skipped redirection, glued to its front - "sudo
1369
+ // rm -rf ~" matches no DESTRUCTIVE row any more than "rm -rf ~" behind a real, unresolved "sudo" would). Fixed
1370
+ // by moving the redirection-skip and the dequote-for-recognition INTO resolveVerb's own walk (see its comment)
1371
+ // rather than only ever applying them once, outside it, to a word array resolveVerb never re-examines: a
1372
+ // redirection is now dropped at the TOP of every one of resolveVerb's loop iterations (wherever it sits - the
1373
+ // front, or between two resolved prefixes), and a prefix name or the final verb is recognised from its DEQUOTED
1374
+ // text. commandForms (below) now calls normalizedForms on `w.slice(i)` - resolveVerb's OWN resolved slice,
1375
+ // already past every redirection and prefix - instead of the whole original array, so this form starts exactly
1376
+ // at the verb regardless of what preceded it. Pushed only when the RESOLVED, DEQUOTED verb is not a shell: a
1377
+ // shell-invoking form (bash -c ..., sh -c ...) already gets an equivalent, and more precise, normalisation from
1378
+ // the -c-script extraction below (the script argument is dequoted and re-split on its own, once per recursion
1379
+ // depth); adding this flat, whole-piece form there too would be a redundant extra form at every depth that
1380
+ // never changes which check fires - only the form COUNT commandForms returns, which an existing fixture (R2,
1381
+ // card bash-rules-prefixed-commands) pins exactly - see the coder's report.
1382
+ //
1383
+ // Parameter-expansion decision (module comment, this card): $HOME and ${HOME} are matched BY NAME - dequoteScript
1384
+ // already leaves a bare or quoted $HOME/${HOME} as that literal text (it doesn't look up environment values), so
1385
+ // no new lookup is added; normalizedForms additionally rewrites ${HOME} to $HOME (one substitution, so the
1386
+ // existing DESTRUCTIVE row's "\$HOME" alternative - already carrying the "/" and "/*" variants - covers both
1387
+ // spellings without a second, parallel set of alternatives). Nothing else is resolved: an indirect variable
1388
+ // (X=~; rm -rf $X), a default-value expansion (${HOME:-/}), or a command substitution's own output are
1389
+ // explicitly OUT of scope - the guard has no shell to run and so no way to learn what value they would carry
1390
+ // (case 7, a pin). Over-denials this normalisation introduces, on the safe side per this module's rule (checks
1391
+ // more, never less): a lone quoted word run as a command name now denies if its dequoted text matches a row
1392
+ // ("rm -rf ~" typed as one quoted argument to nothing, an edge no real script writes); a resolved prefix's own
1393
+ // name or a redirection is never mistaken for a check target, only ever skipped; and, as in commandForms's own
1394
+ // -c-script forms, this doubles as a bash and a zsh reading (kept as two forms only when they differ) - see
1395
+ // normalizedForms. Two more, round 3: `"FOO=1" rm -rf ~` - resolveVerb's ENV_ASSIGN check (see its comment) now
1396
+ // matches the token's DEQUOTED text, so a QUOTED assignment word is skipped as if it were the unquoted form,
1397
+ // even though quoting it actually defeats a real shell's own assignment-word recognition (a real shell tries to
1398
+ // run a program literally named `FOO=1` instead, `rm -rf ~` never becoming its own command at all) - denied here
1399
+ // regardless, since telling that apart would mean tracking which characters of the token were originally
1400
+ // quoted, which this walk does not do. `git commit -m '--no-verify'` (pinned in the test file as P11) - the
1401
+ // commit MESSAGE, not the flag, but normalizedForms's dequoting strips the surrounding quotes from -m's
1402
+ // argument, leaving a real whitespace boundary around the text "--no-verify" that checkNoVerify's own
1403
+ // whitespace-bounded regex cannot tell from the real flag.
1404
+ //
1405
+ // Round 3 (reviewer M2a): normalizedForms used to call its own stripLeadingRedirections on `w` first, but by the
1406
+ // time commandForms calls normalizedForms(w.slice(i)), resolveVerb's walk (above) has ALREADY skipped every
1407
+ // redirection ahead of the verb - unconditionally, on every iteration, regardless of what recognised the
1408
+ // verb - so that call was always a no-op (span 0 on the first word) on every input commandForms actually passes.
1409
+ // Removed rather than kept as dead insurance: this module already used redirWordSpan directly wherever a
1410
+ // redirection actually needs skipping (resolveVerb's own loop), and a second, unreachable copy of the same walk
1411
+ // invited exactly the "is this still needed" question the reviewer asked - answered here by removing it, not by
1412
+ // leaving a comment promising it was harmless.
1413
+ function normalizedForms(w) {
1414
+ if (w.length === 0) return [];
1415
+ const build = (reading) => w.map((t) => dequoteScript(t, reading).replace(/\$\{HOME\}/g, "$HOME")).join(" ");
1416
+ const bash = build("bash");
1417
+ const zsh = build("zsh");
1418
+ return bash === zsh ? [bash] : [bash, zsh];
1419
+ }
1420
+
1421
+ export function commandForms(cmd, depth = 0) {
1422
+ const forms = [cmd];
1423
+ try {
1424
+ const original = wordsQ(cmd);
1425
+ const r = resolveVerb(original, 0);
1426
+ if (r.terminal) return forms;
1427
+ const { w, i } = r;
1428
+ const verb = w[i];
1429
+ // Card bash-rules-normalise-match-text, round 2: the verb this loop compares against isShellVerb is
1430
+ // DEQUOTED first - a quote or a backslash in front of a real shell name ("bash", 'sh', \bash) must not
1431
+ // defeat isShellVerb's exact-name match any more than it defeats a real shell's own lookup of the binary
1432
+ // (case 9, round 2's fix; resolveVerb above does the same for a PREFIX name).
1433
+ //
1434
+ // Round 3 (tester's live probe P3, `$'s\h' -c 'rm -rf ~'`, confirmed real in zsh 5.9 only - bash 3.2.57's
1435
+ // ANSI-C reading of the unrecognised \h escape keeps the backslash, "s\h" resolving to nothing real, but
1436
+ // zsh's reading drops it, resolving to the real "sh"): round 2's comment here claimed the zsh reading "only
1437
+ // matters inside a -c script's own $'...' content, never for a bare verb name" - false, since a bare verb
1438
+ // name can itself be a $'...' word, and P3 is exactly that. verbIsShell now checks BOTH readings
1439
+ // (dequoteScript "bash" and "zsh"), the same "recognise if EITHER matches" rule resolveVerb's own isBase
1440
+ // uses (see its comment) and for the identical reason: a spelling only one real shell's reading resolves to
1441
+ // a real shell name is still a real spelling that shell truly runs. verbIsShell is used for BOTH isShellVerb
1442
+ // decisions below - whether to push the flat normalised form (this one is for an ORDINARY, non-shell verb
1443
+ // only: a shell-invoking form (bash -c ..., sh -c ...) already gets an equivalent, and more precise,
1444
+ // normalisation from the -c-script extraction that follows, once per recursion depth down to the same
1445
+ // depth-3 cap; adding this flat, whole-piece form there TOO would just be a redundant extra form at every
1446
+ // depth - confirmed: it never changes which check fires, only inflates commandForms's own form count, which
1447
+ // the depth-cap fixture (R2, card bash-rules-prefixed-commands) pins exactly) - and whether to descend into
1448
+ // -c-script extraction at all (which reading recognised the verb is not examined past this point: the
1449
+ // script-candidate walk and dequoteScript's own two-reading extraction, right below, are unaffected either
1450
+ // way). normalizedForms is built from `w.slice(i)` - resolveVerb's OWN resolved slice, already walked past
1451
+ // every redirection and prefix - not the whole original array, so a resolved prefix (sudo, env, ...) or a
1452
+ // redirection that used to sit ahead of the verb is never glued to the front of this flat form either
1453
+ // (round 2's other half of case 9: sudo "rm" -rf ~, env "git" reset --hard, > f sudo rm -rf ~,
1454
+ // sudo > f rm -rf ~).
1455
+ const verbIsShell =
1456
+ !!verb && (isShellVerb(basenameOf(dequoteScript(verb, "bash"))) || isShellVerb(basenameOf(dequoteScript(verb, "zsh"))));
1457
+ if (!verb || !verbIsShell) {
1458
+ for (const n of normalizedForms(w.slice(i))) if (!forms.includes(n)) forms.push(n);
1459
+ }
1460
+ if (i > 0 || w !== original) forms.push(w.slice(i).join(" "));
1461
+ if (depth >= 3 || !verb || !verbIsShell) return forms;
1462
+ const { cSeen, candidates } = findScriptCandidates(w, i);
1463
+ if (!cSeen) return forms;
1464
+ for (const idx of candidates) {
1465
+ const bashScript = dequoteScript(w[idx], "bash");
1466
+ const zshScript = dequoteScript(w[idx], "zsh");
1467
+ const scripts = bashScript === zshScript ? [bashScript] : [bashScript, zshScript];
1468
+ for (const script of scripts) {
1469
+ if (!script) continue;
1470
+ for (const piece of splitCommands(script)) {
1471
+ forms.push(...commandForms(piece, depth + 1));
1472
+ }
1473
+ }
1474
+ }
1475
+ return forms;
1476
+ } catch {
1477
+ return [cmd];
1478
+ }
1479
+ }
1480
+
1481
+ export function checkPackageManager(cmd, expected) {
1482
+ if (!expected) return null;
1483
+ const m = /^(npm|pnpm|yarn|bun)\s*(.*)$/.exec(cmd);
1484
+ if (!m) return null;
1485
+ const [, pm, rest] = m;
1486
+ if (pm === expected) return null;
1487
+ const verb = PM_INSTALL_VERBS[pm];
1488
+ if (verb && verb.test(rest.trim())) {
1489
+ return `This project uses ${expected}, not ${pm}. Mixing package managers corrupts the lockfile. Use "${expected} ${rest.trim() || "install"}".`;
1490
+ }
1491
+ return null;
1492
+ }
1493
+
1494
+ export function evaluateBash(command, { config, currentBranch }) {
1495
+ const reasons = [];
1496
+ // Runs once on the whole command string, before splitCommands, so the pipe relation survives; its reason
1497
+ // goes first, ahead of anything the per-command loop below finds.
1498
+ if (config.bash.denyDestructive) {
1499
+ const pipeReason = checkPipeToShell(command);
1500
+ if (pipeReason) reasons.push(pipeReason);
1501
+ }
1502
+ for (const cmd of splitCommands(command)) {
1503
+ // Card bash-rules-prefixed-commands: run the five per-command checks (each anchored on the start of the
1504
+ // string it's given) on every form commandForms resolves for this split command - cmd itself, a prefix
1505
+ // (sudo, an env assignment, env, nice, ...) resolved away, and a shell's -c string evaluated as its own
1506
+ // command - not just cmd. A reason already found on an earlier form is never pushed twice.
1507
+ for (const form of commandForms(cmd)) {
1508
+ const checks = [];
1509
+ if (config.bash.denyNoVerify) checks.push(checkNoVerify(form));
1510
+ if (config.bash.denyForcePushTo && config.bash.denyForcePushTo.length) {
1511
+ checks.push(checkForcePush(form, config.bash.denyForcePushTo, currentBranch));
1512
+ // Independent of denyDestructive: a repository that turns the destructive catalogue off still keeps its
1513
+ // protected branches safe from a hard reset.
1514
+ checks.push(checkResetHardOnProtected(form, config.bash.denyForcePushTo, currentBranch));
1515
+ }
1516
+ if (config.bash.denyDestructive) checks.push(checkDestructive(form));
1517
+ if (config.bash.packageManagerGuard) checks.push(checkPackageManager(form, config.packageManager));
1518
+ for (const r of checks) if (r && !reasons.includes(r)) reasons.push(r);
1519
+ }
1520
+ }
1521
+ return reasons;
1522
+ }