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