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