@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.
- package/LICENSE +21 -0
- package/dist/ablate.d.ts +96 -0
- package/dist/ablate.js +440 -0
- package/dist/ablate.js.map +1 -0
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +19 -0
- package/dist/apply.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +80 -0
- package/dist/bin.js.map +1 -0
- package/dist/board-page.d.ts +15 -0
- package/dist/board-page.js +27 -0
- package/dist/board-page.js.map +1 -0
- package/dist/board-serve-process.d.ts +24 -0
- package/dist/board-serve-process.js +227 -0
- package/dist/board-serve-process.js.map +1 -0
- package/dist/board-serve.d.ts +9 -0
- package/dist/board-serve.js +302 -0
- package/dist/board-serve.js.map +1 -0
- package/dist/board.d.ts +6 -0
- package/dist/board.js +515 -0
- package/dist/board.js.map +1 -0
- package/dist/detect/fs.d.ts +6 -0
- package/dist/detect/fs.js +50 -0
- package/dist/detect/fs.js.map +1 -0
- package/dist/detect/index.d.ts +3 -0
- package/dist/detect/index.js +13 -0
- package/dist/detect/index.js.map +1 -0
- package/dist/detect/node.d.ts +3 -0
- package/dist/detect/node.js +179 -0
- package/dist/detect/node.js.map +1 -0
- package/dist/detect/python.d.ts +2 -0
- package/dist/detect/python.js +388 -0
- package/dist/detect/python.js.map +1 -0
- package/dist/detect/repo.d.ts +2 -0
- package/dist/detect/repo.js +102 -0
- package/dist/detect/repo.js.map +1 -0
- package/dist/detect/types.d.ts +98 -0
- package/dist/detect/types.js +2 -0
- package/dist/detect/types.js.map +1 -0
- package/dist/diff.d.ts +12 -0
- package/dist/diff.js +105 -0
- package/dist/diff.js.map +1 -0
- package/dist/generate/agents.d.ts +19 -0
- package/dist/generate/agents.js +184 -0
- package/dist/generate/agents.js.map +1 -0
- package/dist/generate/claude-md.d.ts +9 -0
- package/dist/generate/claude-md.js +111 -0
- package/dist/generate/claude-md.js.map +1 -0
- package/dist/generate/config.d.ts +51 -0
- package/dist/generate/config.js +155 -0
- package/dist/generate/config.js.map +1 -0
- package/dist/generate/proposal.d.ts +11 -0
- package/dist/generate/proposal.js +175 -0
- package/dist/generate/proposal.js.map +1 -0
- package/dist/generate/settings.d.ts +35 -0
- package/dist/generate/settings.js +213 -0
- package/dist/generate/settings.js.map +1 -0
- package/dist/generate/skills.d.ts +8 -0
- package/dist/generate/skills.js +264 -0
- package/dist/generate/skills.js.map +1 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.js +69 -0
- package/dist/init.js.map +1 -0
- package/dist/trace.d.ts +9 -0
- package/dist/trace.js +106 -0
- package/dist/trace.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/flow/LICENSE +21 -0
- package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
- package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +35 -0
- package/node_modules/@dougharness/flow/agents/planner.md +70 -0
- package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
- package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
- package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
- package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
- package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
- package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
- package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
- package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
- package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
- package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
- package/node_modules/@dougharness/flow/package.json +19 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
- package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
- package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
- package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
- package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
- package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
- package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
- package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/gates/LICENSE +21 -0
- package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
- package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
- package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
- package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
- package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
- package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
- package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
- package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
- package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
- package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
- package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
- package/node_modules/@dougharness/gates/package.json +17 -0
- package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
- package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
- package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
- package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
- package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
- package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
- package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
- package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
- package/package.json +33 -0
- 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
|
+
}
|