@piagent/platform 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +149 -1
  2. package/README.md +1 -1
  3. package/catalog/capabilities.json +4 -4
  4. package/docs/command-reference-vietnamese.md +15 -4
  5. package/docs/context-window-policy.md +1 -0
  6. package/docs/distribution-standard.md +7 -7
  7. package/docs/mcp-and-tools.md +49 -3
  8. package/docs/operator-manual-vietnamese.md +52 -11
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/quickstart-vietnamese.md +4 -2
  11. package/docs/release-install-policy.md +6 -6
  12. package/docs/security-threat-model.md +4 -3
  13. package/docs/team-onboarding.md +3 -3
  14. package/docs/usage-observability.md +49 -0
  15. package/docs-site/architecture.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/mcp-approval.html +54 -2
  18. package/docs-site/content/quickstart.html +1 -1
  19. package/docs-site/index.html +1 -1
  20. package/docs-site/mcp-approval.html +55 -3
  21. package/docs-site/mcp-auth.html +1 -1
  22. package/docs-site/mcp-servers.html +1 -1
  23. package/docs-site/mcp.html +1 -1
  24. package/docs-site/permissions.html +1 -1
  25. package/docs-site/profiles.html +1 -1
  26. package/docs-site/quickstart.html +2 -2
  27. package/docs-site/runtime.html +1 -1
  28. package/docs-site/scope.html +1 -1
  29. package/docs-site/security.html +1 -1
  30. package/docs-site/team.html +1 -1
  31. package/docs-site/workflows.html +1 -1
  32. package/package.json +1 -1
  33. package/packages/piagent-core/README.md +2 -0
  34. package/packages/piagent-core/capabilities/capability-core.js +41 -4
  35. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  36. package/packages/piagent-core/extensions/piagent-guard.ts +539 -70
  37. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  38. package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
  39. package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
  40. package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
  41. package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
  42. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  43. package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
  44. package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
  45. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  46. package/packages/piagent-core/package.json +1 -1
  47. package/packages/piagent-core/prompts/be-to-fe.md +1 -0
  48. package/packages/piagent-core/prompts/piagent-commands.md +1 -0
  49. package/packages/piagent-core/prompts/platform-improve.md +1 -0
  50. package/packages/piagent-core/prompts/review.md +1 -0
  51. package/packages/piagent-core/prompts/scout.md +1 -0
  52. package/packages/piagent-core/prompts/task.md +1 -0
  53. package/scripts/configure-model-scope.sh +19 -3
  54. package/scripts/configure-subagents.sh +17 -4
  55. package/scripts/init-project.sh +15 -8
  56. package/scripts/mcp-manage.mjs +111 -22
  57. package/scripts/pi-model-catalog.sh +11 -1
  58. package/scripts/pi-session-stats.sh +27 -1
  59. package/scripts/pi-usage-history.mjs +567 -0
  60. package/scripts/quality-benchmark.sh +37 -9
  61. package/scripts/setup.sh +21 -13
  62. package/scripts/uninstall-global.sh +10 -4
  63. package/scripts/verify-local.sh +19 -0
  64. package/scripts/verify-release-identity.mjs +29 -0
@@ -6,6 +6,36 @@ const SEARCH_GLOB_OPTIONS = new Set(["-g", "--glob", "--iglob", "--include", "--
6
6
  const SEARCH_PATTERN_OPTIONS = new Set(["-e", "--regexp", "--pattern"]);
7
7
  const ASSIGNMENT_BUILTINS = new Set(["export", "readonly", "declare", "typeset", "local"]);
8
8
  const SHELL_COMMANDS = new Set(["bash", "sh", "zsh"]);
9
+
10
+ // Commands whose non-flag operands are files by definition, rather than by
11
+ // convention. The distinction matters because this set decides where a word
12
+ // nobody can resolve is refused: `cat $F` names a file whatever `F` holds, so
13
+ // not knowing it is worth a question, while `git commit -m $MSG` names a
14
+ // message and asking about it would be noise with no answer behind it.
15
+ const FILE_OPERAND_COMMANDS = new Set([
16
+ "cat", "tac", "head", "tail", "less", "more", "nl", "od", "xxd", "strings", "wc",
17
+ "base64", "md5sum", "shasum", "sha256sum", "cksum", "file", "stat", "readlink",
18
+ "cp", "mv", "ln", "touch", "install", "shred", "truncate", "chmod", "chown", "chgrp",
19
+ "source", ".", "open"
20
+ ]);
21
+
22
+ // Interpreters that take a program on the command line, and the flags that
23
+ // introduce it. What follows such a flag is source code in another language, so
24
+ // none of it tokenises as a shell path — `node -e "readFileSync('.env')"` is one
25
+ // opaque argument to the shell parser. The filenames live in the string literals
26
+ // inside it, which is where this looks.
27
+ const INLINE_CODE_FLAGS = new Map([
28
+ ["node", new Set(["-e", "--eval", "-p", "--print"])],
29
+ ["nodejs", new Set(["-e", "--eval", "-p", "--print"])],
30
+ ["deno", new Set(["eval"])],
31
+ ["bun", new Set(["-e", "--eval"])],
32
+ ["python", new Set(["-c"])],
33
+ ["python2", new Set(["-c"])],
34
+ ["python3", new Set(["-c"])],
35
+ ["ruby", new Set(["-e"])],
36
+ ["perl", new Set(["-e", "-E"])],
37
+ ["php", new Set(["-r"])]
38
+ ]);
9
39
  const SIMPLE_WRAPPERS = new Set(["sudo", "nohup", "time", "nice", "ionice", "command"]);
10
40
 
11
41
  function escapeRegex(value) {
@@ -96,6 +126,12 @@ export function splitShellSegments(command) {
96
126
  let current = "";
97
127
  let quote;
98
128
  let escaped = false;
129
+ // A separator inside `$( )` or backticks belongs to the substitution, not to
130
+ // the command line around it. Splitting there cut `cat $(echo x; echo .env)`
131
+ // into two halves, and the half holding the path was re-read as a command of
132
+ // its own — so the path was never a path to anything here.
133
+ let substitutionDepth = 0;
134
+ let inBackticks = false;
99
135
  for (let index = 0; index < command.length; index += 1) {
100
136
  const char = command[index];
101
137
  const next = command[index + 1];
@@ -119,9 +155,34 @@ export function splitShellSegments(command) {
119
155
  current += char;
120
156
  continue;
121
157
  }
158
+ if (!quote) {
159
+ if (char === "$" && next === "(") {
160
+ substitutionDepth += 1;
161
+ current += char;
162
+ continue;
163
+ }
164
+ if (char === ")" && substitutionDepth > 0) {
165
+ substitutionDepth -= 1;
166
+ current += char;
167
+ continue;
168
+ }
169
+ if (char === "`") {
170
+ inBackticks = !inBackticks;
171
+ current += char;
172
+ continue;
173
+ }
174
+ }
122
175
  const previous = command[index - 1];
123
176
  const ampersandIsSeparator = char === "&" && next !== "&" && previous !== ">" && previous !== "<";
124
- if (!quote && (char === "\n" || char === ";" || char === "|" || ampersandIsSeparator || (char === "&" && next === "&") || (char === "|" && next === "|"))) {
177
+ // `>|` is one operator: the noclobber override. Without the same lookbehind
178
+ // `&` already had, the `|` split the line and the redirection target became
179
+ // a command name, so `printf x >| .env` truncated a protected file against a
180
+ // candidate list that never contained it.
181
+ const pipeIsSeparator = char === "|" && previous !== ">";
182
+ const inSubstitution = substitutionDepth > 0 || inBackticks;
183
+ if (!quote && !inSubstitution
184
+ && (char === "\n" || char === ";" || pipeIsSeparator || ampersandIsSeparator
185
+ || (char === "&" && next === "&") || (char === "|" && next === "|"))) {
125
186
  const segment = current.trim();
126
187
  if (segment) segments.push(segment);
127
188
  current = "";
@@ -135,6 +196,39 @@ export function splitShellSegments(command) {
135
196
  return segments.length ? segments : [command.trim()].filter(Boolean);
136
197
  }
137
198
 
199
+ /**
200
+ * Reads one `$'...'` ANSI-C quote and returns what the shell would pass along.
201
+ *
202
+ * Treating it as an ordinary single quote is not enough: the escapes inside are
203
+ * the point of the syntax, and leaving them raw means `$'\x2eenv'` is read as
204
+ * the literal `x2eenv` while the shell opens `.env`. The same trick reaches
205
+ * `rm -rf $'\x2f'`, which nothing recognised as root.
206
+ *
207
+ * A backslash escapes the next character, the closing quote included, so `\'`
208
+ * does not end the quote.
209
+ *
210
+ * @param {string[]|string} chars the segment, indexed the same way the caller
211
+ * indexes it, so the returned endIndex can be assigned straight back
212
+ * @param {number} openIndex index of the `$`
213
+ * @returns {{value: string, endIndex: number}} decoded content, and the index of
214
+ * the closing quote (or the last character when the quote never closes)
215
+ */
216
+ function readAnsiCQuote(chars, openIndex) {
217
+ let raw = "";
218
+ let index = openIndex + 2;
219
+ for (; index < chars.length; index += 1) {
220
+ const char = chars[index];
221
+ if (char === "\\" && index + 1 < chars.length) {
222
+ raw += char + chars[index + 1];
223
+ index += 1;
224
+ continue;
225
+ }
226
+ if (char === "'") break;
227
+ raw += char;
228
+ }
229
+ return { value: decodeShellDataEscapes(raw), endIndex: Math.min(index, chars.length - 1) };
230
+ }
231
+
138
232
  function shellWordTokens(segment) {
139
233
  const tokens = [];
140
234
  let current = "";
@@ -143,17 +237,37 @@ function shellWordTokens(segment) {
143
237
  let activeGlob = false;
144
238
  let unquotedVariable = false;
145
239
  let variableActive = false;
240
+ // A space inside `$( )`, `${ }` or backticks is part of one word, not a break
241
+ // between two. Splitting there tore `.en$(echo v)` into `.en$(echo` and `v)`,
242
+ // so the filename being assembled was never a single token to reason about.
243
+ let substitutionDepth = 0;
244
+ let inBackticks = false;
245
+ // Set only for a brace the shell would expand. Quoting suspends the expansion
246
+ // (`"{a,b}"` is one literal word), and `${NAME}` is a parameter reference
247
+ // rather than a list, so neither marks the token.
248
+ let braceActive = false;
249
+ // One flag per character of `value`: `1` where the character is text rather
250
+ // than brace syntax -- quoted, escaped, part of `${...}`, or inside a
251
+ // substitution. A single flag for the whole token cannot describe
252
+ // `"{.env,}"{,}`, where one group is literal and the next one expands.
253
+ let literalMask = "";
146
254
  const flush = () => {
147
255
  if (!current) return;
148
- tokens.push({ value: current, activeGlob, unquotedVariable, variableActive });
256
+ tokens.push({ value: current, activeGlob, unquotedVariable, variableActive, braceActive, literalMask });
149
257
  current = "";
258
+ literalMask = "";
150
259
  activeGlob = false;
151
260
  unquotedVariable = false;
152
261
  variableActive = false;
262
+ braceActive = false;
153
263
  };
154
- for (const char of segment.trim()) {
264
+ const chars = [...segment.trim()];
265
+ for (let index = 0; index < chars.length; index += 1) {
266
+ const char = chars[index];
155
267
  if (escaped) {
156
268
  current += char;
269
+ // `\{a,b\}` is a filename, not a list.
270
+ literalMask += "1";
157
271
  escaped = false;
158
272
  continue;
159
273
  }
@@ -161,6 +275,19 @@ function shellWordTokens(segment) {
161
275
  escaped = true;
162
276
  continue;
163
277
  }
278
+ // `$'...'` is ANSI-C quoting: the dollar opens the quote rather than naming
279
+ // a variable, and the escapes inside it are decoded before the word is
280
+ // passed on. Reading it as a variable left the dollar glued to the value, so
281
+ // `cat $'.env'` produced the candidate `$.env`; reading it as a plain single
282
+ // quote dropped the backslashes, so `cat $'\x2eenv'` produced `x2eenv`.
283
+ // Either way the shell opened `.env` and nothing here saw a protected path.
284
+ if (char === "$" && chars[index + 1] === "'" && !quote) {
285
+ const ansiC = readAnsiCQuote(chars, index);
286
+ current += ansiC.value;
287
+ literalMask += "1".repeat(ansiC.value.length);
288
+ index = ansiC.endIndex;
289
+ continue;
290
+ }
164
291
  if ((char === "'" || char === "\"") && !quote) {
165
292
  quote = char;
166
293
  continue;
@@ -169,16 +296,41 @@ function shellWordTokens(segment) {
169
296
  quote = undefined;
170
297
  continue;
171
298
  }
172
- if (!quote && /\s/.test(char)) {
299
+ // The depth this character sits at before it can close the substitution it
300
+ // belongs to. Reading the depth afterwards left the `}` of `${...}` looking
301
+ // like brace syntax, so `{cat,${X:-.env}}` closed on the wrong brace and
302
+ // expanded to `cat}` and `${X:-.env}` instead of `cat` and `.env`.
303
+ const enclosingDepth = substitutionDepth;
304
+ if (!quote) {
305
+ if (char === "$" && (chars[index + 1] === "(" || chars[index + 1] === "{")) substitutionDepth += 1;
306
+ else if ((char === ")" || char === "}") && substitutionDepth > 0) substitutionDepth -= 1;
307
+ else if (char === "`") inBackticks = !inBackticks;
308
+ }
309
+ const insideSubstitution = enclosingDepth > 0 || substitutionDepth > 0;
310
+ if (!quote && substitutionDepth === 0 && !inBackticks && /\s/.test(char)) {
173
311
  flush();
174
312
  continue;
175
313
  }
176
314
  if (!quote && /[*?{\[]/.test(char)) activeGlob = true;
177
- if (char === "$" && quote !== "'") {
315
+ // A brace the shell would expand, which is any brace not opening a parameter
316
+ // reference and not inside one. The dollar is counted rather than looked for
317
+ // behind the brace, because a dollar can be there without opening anything:
318
+ // in `\${a,b}` it is literal text and the shell still expands the group.
319
+ //
320
+ // No decision can currently tell the two readings apart -- the literal
321
+ // dollar is glued onto every alternative, so `\${a,.env}` produces `$a` and
322
+ // `$.env` and neither is a path or a command. This is the correct reading
323
+ // rather than a fix for anything, and it is written this way because the
324
+ // depth is already known here and looking backwards was never right.
325
+ if (!quote && char === "{" && enclosingDepth === 0 && !inBackticks) {
326
+ braceActive = true;
327
+ }
328
+ if ((char === "$" || char === "`") && quote !== "'") {
178
329
  variableActive = true;
179
330
  if (!quote) unquotedVariable = true;
180
331
  }
181
332
  current += char;
333
+ literalMask += (quote || insideSubstitution || inBackticks) ? "1" : "0";
182
334
  }
183
335
  flush();
184
336
  return tokens;
@@ -188,6 +340,323 @@ export function shellWords(segment) {
188
340
  return shellWordTokens(segment).map((token) => token.value);
189
341
  }
190
342
 
343
+ const BRACE_RANGE = /^(-?\d+|[A-Za-z])\.\.(-?\d+|[A-Za-z])(?:\.\.(-?\d+))?$/;
344
+
345
+ /**
346
+ * The members of a `{a..z}` or `{1..9}` range, or undefined when the body is
347
+ * not one.
348
+ *
349
+ * Ranges were left literal on the reasoning that they enumerate counters rather
350
+ * than spell names. They spell names perfectly well: `{n..n}` is one letter, so
351
+ * `r{m..m}` is `rm`, `fi{n..n}d` is `find` and `.e{n..n}v` is `.env`. A range
352
+ * with a single member is the same trick the empty alternative was.
353
+ *
354
+ * @param {string} body
355
+ * @returns {string[]|undefined}
356
+ */
357
+ function braceRangeAlternatives(body) {
358
+ const match = BRACE_RANGE.exec(body);
359
+ if (!match) return undefined;
360
+ const [, from, to, step] = match;
361
+ const increment = Math.abs(Number(step ?? 1)) || 1;
362
+ // The letter ranges are the ones that spell things. A digit range enumerates
363
+ // counters, and a protected pattern that would match what it produces matches
364
+ // the unexpanded word too, since the glob covers the braces -- so no decision
365
+ // here turns on the numeric branch. It is expanded anyway, because a word
366
+ // list that disagrees with the shell is the thing this whole file is for.
367
+ const numeric = /^-?\d+$/.test(from) && /^-?\d+$/.test(to);
368
+ if (!numeric && (from.length !== 1 || to.length !== 1 || /^-?\d+$/.test(from) || /^-?\d+$/.test(to))) {
369
+ return undefined;
370
+ }
371
+ const start = numeric ? Number(from) : from.codePointAt(0);
372
+ const end = numeric ? Number(to) : to.codePointAt(0);
373
+ // Bash keeps the zero padding when either endpoint is written with it.
374
+ const width = numeric && (/^-?0\d/.test(from) || /^-?0\d/.test(to))
375
+ ? Math.max(from.replace("-", "").length, to.replace("-", "").length)
376
+ : 0;
377
+ const members = [];
378
+ const direction = start <= end ? 1 : -1;
379
+ for (let value = start; direction > 0 ? value <= end : value >= end; value += direction * increment) {
380
+ if (members.length >= MAX_BRACE_RESULTS) break;
381
+ if (!numeric) {
382
+ members.push(String.fromCodePoint(value));
383
+ continue;
384
+ }
385
+ const text = String(Math.abs(value)).padStart(width, "0");
386
+ members.push(value < 0 ? `-${text}` : text);
387
+ }
388
+ return members;
389
+ }
390
+
391
+ /**
392
+ * The alternatives of one brace group, split on the commas at its own level.
393
+ * Returns the mask slices alongside, so a nested group keeps knowing which of
394
+ * its characters are syntax.
395
+ */
396
+ function splitBraceAlternatives(body, mask) {
397
+ const parts = [];
398
+ let current = "";
399
+ let currentMask = "";
400
+ let level = 0;
401
+ for (let index = 0; index < body.length; index += 1) {
402
+ const char = body[index];
403
+ const literal = mask[index] === "1";
404
+ if (!literal && char === "{") level += 1;
405
+ else if (!literal && char === "}") level -= 1;
406
+ else if (!literal && char === "," && level === 0) {
407
+ parts.push({ value: current, mask: currentMask });
408
+ current = "";
409
+ currentMask = "";
410
+ continue;
411
+ }
412
+ current += char;
413
+ currentMask += mask[index];
414
+ }
415
+ parts.push({ value: current, mask: currentMask });
416
+ return parts;
417
+ }
418
+
419
+ const MAX_BRACE_RESULTS = 64;
420
+
421
+ /**
422
+ * Brace expansion, which the shell performs before every other expansion.
423
+ *
424
+ * Nothing here modelled it, so a word was read as the single literal the author
425
+ * typed. `rm -rf {/,}` is `rm -rf /` by the time it runs, and `cat {.env,}`
426
+ * opens `.env`; both read as ordinary words holding no root and no protected
427
+ * path. The trailing empty alternative is the whole trick — it makes a one-word
428
+ * expansion out of a form that does not look like one.
429
+ *
430
+ * A group without a top-level comma is left alone, because the shell leaves it
431
+ * alone: `{/}` and `{a}` are literal. Ranges are expanded: they look like they
432
+ * only enumerate counters, but `{n..n}` is a single letter with the braces
433
+ * taken off, which is how `r{m..m}` spells `rm` and `.e{n..n}v` spells `.env`.
434
+ *
435
+ * @param {string} word
436
+ * @returns {string[]} every word the shell would produce, the input itself when
437
+ * there is no expansion to do
438
+ */
439
+ function expandShellBraces(word, mask = "0".repeat(word.length), depth = 0) {
440
+ if (depth >= 6 || !word.includes("{")) return [{ value: word, mask }];
441
+ const literalAt = (index) => mask[index] === "1";
442
+ let open = -1;
443
+ let level = 0;
444
+ for (let index = 0; index < word.length; index += 1) {
445
+ const char = word[index];
446
+ if (literalAt(index)) continue;
447
+ if (char === "{") {
448
+ if (level === 0) open = index;
449
+ level += 1;
450
+ continue;
451
+ }
452
+ if (char !== "}" || level === 0) continue;
453
+ level -= 1;
454
+ if (level > 0) continue;
455
+ const body = word.slice(open + 1, index);
456
+ const bodyMask = mask.slice(open + 1, index);
457
+ const range = braceRangeAlternatives(body);
458
+ const alternatives = range
459
+ ? range.map((value) => ({ value, mask: "0".repeat(value.length) }))
460
+ : splitBraceAlternatives(body, bodyMask);
461
+ // A comma body needs two alternatives to be an expansion at all -- `{a}` is
462
+ // literal to the shell. A *range* does not: `{m..m}` is one member and the
463
+ // braces still come off, which is the whole of `r{m..m}`.
464
+ if (!range && alternatives.length < 2) {
465
+ open = -1;
466
+ continue;
467
+ }
468
+ const prefix = word.slice(0, open);
469
+ const prefixMask = mask.slice(0, open);
470
+ const suffix = word.slice(index + 1);
471
+ const suffixMask = mask.slice(index + 1);
472
+ const results = [];
473
+ for (const alternative of alternatives) {
474
+ const expandedWord = `${prefix}${alternative.value}${suffix}`;
475
+ const expandedMask = `${prefixMask}${alternative.mask}${suffixMask}`;
476
+ for (const expanded of expandShellBraces(expandedWord, expandedMask, depth + 1)) {
477
+ if (results.length >= MAX_BRACE_RESULTS) return results;
478
+ results.push(expanded);
479
+ }
480
+ }
481
+ return results;
482
+ }
483
+ return [{ value: word, mask }];
484
+ }
485
+
486
+ /**
487
+ * True when the word would be matched against filenames rather than used as
488
+ * one, judged per character so quoting counts where the shell counts it.
489
+ *
490
+ * The tokenizer decides this for a word as typed. After brace expansion the
491
+ * answer can differ for each word that comes out -- `{cat,.env*}` produces a
492
+ * command and a pattern from one token, and carrying the parent token's verdict
493
+ * onto both got one of them wrong.
494
+ *
495
+ * @param {string} value
496
+ * @param {string} mask
497
+ * @returns {boolean}
498
+ */
499
+ /**
500
+ * The single file a redirection would open, or nothing when there is no single
501
+ * file for it to name.
502
+ *
503
+ * Bash writes only where the target expands to exactly one word, and calls
504
+ * everything else an ambiguous redirect and does nothing. `> {.env,x}`,
505
+ * `> "{.env,}"{,}` and `> .en{v,w}*` each expand to two words and open no file
506
+ * at all, so reporting a protected path for them blocked a command that writes
507
+ * nothing. `> {.env,}` is the other way round -- the empty alternative is
508
+ * dropped, one word is left, and it really does truncate `.env`.
509
+ *
510
+ * Expansion happens here rather than at the candidate, so it happens with the
511
+ * record of which characters were quoted, and in the order the shell uses:
512
+ * braces first, substitutions after.
513
+ *
514
+ * Whether to expand at all is the mask's decision and nothing else's. The
515
+ * scanner used to hand over a separate `braceActive` flag beside it, and a flag
516
+ * for a whole word is the thing that went wrong here in the first place: it can
517
+ * only say "expand it all" or "expand none of it", and it can disagree with the
518
+ * record next to it. The mask answers per character, so it answers both.
519
+ *
520
+ * @param {{value: string, literalMask?: string}} redirect
521
+ * @returns {{value: string, mask: string}[]}
522
+ */
523
+ function redirectionTargetWords(redirect) {
524
+ // The scanner writes one mask character per target character, so the fallback
525
+ // is a defensive default rather than a reachable branch. Unmasked is the safe
526
+ // direction of the two: it expands, and expanding offers more to check.
527
+ const mask = redirect.literalMask?.length === redirect.value.length
528
+ ? redirect.literalMask
529
+ : "0".repeat(redirect.value.length);
530
+ const words = expandShellBraces(redirect.value, mask).filter((entry) => entry.value);
531
+ if (words.length === 1) return words;
532
+ // Two words are only two words while both survive. An unset variable expands
533
+ // to nothing, so `> {$X,.env}` leaves one word and truncates `.env`, and an
534
+ // unmatched glob does the same under `nullglob`. Where a word can still
535
+ // vanish the count is not knowable here, so every word stays a candidate.
536
+ const collapsible = words.some((entry) => /\$|`/.test(entry.value) || wordHasActiveGlob(entry.value, entry.mask));
537
+ return collapsible ? words : [];
538
+ }
539
+
540
+ function wordHasActiveGlob(value, mask) {
541
+ for (let index = 0; index < value.length; index += 1) {
542
+ if (mask[index] === "1") continue;
543
+ if (/[*?[]/.test(value[index])) return true;
544
+ }
545
+ return false;
546
+ }
547
+
548
+ /**
549
+ * The word list the shell builds for a segment, brace expansion included.
550
+ *
551
+ * Applying the expansion inside the destructive checks closed those checks and
552
+ * nothing else, which is not what the shell does -- it expands once, before it
553
+ * decides what the command is, and every reader downstream sees the result. So
554
+ * `git reset --har{d,}` sailed past a legacy pattern, `docker volume pr{une,}`
555
+ * past an exec-policy rule, and `{bash,} -c '...'` past the nested-interpreter
556
+ * scan, each of them reading the text as typed. This is the one place the word
557
+ * list is built, so a reader cannot be left behind again.
558
+ *
559
+ * `text` is the segment rewritten from those words, for the matchers that
560
+ * compare against the command as a string. It is the raw segment when there is
561
+ * no expansion to do, so quoting is preserved in the ordinary case.
562
+ *
563
+ * @param {string} segment
564
+ * @returns {{words: string[], text: string, tokens: ReturnType<typeof shellWordTokens>}}
565
+ */
566
+ function expandedSegment(segment, assignments = new Map()) {
567
+ const rawTokens = shellWordTokens(segment);
568
+ if (!rawTokens.some((token) => token.braceActive)) {
569
+ return resolveExpandedTokens(segment, rawTokens, assignments, segment);
570
+ }
571
+ // Expanded into *tokens*, not just into a word list. Taking the command name
572
+ // from the expanded words and the arguments from the raw ones read
573
+ // `{cat,.env}` as a `cat` with no operands at all, because both the command
574
+ // and its argument come out of that single word. Everything downstream takes
575
+ // command and arguments from this one stream.
576
+ //
577
+ // An empty alternative expands to nothing rather than to an empty argument --
578
+ // `rm {-rf,} /` reaches `rm` as two words, not three.
579
+ const tokens = rawTokens.flatMap((token) => {
580
+ if (!token.braceActive) return [token];
581
+ const expansions = expandShellBraces(token.value, token.literalMask).filter((entry) => entry.value);
582
+ if (expansions.length === 1 && expansions[0].value === token.value) return [token];
583
+ // The mask comes out of the expansion with the word, because one token can
584
+ // produce words that differ in what they are: `{cat,.env*}` is a command and
585
+ // a pattern, and deciding either from the parent's flags gets one of them
586
+ // wrong. `braceActive` is cleared below for every token alike.
587
+ return expansions.map((entry) => ({
588
+ ...token,
589
+ value: entry.value,
590
+ literalMask: entry.mask,
591
+ activeGlob: wordHasActiveGlob(entry.value, entry.mask)
592
+ }));
593
+ });
594
+ // `braceActive` is cleared on every token, expanded or not. The expansion has
595
+ // already happened here, with the mask; a later reader that expanded again
596
+ // would be doing it without one, and `{.env","x}` -- whose comma is quoted,
597
+ // so the shell leaves the whole word alone -- came back as `.env`.
598
+ // `braceActive` is cleared on every token below, expanded or not.
599
+ return resolveExpandedTokens(segment, tokens, assignments, undefined);
600
+ }
601
+
602
+ /**
603
+ * The rest of the expansion order, applied to an already brace-expanded stream.
604
+ *
605
+ * The shell expands braces, then parameters, then command substitutions, and
606
+ * only then decides what the command is. This module did the first of those
607
+ * here and left the other two to whichever reader happened to want them, which
608
+ * meant one reader had them and eleven did not: `rm -rf $(printf /)` was
609
+ * refused because the destructive check resolved its own target, while
610
+ * `r$(printf %s m) -rf /` -- the same removal with the name assembled the same
611
+ * way -- was permitted by every check in the file, and `echo .env | xa$(printf
612
+ * %s rgs) cat` opened a protected file because the consumer's name was read as
613
+ * written. Resolving here means a reader cannot be left behind: there is one
614
+ * word list, and it is the one the shell would build.
615
+ *
616
+ * A word is resolved only where resolving it is faithful. Single quotes suspend
617
+ * substitution, a body this cannot read exactly gives up rather than guess, and
618
+ * an unresolved word is passed through untouched with `resolved: false` so the
619
+ * checks that ask about it still see an expansion they could not evaluate.
620
+ *
621
+ * @param {string} segment
622
+ * @param {ReturnType<typeof shellWordTokens>} tokens
623
+ * @param {Map<string, string>} assignments
624
+ * @param {string | undefined} rawText text to keep when nothing resolves, so
625
+ * quoting survives for the matchers that compare against the command string
626
+ * @returns {{words: string[], text: string, braceText: string,
627
+ * tokens: ReturnType<typeof shellWordTokens>}}
628
+ */
629
+ function resolveExpandedTokens(segment, tokens, assignments, rawText) {
630
+ const substituting = segmentHasUnquotedSubstitution(segment);
631
+ const evaluable = substituting && substitutionsAreLiteral(segment);
632
+ // The text as it stands after brace expansion and before anything is
633
+ // resolved. The nested scan reads substitution bodies and interpreter
634
+ // payloads out of it, and resolving first takes them away: rewrite
635
+ // `printf data > $(printf %s .env)` to `printf data > .env` and there is no
636
+ // body left to descend into, and the descent is what saw the file.
637
+ const braceText = rawText ?? tokens.map((token) => token.value).join(" ");
638
+ let changed = false;
639
+ const resolved = tokens.map((token) => {
640
+ const base = { ...token, braceActive: false };
641
+ if (!token.variableActive) return { ...base, resolved: true };
642
+ const outcome = resolveSubstitutionTarget(token.value, assignments, evaluable);
643
+ if (outcome.value !== token.value) changed = true;
644
+ // The mask described the text as written; it does not describe this.
645
+ return { ...base, value: outcome.value, literalMask: undefined, resolved: outcome.resolved };
646
+ });
647
+ const words = resolved.map((token) => token.value);
648
+ return {
649
+ words,
650
+ text: !changed && rawText !== undefined ? rawText : words.join(" "),
651
+ braceText,
652
+ tokens: resolved,
653
+ // Reported rather than recomputed. A reader deciding for itself whether a
654
+ // substitution would run is a second rule that can disagree with this one,
655
+ // and disagreeing rules are what put the holes here in the first place.
656
+ evaluable
657
+ };
658
+ }
659
+
191
660
  export function arrayStartsWith(items, prefix) {
192
661
  return prefix.length > 0 && prefix.every((item, index) => items[index] === item);
193
662
  }
@@ -224,6 +693,19 @@ function isAssignment(word) {
224
693
  return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(word);
225
694
  }
226
695
 
696
+ /**
697
+ * Whether a word is a redirection rather than a command or an argument.
698
+ *
699
+ * Covers the separated form (`>`, `2>`, `>|`) and the attached one (`>x`,
700
+ * `2>>x`), which is why the pattern is anchored but not terminated.
701
+ *
702
+ * @param {string} word
703
+ * @returns {boolean}
704
+ */
705
+ function isLeadingRedirection(word) {
706
+ return /^[0-9]*(?:>>?\|?|<>?|>&|<&)/.test(word);
707
+ }
708
+
227
709
  function stripWrapper(words) {
228
710
  let current = [...words];
229
711
  let changed = true;
@@ -235,6 +717,18 @@ function stripWrapper(words) {
235
717
  changed = true;
236
718
  }
237
719
 
720
+ // A redirection may precede the command word, and the shell still runs the
721
+ // command. Leaving it in place made `>x rm -rf /` read as a command called
722
+ // ">x", so every rule that decides from the command word — the recursive
723
+ // removal check, the legacy patterns — saw something it had no opinion
724
+ // about and said nothing.
725
+ while (current.length > 0 && isLeadingRedirection(current[0])) {
726
+ // `> file` puts the target in the next word; `>file` carries its own.
727
+ const attached = /^[0-9]*(?:>>?\|?|<>?|>&|<&)./.test(current[0]);
728
+ current = current.slice(attached ? 1 : 2);
729
+ changed = true;
730
+ }
731
+
238
732
  const command = commandBasename(current[0] ?? "");
239
733
  if (command === "env") {
240
734
  current = current.slice(1);
@@ -258,9 +752,247 @@ function stripWrapper(words) {
258
752
  return current;
259
753
  }
260
754
 
261
- function rmFinding(words) {
755
+ // `$(printf /)` is `/`. The destructive checks read raw words, so an operand
756
+ // nothing had expanded was compared as the literal text `$(printf /)` and
757
+ // matched no catastrophic target -- while the shell ran the removal of `/`.
758
+ //
759
+ // Two answers, because the substitutions split cleanly in two. `printf` and
760
+ // `echo` with literal arguments are pure text and can be evaluated here, so
761
+ // those resolve and the existing refusal applies to the result. Anything else
762
+ // (`$(mktemp -d)`, `$(git rev-parse --show-toplevel)`) names a path only the
763
+ // shell can produce; refusing those outright would take a common idiom away,
764
+ // and allowing them silently is how the check above was walked around. They
765
+ // become a question instead.
766
+ const STATIC_TEXT_PRODUCERS = new Set(["printf", "echo"]);
767
+
768
+ /**
769
+ * Whether the segment contains a substitution the shell would actually run.
770
+ *
771
+ * Single quotes suspend it entirely, and that is lost by the time a word is
772
+ * tokenized: `rm -rf '$(printf /)'` removes a file with a strange name, and
773
+ * refusing it as a root removal is a refusal of something nobody asked for.
774
+ *
775
+ * @param {string} segment
776
+ * @returns {boolean}
777
+ */
778
+ function segmentHasUnquotedSubstitution(segment) {
779
+ if (typeof segment !== "string") return false;
780
+ let quote;
781
+ for (let index = 0; index < segment.length; index += 1) {
782
+ const char = segment[index];
783
+ if (char === "\\" && quote !== "'") {
784
+ index += 1;
785
+ continue;
786
+ }
787
+ if (quote) {
788
+ if (char === quote) quote = undefined;
789
+ else if (quote === '"' && (char === "`" || (char === "$" && segment[index + 1] === "("))) return true;
790
+ continue;
791
+ }
792
+ if (char === "'" || char === '"') {
793
+ quote = char;
794
+ continue;
795
+ }
796
+ if (char === "`" || (char === "$" && segment[index + 1] === "(")) return true;
797
+ }
798
+ return false;
799
+ }
800
+
801
+ /**
802
+ * Every command-substitution body in the segment, located by balance rather than
803
+ * by pattern.
804
+ *
805
+ * A regex for `$(...)` cannot see nesting: against `$(printf $(printf /))` it
806
+ * matches the inner half and reports a body of `printf /`, which is a true
807
+ * statement about a substitution nobody asked about. Scanning with the same
808
+ * balance rule the shell uses reports one body, `printf $(printf /)`, and that
809
+ * body is visibly not something this process should try to evaluate.
810
+ *
811
+ * It cannot currently change an outcome, and that is worth stating rather than
812
+ * leaving for the next reader to work out. Evaluation only ever succeeds for a
813
+ * body with no parentheses in it -- the replacement pattern requires that, and
814
+ * anything it leaves behind is caught as a substitution still present in the
815
+ * result. On a paren-free body a pattern scan and a balance scan agree. This is
816
+ * here so the extent of a body is right by construction rather than by that
817
+ * argument continuing to hold.
818
+ *
819
+ * An unterminated substitution yields the rest of the segment as its body. It
820
+ * needs no flag of its own: nothing replaces it, so it survives into the result
821
+ * and is caught there as text that is still a substitution.
822
+ *
823
+ * @param {string} segment
824
+ * @returns {string[]}
825
+ */
826
+ function substitutionBodies(segment) {
827
+ const bodies = [];
828
+ let quote;
829
+ for (let index = 0; index < segment.length; index += 1) {
830
+ // Single quotes suspend substitution, so text inside them is not a body and
831
+ // reading it as one made a whole segment unevaluable on the strength of a
832
+ // filename. `rm -rf $(printf /) '$(a;b)'` really does remove `/`, and the
833
+ // literal beside it was enough to drop the refusal to a question. Double
834
+ // quotes do not suspend it, so those are scanned through.
835
+ if (segment[index] === "\\" && quote !== "'") {
836
+ index += 1;
837
+ continue;
838
+ }
839
+ if (quote) {
840
+ if (segment[index] === quote) quote = undefined;
841
+ else if (quote === "'") continue;
842
+ } else if (segment[index] === "'" || segment[index] === '"') {
843
+ quote = segment[index];
844
+ continue;
845
+ }
846
+ if (quote === "'") continue;
847
+ if (segment[index] === "`") {
848
+ const end = segment.indexOf("`", index + 1);
849
+ if (end < 0) {
850
+ bodies.push(segment.slice(index + 1));
851
+ break;
852
+ }
853
+ bodies.push(segment.slice(index + 1, end));
854
+ index = end;
855
+ continue;
856
+ }
857
+ if (segment[index] === "$" && segment[index + 1] === "(") {
858
+ const found = findBalanced(segment, index + 1, "(", ")");
859
+ if (!found) {
860
+ bodies.push(segment.slice(index + 2));
861
+ break;
862
+ }
863
+ bodies.push(found.body);
864
+ index = found.end;
865
+ continue;
866
+ }
867
+ }
868
+ return bodies;
869
+ }
870
+
871
+ // What a substitution body may contain for this process to claim it knows the
872
+ // output: one plain command and its literal operands, nothing else.
873
+ //
874
+ // Everything excluded here is a way for a body to produce output that reading
875
+ // its first command does not describe. `;`, `&` and `|` put a second command
876
+ // after the one being read, so `$(printf /; echo)` runs `printf /` and the
877
+ // evaluation stops at a word that is not the output. A newline is another
878
+ // separator. `$(` and a backtick nest. `<` and `>` move the output somewhere
879
+ // else. A quote or a backslash is gone by the time the word is tokenized, so
880
+ // the body being read is no longer the body the shell had. `$` on its own is a
881
+ // value this cannot resolve.
882
+ const SIMPLE_SUBSTITUTION_BODY = /^[^'"\\`;&|<>()$\n\r]*$/;
883
+
884
+ /**
885
+ * Whether the substitutions in this segment can be evaluated here at all.
886
+ *
887
+ * Resolving to a wrong value and then permitting on it is worse than not
888
+ * resolving: `$(printf '\x2f')` reaches these checks as `$(printf x2f)`
889
+ * because quoting is applied before tokenizing, and `$(printf /; echo)` is `/`
890
+ * however little the first command says so. One body that this cannot read
891
+ * gives up the whole segment, because a target assembled from several of them
892
+ * is only as knowable as its least knowable part.
893
+ *
894
+ * @param {string} segment
895
+ * @returns {boolean}
896
+ */
897
+ function substitutionsAreLiteral(segment) {
898
+ if (typeof segment !== "string") return false;
899
+ return substitutionBodies(segment).every((body) => SIMPLE_SUBSTITUTION_BODY.test(body));
900
+ }
901
+
902
+ /**
903
+ * @param {string} word
904
+ * @param {Map<string, string>} assignments
905
+ * @param {boolean} evaluable
906
+ * @returns {{value: string, resolved: boolean}}
907
+ */
908
+ function resolveSubstitutionTarget(word, assignments, evaluable) {
909
+ const substitution = /\$\(([^()]*)\)|`([^`]*)`/g;
910
+ let resolved = true;
911
+ // Always expanded: a variable is resolved from text in this same command and
912
+ // has nothing to do with whether a substitution would run. A word whose `$(`
913
+ // is inside single quotes arrives with `evaluable` unset, so the replacement
914
+ // below leaves it exactly as written.
915
+ const value = expandKnownShellVariables(word, assignments).replace(substitution, (match, parenthesised, backticked) => {
916
+ const inner = (parenthesised ?? backticked ?? "").trim();
917
+ const producer = commandBasename(shellWords(inner)[0] ?? "");
918
+ if (!evaluable || !STATIC_TEXT_PRODUCERS.has(producer)) {
919
+ resolved = false;
920
+ return match;
921
+ }
922
+ const output = staticDataOutput(inner, assignments, producer);
923
+ // More than one word out of a substitution is more than one operand, and
924
+ // stitching them back into one is not what the shell does. `exact` is the
925
+ // other half: a format this renderer does not reproduce yields a value bash
926
+ // never printed, and permitting on that is the failure this whole path is
927
+ // here to avoid.
928
+ if (!output.exact || output.words.length !== 1) {
929
+ resolved = false;
930
+ return match;
931
+ }
932
+ return output.words[0];
933
+ });
934
+ // A substitution left in the result is one the replacement above did not
935
+ // reach -- a nested body, or one whose shape the scanner rejected. The value
936
+ // is not the target either way, so it is not a resolution.
937
+ if (/\$\(|`/.test(value)) return { value, resolved: false };
938
+ return { value, resolved };
939
+ }
940
+
941
+ const WHOLE_WORD_EXPANSION = /^\$\{[^{}]*\}$|^\$[A-Za-z_][A-Za-z0-9_]*$/;
942
+
943
+ /**
944
+ * True when the entire target is one parameter expansion that came out the
945
+ * other side unresolved.
946
+ *
947
+ * `${HOME:0:1}` is `/`, and substring expansion is not modelled here -- the
948
+ * operator group in `expandKnownShellVariables` is deliberately narrow, so the
949
+ * word stayed exactly as written and matched none of the catastrophic targets.
950
+ * Naming one more operator each time one is reported is the losing game this
951
+ * file already refuses to play, so the shape is what decides: a target that is
952
+ * nothing but an expansion can be *any* value, root included, and a recursive
953
+ * removal of an unknown value is worth a question.
954
+ *
955
+ * Only the whole word counts. `$TMPDIR/build` cannot become root however
956
+ * `TMPDIR` resolves, and refusing it would stop ordinary work for nothing.
957
+ *
958
+ * @param {string} word
959
+ * @param {Map<string, string>} assignments
960
+ * @returns {boolean}
961
+ */
962
+ function isUnresolvedWholeWordExpansion(word, assignments) {
963
+ if (!WHOLE_WORD_EXPANSION.test(word)) return false;
964
+ return WHOLE_WORD_EXPANSION.test(expandKnownShellVariables(word, assignments));
965
+ }
966
+
967
+ /** True when the word carries a substitution this process cannot evaluate. */
968
+ function hasUnresolvedSubstitution(word, assignments, evaluable) {
969
+ if (!/\$\(|`/.test(word)) return false;
970
+ return !resolveSubstitutionTarget(word, assignments, evaluable).resolved;
971
+ }
972
+
973
+ /**
974
+ * True when the command name is still being computed when the shell runs it.
975
+ *
976
+ * The word arrives here after every expansion this module can perform, so an
977
+ * expansion still in it is one nobody can evaluate -- and a name nobody can
978
+ * evaluate could be any command at all, including the one being looked for.
979
+ *
980
+ * Testing for a leading `$` was the old reading, and it only caught a name that
981
+ * was *entirely* an expansion. A name with one literal character in front of it
982
+ * ducked underneath: `r${X:+m} -rf /` and `$(echo -n r)${X:+m} -rf /` are both
983
+ * `rm -rf /` when the variable is set, and both read as an ordinary command
984
+ * called something other than `rm`.
985
+ *
986
+ * @param {string} name
987
+ * @returns {boolean}
988
+ */
989
+ function commandNameIsDynamic(name) {
990
+ return /\$\{|\$\(|`|\$[A-Za-z_]/.test(name);
991
+ }
992
+
993
+ function rmFinding(words, assignments, evaluable, substituting) {
262
994
  const command = commandBasename(words[0] ?? "");
263
- const dynamicCommand = command.startsWith("$");
995
+ const dynamicCommand = commandNameIsDynamic(command);
264
996
  if (command !== "rm" && !dynamicCommand) return undefined;
265
997
 
266
998
  let recursive = false;
@@ -284,18 +1016,77 @@ function rmFinding(words) {
284
1016
  targets.push(word);
285
1017
  }
286
1018
 
287
- if (targets.some(isRootOrHomeTarget) && (recursive || force || dynamicCommand)) {
288
- return "Refusing recursive/forced removal of root or home target.";
1019
+ const resolvedTargets = targets.map((target) => resolveSubstitutionTarget(target, assignments, evaluable));
1020
+ if (resolvedTargets.some((target) => isRootOrHomeTarget(target.value)) && (recursive || force || dynamicCommand)) {
1021
+ return { action: "forbid", reason: "Refusing recursive/forced removal of root or home target." };
1022
+ }
1023
+ const opaque = [...new Set([
1024
+ ...(substituting ? targets.filter((target) => hasUnresolvedSubstitution(target, assignments, evaluable)) : []),
1025
+ ...targets.filter((target) => isUnresolvedWholeWordExpansion(target, assignments))
1026
+ ])];
1027
+ if (opaque.length > 0 && (recursive || force)) {
1028
+ return {
1029
+ action: "prompt",
1030
+ reason: `Recursive/forced rm targets a path this policy cannot resolve: ${opaque.join(", ")}. `
1031
+ + "Its value is produced at run time, so what would be deleted is not knowable here. "
1032
+ + "Confirm, or run the substitution first and pass the result."
1033
+ };
289
1034
  }
290
1035
  return undefined;
291
1036
  }
292
1037
 
293
- function findDeleteFinding(words) {
294
- if (commandBasename(words[0] ?? "") !== "find") return undefined;
1038
+ function findDeleteFinding(words, assignments, evaluable, substituting) {
1039
+ const command = commandBasename(words[0] ?? "");
1040
+ // `-delete` with a root operand is the whole shape; a name still being
1041
+ // computed does not make it something else.
1042
+ if (command !== "find" && !commandNameIsDynamic(command)) return undefined;
295
1043
  if (!words.includes("-delete")) return undefined;
296
- const firstTarget = words.slice(1).find((word) => !word.startsWith("-"));
297
- if (firstTarget && isRootOrHomeTarget(firstTarget)) {
298
- return "Refusing find -delete against root or home target.";
1044
+ // `find` walks every path it is given before the expression starts, so all of
1045
+ // the leading operands are starting points. Reading only the first one meant
1046
+ // `find fi / -delete` was judged on `fi` -- and brace expansion produces
1047
+ // exactly that shape, since `fi{nd,} / -delete` is `find fi / -delete` by the
1048
+ // time the shell runs it.
1049
+ // `find` takes its own options before the paths start: `-H`, `-L`, `-P`,
1050
+ // `-O<level>`, `-D <opts>`, and `--`. Stopping at the first `-` meant
1051
+ // `find -H / -delete` had no target at all and passed with nothing read.
1052
+ const targets = [];
1053
+ let index = 1;
1054
+ while (index < words.length) {
1055
+ const word = words[index];
1056
+ if (word === "--") {
1057
+ index += 1;
1058
+ break;
1059
+ }
1060
+ if (/^-[HLP]$/.test(word) || /^-O\d*$/.test(word)) {
1061
+ index += 1;
1062
+ if (word === "-O" && /^\d+$/.test(words[index] ?? "")) index += 1;
1063
+ continue;
1064
+ }
1065
+ if (word === "-D") {
1066
+ index += 2;
1067
+ continue;
1068
+ }
1069
+ break;
1070
+ }
1071
+ for (; index < words.length; index += 1) {
1072
+ if (words[index].startsWith("-")) break;
1073
+ targets.push(words[index]);
1074
+ }
1075
+ if (targets.length === 0) return undefined;
1076
+ const resolvedTargets = targets.map((target) => resolveSubstitutionTarget(target, assignments, evaluable));
1077
+ if (resolvedTargets.some((target) => isRootOrHomeTarget(target.value))) {
1078
+ return { action: "forbid", reason: "Refusing find -delete against root or home target." };
1079
+ }
1080
+ const opaque = targets.filter((target) =>
1081
+ (substituting && hasUnresolvedSubstitution(target, assignments, evaluable))
1082
+ || isUnresolvedWholeWordExpansion(target, assignments));
1083
+ if (opaque.length > 0) {
1084
+ return {
1085
+ action: "prompt",
1086
+ reason: `find -delete starts from a path this policy cannot resolve: ${opaque.join(", ")}. `
1087
+ + "Its value is produced at run time, so what would be deleted is not knowable here. "
1088
+ + "Confirm, or run the substitution first and pass the result."
1089
+ };
299
1090
  }
300
1091
  return undefined;
301
1092
  }
@@ -303,14 +1094,30 @@ function findDeleteFinding(words) {
303
1094
  function ddFinding(words) {
304
1095
  if (commandBasename(words[0] ?? "") !== "dd") return undefined;
305
1096
  const out = words.find((word) => /^of=\/dev\/(?:sd[a-z]\d*|hd[a-z]\d*|xvd[a-z]\d*|nvme\d+n\d+(?:p\d+)?|disk\d+|rdisk\d+|mapper\/.+)$/i.test(word));
306
- return out ? `Refusing dd write to block device ${out}.` : undefined;
1097
+ return out ? { action: "forbid", reason: `Refusing dd write to block device ${out}.` } : undefined;
307
1098
  }
308
1099
 
309
- function semanticCommandFindings(words) {
1100
+ /**
1101
+ * @param {string[]} words
1102
+ * @param {Map<string, string>} assignments
1103
+ * @param {string} segment
1104
+ * @returns {{action: "forbid"|"prompt", reason: string}[]}
1105
+ */
1106
+ function semanticCommandFindings(words, assignments, segment) {
1107
+ // A word arrives here with its quotes already removed, so `'$(printf /)'` and
1108
+ // `$(printf /)` are the same three-character-longer string -- and the first
1109
+ // one is a filename with an awkward name, not a substitution. Whether the
1110
+ // segment contains one the shell would actually run is the part only the raw
1111
+ // text still knows.
1112
+ const substituting = segmentHasUnquotedSubstitution(segment);
1113
+ const evaluable = substituting && substitutionsAreLiteral(segment);
1114
+ // `words` arrives brace-expanded from `expandedSegment` -- the shell expands
1115
+ // before it decides what the command is, so that has to happen before any
1116
+ // reader here, not inside one of them.
310
1117
  const normalizedWords = stripWrapper(words);
311
1118
  return [
312
- rmFinding(normalizedWords),
313
- findDeleteFinding(normalizedWords),
1119
+ rmFinding(normalizedWords, assignments, evaluable, substituting),
1120
+ findDeleteFinding(normalizedWords, assignments, evaluable, substituting),
314
1121
  ddFinding(normalizedWords),
315
1122
  xargsFinding(normalizedWords)
316
1123
  ].filter(Boolean);
@@ -321,14 +1128,17 @@ function xargsFinding(words) {
321
1128
  const commandIndex = words.findIndex((word, index) => index > 0 && !word.startsWith("-"));
322
1129
  if (commandIndex < 0) return undefined;
323
1130
  const nested = words.slice(commandIndex);
324
- if (commandBasename(nested[0] ?? "") !== "rm") return undefined;
1131
+ const nestedName = commandBasename(nested[0] ?? "");
1132
+ if (nestedName !== "rm" && !commandNameIsDynamic(nestedName)) return undefined;
325
1133
  const hasRecursiveOrForce = nested.slice(1).some((word) => {
326
1134
  if (word === "--recursive" || word === "--force") return true;
327
1135
  if (!word.startsWith("-") || word.startsWith("--")) return false;
328
1136
  const chars = flagChars(word);
329
1137
  return chars.includes("r") || chars.includes("R") || chars.includes("f");
330
1138
  });
331
- return hasRecursiveOrForce ? "Refusing xargs with recursive/forced rm; input target is not visible to policy." : undefined;
1139
+ return hasRecursiveOrForce
1140
+ ? { action: "forbid", reason: "Refusing xargs with recursive/forced rm; input target is not visible to policy." }
1141
+ : undefined;
332
1142
  }
333
1143
 
334
1144
  function legacyPatternMatchesSegment(pattern, words) {
@@ -336,6 +1146,18 @@ function legacyPatternMatchesSegment(pattern, words) {
336
1146
  return arrayStartsWith(words, patternWords);
337
1147
  }
338
1148
 
1149
+ // How far a command may nest interpreters before the policy stops reading, and
1150
+ // how many segments it may expand to in total.
1151
+ //
1152
+ // The previous limit was four, and stopping there meant *not looking* rather
1153
+ // than *refusing*: a payload wrapped in five layers of `bash -c` was inspected
1154
+ // at every level the walk reached and then permitted on the strength of a level
1155
+ // it never opened. The depth now goes far past anything a person writes by hand,
1156
+ // and reaching either limit is itself grounds to refuse, so the answer to a
1157
+ // command too convoluted to read is no rather than silence.
1158
+ const MAX_NESTED_DEPTH = 16;
1159
+ const MAX_INSPECTED_SEGMENTS = 512;
1160
+
339
1161
  export function evaluateExecPolicyCore(command, options) {
340
1162
  const policy = options.policy ?? {};
341
1163
  const mode = options.mode ?? policy.execPolicy?.defaultMode ?? "enforce";
@@ -346,10 +1168,16 @@ export function evaluateExecPolicyCore(command, options) {
346
1168
  const reasons = [];
347
1169
  const pending = splitShellSegments(command).map((segment) => ({ segment, depth: 0 }));
348
1170
  const segments = [];
1171
+ // Carried across segments so `D=/; rm -rf "$D"` resolves the same way the
1172
+ // path checks already resolve it.
1173
+ const assignments = new Map();
349
1174
 
350
1175
  while (pending.length > 0) {
351
1176
  const { segment, depth } = pending.shift();
352
- const words = shellWords(segment);
1177
+ // Expanded once here, so every reader below is looking at the word list the
1178
+ // shell would build rather than at the text the author typed.
1179
+ const { words, text, braceText } = expandedSegment(segment, assignments);
1180
+ rememberLeadingShellAssignments(words, assignments);
353
1181
  const matches = [];
354
1182
  const warnings = [];
355
1183
 
@@ -360,15 +1188,15 @@ export function evaluateExecPolicyCore(command, options) {
360
1188
  }
361
1189
 
362
1190
  for (const rule of execPolicy.rules) {
363
- if (!commandRuleMatches(rule, segment, words)) continue;
1191
+ if (!commandRuleMatches(rule, text, words)) continue;
364
1192
  matches.push(`${rule.action}:${rule.id}`);
365
1193
  if (rule.action === "forbid") reasons.push(`Forbidden by exec policy ${rule.id}: ${rule.reason}`);
366
1194
  if (rule.action === "prompt") reasons.push(`Prompt required by exec policy ${rule.id}: ${rule.reason}`);
367
1195
  }
368
1196
 
369
- for (const finding of semanticCommandFindings(words)) {
370
- matches.push("forbid:semantic-shell-safety");
371
- reasons.push(finding);
1197
+ for (const finding of semanticCommandFindings(words, assignments, segment)) {
1198
+ matches.push(`${finding.action}:semantic-shell-safety`);
1199
+ reasons.push(finding.reason);
372
1200
  }
373
1201
 
374
1202
  for (const pattern of policy.blockedCommandPatterns ?? []) {
@@ -377,17 +1205,33 @@ export function evaluateExecPolicyCore(command, options) {
377
1205
  reasons.push(`Blocked by legacy policy pattern: ${pattern}`);
378
1206
  }
379
1207
 
380
- segments.push({ command: segment, words, matches, warnings });
381
- if (depth < 4) {
382
- for (const nestedCommand of extractNestedCommands(segment, words)) {
1208
+ const nested = extractNestedCommands(braceText, words);
1209
+ if (nested.length > 0 && depth >= MAX_NESTED_DEPTH) {
1210
+ matches.push("forbid:nesting-depth");
1211
+ reasons.push(
1212
+ `Forbidden by exec policy nesting-depth: interpreters nest more than ${MAX_NESTED_DEPTH} levels deep, ` +
1213
+ "past the point this policy can read the command. Run it without wrapping it."
1214
+ );
1215
+ } else if (segments.length >= MAX_INSPECTED_SEGMENTS) {
1216
+ matches.push("forbid:nesting-breadth");
1217
+ reasons.push(
1218
+ `Forbidden by exec policy nesting-breadth: the command expands past ${MAX_INSPECTED_SEGMENTS} segments, ` +
1219
+ "past the point this policy can read it. Split it up."
1220
+ );
1221
+ } else {
1222
+ for (const nestedCommand of nested) {
383
1223
  for (const nestedSegment of splitShellSegments(nestedCommand)) {
384
1224
  pending.push({ segment: nestedSegment, depth: depth + 1 });
385
1225
  }
386
1226
  }
387
1227
  }
1228
+
1229
+ segments.push({ command: segment, expanded: text, words, matches, warnings });
388
1230
  }
389
1231
 
390
- const normalizedCommand = command.toLowerCase();
1232
+ // The expanded text of every segment as well as the raw command, so a pattern
1233
+ // written as `git push` still matches when the author typed `git p{ush,}`.
1234
+ const normalizedCommand = [command, ...segments.map((entry) => entry.expanded)].join("\n").toLowerCase();
391
1235
  for (const pattern of policy.requireConfirmationPatterns ?? []) {
392
1236
  if (normalizedCommand.includes(String(pattern).toLowerCase())) {
393
1237
  reasons.push(`Confirmation required by legacy policy pattern: ${pattern}`);
@@ -395,8 +1239,16 @@ export function evaluateExecPolicyCore(command, options) {
395
1239
  }
396
1240
  }
397
1241
 
398
- const hasForbid = reasons.some((reason) => reason.startsWith("Forbidden") || reason.startsWith("Blocked") || reason.startsWith("Refusing"));
399
- const hasPrompt = reasons.some((reason) => reason.startsWith("Prompt") || reason.startsWith("Confirmation"));
1242
+ // The action a check decided, not how its sentence happens to start. Reading
1243
+ // the prose meant a finding was only as strong as its first word: a new one
1244
+ // whose reason did not begin with "Refusing" or "Prompt" was recorded, shown,
1245
+ // and then counted as `allow`. The legacy confirmation patterns below push a
1246
+ // reason without a match, so the prose test stays as well.
1247
+ const actions = segments.flatMap((entry) => entry.matches);
1248
+ const hasForbid = actions.some((action) => action.startsWith("forbid:"))
1249
+ || reasons.some((reason) => reason.startsWith("Forbidden") || reason.startsWith("Blocked") || reason.startsWith("Refusing"));
1250
+ const hasPrompt = actions.some((action) => action.startsWith("prompt:"))
1251
+ || reasons.some((reason) => reason.startsWith("Prompt") || reason.startsWith("Confirmation"));
400
1252
  return {
401
1253
  mode,
402
1254
  decision: mode === "off" ? "allow" : hasForbid ? "forbid" : hasPrompt ? "prompt" : "allow",
@@ -440,16 +1292,31 @@ function findBalanced(text, startIndex, openChar, closeChar) {
440
1292
  function shellCPayloads(words) {
441
1293
  const normalizedWords = stripWrapper(words);
442
1294
  const rootCommand = commandBasename(normalizedWords[0] ?? "");
443
- const carrier = SHELL_COMMANDS.has(rootCommand) || rootCommand === "xargs" || rootCommand === "find";
1295
+ // A name still being computed when the shell runs it could be an interpreter,
1296
+ // and `${X:+b}ash -c 'cat .env'` reads the file whenever the variable is set.
1297
+ // The payload beside a `-c` is a script under that reading, so it is scanned
1298
+ // rather than skipped on the strength of a name nobody can evaluate.
1299
+ const carrier = SHELL_COMMANDS.has(rootCommand) || rootCommand === "xargs" || rootCommand === "find"
1300
+ || commandNameIsDynamic(rootCommand);
444
1301
  if (!carrier) return [];
445
1302
 
446
1303
  const payloads = [];
447
1304
  for (let commandIndex = 0; commandIndex < normalizedWords.length; commandIndex += 1) {
448
- if (!SHELL_COMMANDS.has(commandBasename(normalizedWords[commandIndex] ?? ""))) continue;
1305
+ const name = commandBasename(normalizedWords[commandIndex] ?? "");
1306
+ if (!SHELL_COMMANDS.has(name) && !commandNameIsDynamic(name)) continue;
449
1307
  for (let index = commandIndex + 1; index < normalizedWords.length - 1; index += 1) {
450
1308
  const word = normalizedWords[index];
451
1309
  if (word === "-c" || (/^-[A-Za-z]+$/.test(word) && word.includes("c"))) {
452
- payloads.push(normalizedWords[index + 1]);
1310
+ // A lone `-` or a `--` between `-c` and the script ends the option list
1311
+ // without being the option's argument, so the payload is the word after
1312
+ // them: `bash -c - 'rm -rf /'` runs the removal, and reading `-` as the
1313
+ // script found nothing to inspect. Brace expansion produces exactly
1314
+ // this shape, since `bash -{c,} '...'` is `bash -c - '...'`.
1315
+ let payloadIndex = index + 1;
1316
+ while (normalizedWords[payloadIndex] === "-" || normalizedWords[payloadIndex] === "--") {
1317
+ payloadIndex += 1;
1318
+ }
1319
+ if (payloadIndex < normalizedWords.length) payloads.push(normalizedWords[payloadIndex]);
453
1320
  break;
454
1321
  }
455
1322
  }
@@ -547,14 +1414,40 @@ function isFilesystemArgument(word) {
547
1414
  if (!word || word === "-") return false;
548
1415
  if (word.startsWith("http://") || word.startsWith("https://")) return false;
549
1416
  if (PATH_REDIRECT_OPERATORS.has(word)) return false;
550
- if (isAssignment(word)) return false;
551
1417
  return isPathLike(word) || !word.startsWith("-");
552
1418
  }
553
1419
 
1420
+ /**
1421
+ * The path inside a `key=value` operand.
1422
+ *
1423
+ * The shell treats `NAME=VALUE` as an assignment only before the command word;
1424
+ * after it, the word is an operand and the command decides what it means. This
1425
+ * is only ever asked about words after the command word, so rejecting them all
1426
+ * as assignments hid `dd if=.env` and `dd of=.env` — which `ddFinding` already
1427
+ * parses to catch block-device writes, so the module knew and never said.
1428
+ *
1429
+ * @param {string} word
1430
+ * @returns {string|undefined}
1431
+ */
1432
+ function operandValuePath(word) {
1433
+ const match = word.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/);
1434
+ if (!match) return undefined;
1435
+ const value = match[2];
1436
+ if (value.startsWith("http://") || value.startsWith("https://")) return undefined;
1437
+ return value;
1438
+ }
1439
+
554
1440
  function rememberLeadingShellAssignments(words, assignments) {
555
1441
  const remember = (word) => {
556
1442
  const match = word.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
557
- if (match) assignments.set(match[1], expandKnownShellVariables(match[2], assignments));
1443
+ if (!match) return;
1444
+ // `a=(.env)` is an array literal, and storing it whole left the parentheses
1445
+ // glued to the value so `${a[@]}` resolved to a name no file has. Every
1446
+ // element is a path this command may reach, so the whole set is kept and any
1447
+ // subscript resolves to all of it — matching one of them is enough to refuse.
1448
+ const array = match[2].match(/^\((.*)\)$/);
1449
+ const value = array ? array[1].trim() : match[2];
1450
+ assignments.set(match[1], expandKnownShellVariables(value, assignments));
558
1451
  };
559
1452
  let start = 0;
560
1453
  while (start < words.length && isAssignment(words[start])) start += 1;
@@ -566,16 +1459,86 @@ function rememberLeadingShellAssignments(words, assignments) {
566
1459
  for (const word of words.slice(start + 1)) remember(word);
567
1460
  }
568
1461
 
1462
+ /**
1463
+ * Applies one parameter-expansion operator to a value the assignments already
1464
+ * resolved.
1465
+ *
1466
+ * These are the operators that rewrite a known value into a different string.
1467
+ * Without them `V=q.env; cat ${V#q}` produced the candidate `${V#q}`, which
1468
+ * matches no protected pattern while the shell opens `.env`. Patterns are
1469
+ * treated as literals rather than as shell globs: matching less is safe here,
1470
+ * because a word this cannot resolve is refused by `unresolvedPathExpansions`
1471
+ * rather than assumed harmless.
1472
+ *
1473
+ * @param {string} operator the characters between the name and the argument
1474
+ * @param {string} argument
1475
+ * @param {string} value the resolved value of the variable
1476
+ * @returns {string|undefined} the rewritten value, or undefined when this
1477
+ * operator is not modelled
1478
+ */
1479
+ function applyParameterOperator(operator, argument, value) {
1480
+ switch (operator) {
1481
+ case ":+":
1482
+ case "+":
1483
+ return value ? argument : "";
1484
+ case "#":
1485
+ return value.startsWith(argument) ? value.slice(argument.length) : value;
1486
+ case "##": {
1487
+ return value.startsWith(argument) ? value.slice(argument.length) : value;
1488
+ }
1489
+ case "%":
1490
+ case "%%":
1491
+ return value.endsWith(argument) ? value.slice(0, -argument.length || undefined) : value;
1492
+ default:
1493
+ return undefined;
1494
+ }
1495
+ }
1496
+
569
1497
  function expandKnownShellVariables(value, assignments, resolving = new Set(), depth = 0) {
570
1498
  if (depth >= 8) return String(value ?? "");
571
- return String(value ?? "").replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (match, braced, plain) => {
572
- const name = braced ?? plain;
573
- const resolved = assignments.get(name);
574
- if (resolved === undefined || resolving.has(name)) return match;
575
- const nextResolving = new Set(resolving);
576
- nextResolving.add(name);
577
- return expandKnownShellVariables(resolved, assignments, nextResolving, depth + 1);
578
- });
1499
+ return String(value ?? "").replace(
1500
+ // Three shapes: `${NAME<op><arg>}`, `${NAME}` with the `:-`/`:=` default,
1501
+ // and bare `$NAME`. The operator group is deliberately narrow — anything it
1502
+ // does not name stays unexpanded, and unexpanded is what gets refused.
1503
+ /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?(?:(:?[-=+]|#{1,2}|%{1,2}|\/{1,2})([^{}]*))?\}|([A-Za-z_][A-Za-z0-9_]*))/g,
1504
+ (match, braced, operator, argument, plain) => {
1505
+ const name = braced ?? plain;
1506
+ const resolved = assignments.get(name);
1507
+ // The value is recursed with `name` marked so `F=$G; G=$F` terminates. An
1508
+ // operator's argument is separate text that merely mentions the same name,
1509
+ // so it keeps the outer set -- `${V:+$V}` is the value, not a cycle.
1510
+ const recur = (next) => expandKnownShellVariables(next, assignments, resolving, depth + 1);
1511
+
1512
+ if (resolved === undefined || resolving.has(name)) {
1513
+ // `${VAR:-default}` states what it becomes when the variable is unset,
1514
+ // which is the usual case here since nothing in the segment assigned it.
1515
+ // Leaving the expression whole meant `cat .${X:-env}` produced the
1516
+ // candidate `.${X:-env}` — the one form that says where it points was
1517
+ // the one form nothing read.
1518
+ if (operator === ":-" || operator === "-" || operator === ":=" || operator === "=") {
1519
+ return expandKnownShellVariables(argument, assignments, resolving, depth + 1);
1520
+ }
1521
+ return match;
1522
+ }
1523
+
1524
+ const expanded = expandKnownShellVariables(resolved, assignments, new Set([...resolving, name]), depth + 1);
1525
+ if (operator === undefined) return expanded;
1526
+ if (operator === ":-" || operator === "-" || operator === ":=" || operator === "=") return expanded;
1527
+
1528
+ if (operator === "/" || operator === "//") {
1529
+ const separator = argument.indexOf("/");
1530
+ const pattern = separator >= 0 ? argument.slice(0, separator) : argument;
1531
+ const replacement = separator >= 0 ? argument.slice(separator + 1) : "";
1532
+ if (!pattern) return expanded;
1533
+ return operator === "//"
1534
+ ? expanded.split(pattern).join(recur(replacement))
1535
+ : expanded.replace(pattern, recur(replacement));
1536
+ }
1537
+
1538
+ const applied = applyParameterOperator(operator, recur(argument), expanded);
1539
+ return applied === undefined ? match : applied;
1540
+ }
1541
+ );
579
1542
  }
580
1543
 
581
1544
  function executableArgumentTokens(tokens, words, commandName) {
@@ -651,10 +1614,14 @@ function shellDataTokens(segment) {
651
1614
  let quote;
652
1615
  let tokenStarted = false;
653
1616
  let variableActive = false;
1617
+ // Same per-character flag the word tokenizer carries, for the same reason:
1618
+ // the producer's arguments can come out of the brace word that names it.
1619
+ let literalMask = "";
654
1620
  const flush = () => {
655
1621
  if (!tokenStarted) return;
656
- tokens.push({ value: current, variableActive });
1622
+ tokens.push({ value: current, variableActive, literalMask });
657
1623
  current = "";
1624
+ literalMask = "";
658
1625
  tokenStarted = false;
659
1626
  variableActive = false;
660
1627
  };
@@ -665,7 +1632,10 @@ function shellDataTokens(segment) {
665
1632
  if (quote === "'") {
666
1633
  tokenStarted = true;
667
1634
  if (char === "'") quote = undefined;
668
- else current += char;
1635
+ else {
1636
+ current += char;
1637
+ literalMask += "1";
1638
+ }
669
1639
  continue;
670
1640
  }
671
1641
  if (quote === '"') {
@@ -674,10 +1644,12 @@ function shellDataTokens(segment) {
674
1644
  quote = undefined;
675
1645
  } else if (char === "\\" && next !== undefined && /[$`"\\\n]/.test(next)) {
676
1646
  current += next;
1647
+ literalMask += "1";
677
1648
  index += 1;
678
1649
  } else {
679
1650
  if (char === "$" && next !== undefined) variableActive = true;
680
1651
  current += char;
1652
+ literalMask += "1";
681
1653
  }
682
1654
  continue;
683
1655
  }
@@ -692,44 +1664,187 @@ function shellDataTokens(segment) {
692
1664
  }
693
1665
  if (char === "\\" && next !== undefined) {
694
1666
  current += next;
1667
+ literalMask += "1";
695
1668
  index += 1;
696
1669
  continue;
697
1670
  }
698
1671
  if (char === "$" && next !== undefined) variableActive = true;
699
1672
  current += char;
1673
+ literalMask += segment[index - 1] === "$" ? "1" : "0";
700
1674
  }
701
1675
  flush();
702
1676
  return tokens;
703
1677
  }
704
1678
 
1679
+ const PRINTF_CONVERSION_PARTS = /%(?:\d+\$)?([-+#0 ']*)(\d+|\*)?(?:\.(\d+|\*))?[hlL]?([bcdeEfgGiosuxXaA])/g;
1680
+
1681
+ /**
1682
+ * What `printf` writes, as closely as this can model it.
1683
+ *
1684
+ * Substituting the raw argument for every conversion was the whole of it
1685
+ * before, and that is not what the output looks like. A precision truncates
1686
+ * the argument and the rest of the format still prints, so `.e%.1sv` with `nv`
1687
+ * is `.env` rather than `.envv`; a width pads it; and bash reuses the format
1688
+ * while arguments remain, so `%s` with `.e` and `nv` is also `.env`. Each of
1689
+ * those assembled a name out of pieces that were individually harmless, which
1690
+ * is exactly the shape the path checks are looking for.
1691
+ *
1692
+ * @param {string} format
1693
+ * @param {string[]} values
1694
+ * @returns {string}
1695
+ */
705
1696
  function renderStaticPrintf(format, values) {
706
- let valueIndex = 0;
707
- let rendered = "";
708
1697
  const decodedFormat = decodeShellDataEscapes(format);
709
- const conversion = /%(?:\d+\$)?[-+#0 ']*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hlL]?[bcdeEfgGiosuxXaA]/g;
710
- let cursor = 0;
711
- for (const match of decodedFormat.matchAll(conversion)) {
712
- if (rendered.length > 8192 || valueIndex >= 32) break;
713
- const start = match.index ?? 0;
714
- rendered += decodedFormat.slice(cursor, start).replace(/%%/g, "%");
715
- const specifier = match[0].slice(-1);
716
- const argument = values[valueIndex] ?? "";
717
- rendered += specifier === "b" ? decodeShellDataEscapes(argument) : argument;
718
- valueIndex += 1;
719
- cursor = start + match[0].length;
1698
+ let rendered = "";
1699
+ let valueIndex = 0;
1700
+ let passes = 0;
1701
+ // A format with no conversion prints once and ignores the rest, so the reuse
1702
+ // below has to stop on the pass that consumed nothing.
1703
+ let consumed = true;
1704
+ while (consumed && passes < 32 && rendered.length <= 8192 && valueIndex < 32) {
1705
+ let cursor = 0;
1706
+ consumed = false;
1707
+ for (const match of decodedFormat.matchAll(PRINTF_CONVERSION_PARTS)) {
1708
+ if (rendered.length > 8192 || valueIndex >= 32) break;
1709
+ const [whole, flags, width, precision, specifier] = match;
1710
+ const start = match.index ?? 0;
1711
+ rendered += decodedFormat.slice(cursor, start).replace(/%%/g, "%");
1712
+ cursor = start + whole.length;
1713
+ // `*` takes its width or precision from the argument list, which is why
1714
+ // `%*s 0 /` prints `/` and not the zero. Consuming it for a *precision*
1715
+ // decides which argument gets truncated, and that changes the name
1716
+ // (`%.*s 4 .envXX` is `.env`). Consuming it for a *width* changes no
1717
+ // answer any check here asks: padding only inserts spaces, so it can
1718
+ // split a name into words but never spell one, and the argument it would
1719
+ // otherwise mistake for the value is a candidate in its own right. It is
1720
+ // consumed anyway, so the rendering stays what bash prints rather than
1721
+ // being right for the reason nobody is looking.
1722
+ const widthValue = width === "*" ? values[valueIndex++] : width;
1723
+ const precisionValue = precision === "*" ? values[valueIndex++] : precision;
1724
+ const argument = values[valueIndex] ?? "";
1725
+ valueIndex += 1;
1726
+ consumed = true;
1727
+ let text = specifier === "b" ? decodeShellDataEscapes(argument) : argument;
1728
+ if (precisionValue !== undefined && /^\d+$/.test(String(precisionValue))) {
1729
+ text = text.slice(0, Number(precisionValue));
1730
+ }
1731
+ const pad = Number(widthValue);
1732
+ if (Number.isFinite(pad) && pad > text.length) {
1733
+ text = flags.includes("-") ? text.padEnd(pad) : text.padStart(pad);
1734
+ }
1735
+ rendered += text;
1736
+ }
1737
+ rendered += decodedFormat.slice(cursor).replace(/%%/g, "%");
1738
+ passes += 1;
1739
+ if (valueIndex >= values.length) break;
720
1740
  }
721
- rendered += decodedFormat.slice(cursor).replace(/%%/g, "%");
722
1741
  return rendered.slice(0, 8192);
723
1742
  }
724
1743
 
725
- function staticDataOutputCandidates(segment, assignments, producerCommand) {
726
- const tokens = shellDataTokens(segment);
727
- const commandIndex = tokens.findIndex((token) => commandBasename(token.value) === producerCommand);
728
- if (commandIndex < 0) return [];
1744
+ // A string conversion with a constant width and precision, which is the whole
1745
+ // of what the renderer above reproduces byte for byte.
1746
+ const EXACT_PRINTF_CONVERSION = /%-?\d*(?:\.\d+)?s/g;
1747
+
1748
+ // Whether this renderer reproduces what bash prints for this format.
1749
+ //
1750
+ // This is the gate on *refusing* a destructive target, so it answers a stricter
1751
+ // question than the rendering does. String conversions with a constant width or
1752
+ // precision are reproduced exactly, and so is format reuse, so a target built
1753
+ // out of those is refused rather than asked about: `$(printf %.0s/ x)` is `/`
1754
+ // and `$(printf %s / /)` is `//` in any shell.
1755
+ //
1756
+ // `*` stays outside it even though the renderer now follows one, because the
1757
+ // width it consumes has its own rules -- a negative one left-aligns -- and a
1758
+ // modelling slip there turns into a refusal of something nobody asked for. `%q`
1759
+ // requotes, `%b` decodes escapes, every numeric conversion reformats, and `%%`
1760
+ // collapses after substitution here rather than before. None of those is
1761
+ // claimed; a target built out of them is confirmed instead, which costs a
1762
+ // question rather than a wrong answer.
1763
+ function printfFormatIsExact(format) {
1764
+ if (!format.includes("%")) return true;
1765
+ const withoutStringConversions = format.replace(EXACT_PRINTF_CONVERSION, "");
1766
+ // Any `%` the string conversions did not account for is a form this does not
1767
+ // reproduce.
1768
+ if (withoutStringConversions.includes("%")) return false;
1769
+ return withoutStringConversions.length !== format.length;
1770
+ }
1771
+
1772
+ const PRINTF_CONVERSION = /%(?:\d+\$)?[-+#0 ']*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hlL]?[A-Za-z]/g;
1773
+
1774
+ /**
1775
+ * The format's own literal text, with the conversions taken out and what is left
1776
+ * closed up.
1777
+ *
1778
+ * Closed up rather than split, because a conversion that prints nothing lets the
1779
+ * text on either side meet: `.e%.0snv` is `.env`, and splitting there produced
1780
+ * `.e` and `nv`, neither of which is a file anybody protects, while the command
1781
+ * opened one that is.
1782
+ *
1783
+ * Splitting as well adds nothing this misses: a protected path the output
1784
+ * carries in literal text alone is contiguous here too, and one that spans an
1785
+ * argument is covered by the arguments, which are candidates in their own
1786
+ * right.
1787
+ *
1788
+ * This does name files a command sometimes does not open -- `%s.env` with `x`
1789
+ * prints `x.env`, and `.env` is offered anyway. That is the accepted direction:
1790
+ * the same format with an empty argument prints `.env` exactly, and which one
1791
+ * it is cannot be known from the format. A candidate that never materialises
1792
+ * costs one command refused with the reason on screen; a path that materialises
1793
+ * with no candidate costs the check.
1794
+ *
1795
+ * @param {string} format
1796
+ * @returns {string[]}
1797
+ */
1798
+ function printfFormatLiterals(format) {
1799
+ return [format.replace(PRINTF_CONVERSION, "")].filter(Boolean);
1800
+ }
1801
+
1802
+ /**
1803
+ * What a `printf` or `echo` prints, as far as this process can tell.
1804
+ *
1805
+ * `words` is what it prints and nothing else, so a caller deciding whether to
1806
+ * permit a command can only use it when `exact` is set. `candidates` is wider on
1807
+ * purpose: it adds the format's own literal text and the raw arguments, because
1808
+ * a caller looking for a protected path wants every string that could reach the
1809
+ * output. `printf %.0s.env x` prints `.env`, and reading only the substituted
1810
+ * argument returned `x.env` -- a name matching no protected pattern, which is
1811
+ * the wrong direction for that check to be wrong in.
1812
+ *
1813
+ * @returns {{words: string[], candidates: string[], exact: boolean}}
1814
+ */
1815
+ /**
1816
+ * Where the producer sits in a word list, allowing for a name the shell
1817
+ * assembles. `{printf,} .env | xargs cat` runs `printf`, and matching the word
1818
+ * as written found no producer at all -- so the output was never rendered and
1819
+ * the file it names was never offered.
1820
+ *
1821
+ * @param {string[]} words
1822
+ * @param {string} producerCommand
1823
+ * @returns {number}
1824
+ */
1825
+ function producerCommandIndex(words, producerCommand) {
1826
+ return words.findIndex((word) => commandBasename(word) === producerCommand
1827
+ || expandShellBraces(word).some((entry) => commandBasename(entry.value) === producerCommand));
1828
+ }
1829
+
1830
+ function staticDataOutput(segment, assignments, producerCommand) {
1831
+ const empty = { words: [], candidates: [], exact: false };
1832
+ // Expanded before the producer is located, so an argument that comes out of
1833
+ // the same brace word as the name is an argument here too: `{printf,.e%.1sv}`
1834
+ // is `printf` plus the format it prints.
1835
+ const tokens = shellDataTokens(segment).flatMap((token) => {
1836
+ const expansions = expandShellBraces(token.value, token.literalMask).filter((entry) => entry.value);
1837
+ if (expansions.length === 1 && expansions[0].value === token.value) return [token];
1838
+ return expansions.map((entry) => ({ ...token, value: entry.value, literalMask: entry.mask }));
1839
+ });
1840
+ const commandIndex = producerCommandIndex(tokens.map((token) => token.value), producerCommand);
1841
+ if (commandIndex < 0) return empty;
729
1842
  const args = tokens.slice(commandIndex + 1).map((token) => token.variableActive
730
1843
  ? expandKnownShellVariables(token.value, assignments)
731
1844
  : token.value);
732
1845
  let output = "";
1846
+ let exact = false;
1847
+ const extra = [];
733
1848
  if (producerCommand === "echo") {
734
1849
  let argumentIndex = 0;
735
1850
  let escapes = false;
@@ -739,11 +1854,32 @@ function staticDataOutputCandidates(segment, assignments, producerCommand) {
739
1854
  argumentIndex += 1;
740
1855
  }
741
1856
  output = args.slice(argumentIndex).join(" ");
1857
+ // Whether `echo` interprets escapes without `-e` is shell- and
1858
+ // build-dependent, so a command that turns them on is read but not trusted
1859
+ // to the exact byte.
1860
+ exact = !escapes;
742
1861
  if (escapes) output = decodeShellDataEscapes(output);
743
1862
  } else if (producerCommand === "printf" && args.length > 0) {
744
- output = renderStaticPrintf(args[0], args.slice(1));
1863
+ // `printf -- /` prints `/`: the builtin takes `--` as the end of its
1864
+ // options, so the format is the argument after it. Reading `--` as the
1865
+ // format produced the value `--`, which is no root and no protected path,
1866
+ // while the shell printed one.
1867
+ const printfArgs = args[0] === "--" ? args.slice(1) : args;
1868
+ const format = printfArgs[0] ?? "";
1869
+ output = renderStaticPrintf(format, printfArgs.slice(1));
1870
+ // Any other option is one this does not model -- `-v NAME` assigns the
1871
+ // result to a variable and prints nothing at all -- so the rendering is not
1872
+ // claimed to be what bash would print.
1873
+ exact = !format.startsWith("-") && printfFormatIsExact(format);
1874
+ extra.push(...printfFormatLiterals(format), ...printfArgs.slice(1));
745
1875
  }
746
- return output.split(/\s+/).filter(Boolean);
1876
+ const words = output.split(/\s+/).filter(Boolean);
1877
+ const candidates = [...words, ...extra.flatMap((item) => item.split(/\s+/)).filter(Boolean)];
1878
+ return { words, candidates: [...new Set(candidates)], exact };
1879
+ }
1880
+
1881
+ function staticDataOutputCandidates(segment, assignments, producerCommand) {
1882
+ return staticDataOutput(segment, assignments, producerCommand).candidates;
747
1883
  }
748
1884
 
749
1885
  function echoEscapeMode(words, commandIndex) {
@@ -773,19 +1909,29 @@ function xargsPipelineInputCandidates(command) {
773
1909
  const candidates = [];
774
1910
  const assignments = new Map();
775
1911
  for (let index = 0; index < segments.length; index += 1) {
776
- const producerTokens = shellWordTokens(segments[index]);
777
- const producerWords = producerTokens.map((token) => token.value);
1912
+ // The producer's arguments can come out of the same brace word as its name:
1913
+ // `{printf,.e%.1sv} nv` is `printf .e%.1sv nv`, and reading the name from
1914
+ // the expanded list while slicing arguments off the raw one dropped the
1915
+ // format entirely.
1916
+ const { tokens: producerTokens, words: producerWords } = expandedSegment(segments[index], assignments);
778
1917
  rememberLeadingShellAssignments(producerWords, assignments);
779
1918
  if (index + 1 >= segments.length) continue;
780
- const consumerWords = shellWords(segments[index + 1]);
1919
+ // The consumer's name is assembled the same ways the producer's is, and
1920
+ // reading it as written meant `echo .env | xa$(printf %s rgs) cat` had no
1921
+ // `xargs` in it as far as this was concerned.
1922
+ const consumerWords = expandedSegment(segments[index + 1], assignments).words;
781
1923
  const consumerCommand = commandBasename(stripWrapper(consumerWords)[0] ?? "");
782
- if (consumerCommand !== "xargs") continue;
1924
+ // A consumer whose name is still being computed could be `xargs`, and if it
1925
+ // is, whatever the producer prints is opened. `echo .env | $(eval printf %s
1926
+ // xargs) cat` is that command whenever the substitution runs.
1927
+ if (consumerCommand !== "xargs" && !commandNameIsDynamic(consumerCommand)) continue;
783
1928
  const producerCommand = commandBasename(stripWrapper(producerWords)[0] ?? "");
784
1929
  if (!DATA_ONLY_COMMANDS.has(producerCommand)) continue;
785
- const commandIndex = producerWords.findIndex((word) => commandBasename(word) === producerCommand);
1930
+ const commandIndex = producerCommandIndex(producerWords, producerCommand);
786
1931
  for (const token of producerTokens.slice(commandIndex + 1)) {
787
1932
  const word = token.variableActive ? expandKnownShellVariables(token.value, assignments) : token.value;
788
1933
  if (word.startsWith("-") || word === "%s" || word === "%s\\n") continue;
1934
+ // Already brace-expanded by `expandedSegment`, mask and all.
789
1935
  candidates.push(word);
790
1936
  }
791
1937
  if (producerCommand === "printf" || (producerCommand === "echo" && echoEscapeMode(producerWords, commandIndex))) {
@@ -826,9 +1972,19 @@ function extractAttachedRedirectionPaths(segment) {
826
1972
  while (segment[index + 1] === "<") index += 1;
827
1973
  continue;
828
1974
  }
1975
+ // `>&` duplicates a file descriptor only when a digit or `-` follows it.
1976
+ // `>&word` with anything else redirects both streams to a file called
1977
+ // `word`, so skipping the whole form meant `printf x >& .env` truncated a
1978
+ // protected file while the scan moved past it.
829
1979
  if (next === "&") {
1980
+ let after = index + 2;
1981
+ while (/\s/.test(segment[after] ?? "")) after += 1;
1982
+ const target = segment[after] ?? "";
1983
+ if (target === "" || target === "-" || /[0-9]/.test(target)) {
1984
+ index += 1;
1985
+ continue;
1986
+ }
830
1987
  index += 1;
831
- continue;
832
1988
  }
833
1989
 
834
1990
  if ((char === ">" && (next === ">" || next === "|")) || (char === "<" && next === ">")) index += 1;
@@ -839,10 +1995,25 @@ function extractAttachedRedirectionPaths(segment) {
839
1995
  let target = "";
840
1996
  let targetQuote;
841
1997
  let targetEscaped = false;
1998
+ // The same per-character record the word tokenizer keeps, and it is the
1999
+ // whole answer on what expands: `> {.env,}` writes `.env`, `> "{.env,}"`
2000
+ // writes a file with that literal name, and the quotes are gone by the time
2001
+ // the target is a string. One target can also hold a quoted group beside an
2002
+ // expanding one -- `> "{.env,}"{,}` expands to two copies of a literal
2003
+ // `{.env,}`, an ambiguous redirect that writes nothing -- and a single flag
2004
+ // for the whole target could only say "expand it all", which produced
2005
+ // `.env` and refused a command that opens no such file.
2006
+ let targetMask = "";
2007
+ // Whitespace and `|` inside `$( )` or backticks belong to the target word.
2008
+ // Ending at the first space split `> .en$(echo v)` into a target of
2009
+ // `.en$(echo`, a name that matches nothing and hides the one being written.
2010
+ let targetSubstitution = 0;
2011
+ let targetBackticks = false;
842
2012
  for (; cursor < segment.length; cursor += 1) {
843
2013
  const targetChar = segment[cursor];
844
2014
  if (targetEscaped) {
845
2015
  target += targetChar;
2016
+ targetMask += "1";
846
2017
  targetEscaped = false;
847
2018
  continue;
848
2019
  }
@@ -850,6 +2021,16 @@ function extractAttachedRedirectionPaths(segment) {
850
2021
  targetEscaped = true;
851
2022
  continue;
852
2023
  }
2024
+ // A redirection target is a word like any other, so it can be written in
2025
+ // ANSI-C quoting too. `printf x >$'.env'` used to yield the target
2026
+ // `$.env`, which matches nothing, while the shell truncated `.env`.
2027
+ if (targetChar === "$" && segment[cursor + 1] === "'" && !targetQuote) {
2028
+ const ansiC = readAnsiCQuote(segment, cursor);
2029
+ target += ansiC.value;
2030
+ targetMask += "1".repeat(ansiC.value.length);
2031
+ cursor = ansiC.endIndex;
2032
+ continue;
2033
+ }
853
2034
  if ((targetChar === "'" || targetChar === '"') && !targetQuote) {
854
2035
  targetQuote = targetChar;
855
2036
  continue;
@@ -858,10 +2039,22 @@ function extractAttachedRedirectionPaths(segment) {
858
2039
  targetQuote = undefined;
859
2040
  continue;
860
2041
  }
861
- if (!targetQuote && (/\s/.test(targetChar) || /[;&|<>]/.test(targetChar))) break;
2042
+ // The depth this character sits at before it can close the substitution
2043
+ // it belongs to, the way the word tokenizer reads it and for the same
2044
+ // reason: read afterwards, the `}` of `${...}` looked like brace syntax
2045
+ // and left the target masked one character short.
2046
+ const enclosingDepth = targetSubstitution;
2047
+ if (!targetQuote) {
2048
+ if (targetChar === "$" && (segment[cursor + 1] === "(" || segment[cursor + 1] === "{")) targetSubstitution += 1;
2049
+ else if ((targetChar === ")" || targetChar === "}") && targetSubstitution > 0) targetSubstitution -= 1;
2050
+ else if (targetChar === "`") targetBackticks = !targetBackticks;
2051
+ }
2052
+ const insideSubstitution = enclosingDepth > 0 || targetSubstitution > 0 || targetBackticks;
2053
+ if (!targetQuote && !insideSubstitution && (/\s/.test(targetChar) || /[;&|<>]/.test(targetChar))) break;
862
2054
  target += targetChar;
2055
+ targetMask += (targetQuote || insideSubstitution) ? "1" : "0";
863
2056
  }
864
- if (target) paths.push(target);
2057
+ if (target) paths.push({ value: target, literalMask: targetMask });
865
2058
  index = Math.max(index, cursor - 1);
866
2059
  }
867
2060
 
@@ -871,40 +2064,94 @@ function extractAttachedRedirectionPaths(segment) {
871
2064
  export function extractShellPathCandidates(command) {
872
2065
  const candidates = [...xargsPipelineInputCandidates(command)];
873
2066
  const assignments = new Map();
874
- const addCandidate = (candidate, variableActive = true) => {
2067
+ // `braceActive` is the tokenizer's verdict on whether the shell would expand
2068
+ // this word's braces. Expanding unconditionally was the conservative choice
2069
+ // and it refused work nobody asked about: `cat "{.env,}"` opens a file with
2070
+ // that literal name, and offering `.env` for it blocked the command. Every
2071
+ // caller that reads a token has the answer, so it is passed rather than
2072
+ // guessed; the word as written stays a candidate either way.
2073
+ const addCandidate = (candidate, variableActive = true, braceActive = false, literalMask = undefined) => {
875
2074
  if (typeof candidate !== "string" || !candidate) return;
876
- candidates.push(variableActive ? expandKnownShellVariables(candidate, assignments) : candidate);
2075
+ const expanded = variableActive ? expandKnownShellVariables(candidate, assignments) : candidate;
2076
+ candidates.push(expanded);
2077
+ if (!braceActive) return;
2078
+ // Expanded with the record the scanner kept, not without one. A target
2079
+ // holding a quoted group beside an expanding one -- `"{.env,}"{,}` -- was
2080
+ // expanded all the way through for want of it, and offered `.env` for a
2081
+ // command that writes no such file.
2082
+ const mask = literalMask?.length === expanded.length ? literalMask : undefined;
2083
+ candidates.push(...expandShellBraces(expanded, mask)
2084
+ .map((entry) => entry.value)
2085
+ .filter((word) => word !== expanded));
877
2086
  };
878
2087
  const pending = splitShellSegments(command).map((segment) => ({ segment, depth: 0 }));
879
2088
  while (pending.length > 0) {
880
2089
  const { segment, depth } = pending.shift();
881
- const tokens = shellWordTokens(segment);
882
- const words = tokens.map((token) => token.value);
2090
+ // One stream for the command name and for the operands alike: `{cat,.env}`
2091
+ // is a `cat` whose only argument came out of the same word, and reading the
2092
+ // name from one list and the arguments from another lost it.
2093
+ const { words, text, tokens, braceText, evaluable } = expandedSegment(segment, assignments);
2094
+ const expandedWords = words;
883
2095
  rememberLeadingShellAssignments(words, assignments);
884
2096
  const commandName = commandBasename(stripWrapper(words)[0] ?? "");
885
- for (const redirectPath of extractAttachedRedirectionPaths(segment)) addCandidate(redirectPath);
2097
+ // Redirection targets are read off the raw segment, because that is the only
2098
+ // text that still shows an operator glued to its path. They get the same
2099
+ // resolution the words got: the data-only skip below never covers them --
2100
+ // `printf` writes to whatever `>` names whether or not `printf` itself opens
2101
+ // files -- so `printf x > .en$(echo v)` truncated `.env` while every reader
2102
+ // that could have resolved the name had been skipped.
2103
+ for (const word of extractAttachedRedirectionPaths(segment).flatMap(redirectionTargetWords)) {
2104
+ const resolvedTarget = resolveSubstitutionTarget(word.value, assignments, evaluable);
2105
+ addCandidate(resolvedTarget.value);
2106
+ }
2107
+
2108
+ const inlineFlags = INLINE_CODE_FLAGS.get(commandName);
2109
+ if (inlineFlags) {
2110
+ for (let index = 0; index < words.length - 1; index += 1) {
2111
+ if (!inlineFlags.has(words[index])) continue;
2112
+ for (const literal of quotedShellLiterals(words[index + 1])) addCandidate(literal);
2113
+ }
2114
+ }
2115
+
886
2116
  if (!DATA_ONLY_COMMANDS.has(commandName) || depth > 0) {
887
2117
  const argumentTokens = executableArgumentTokens(tokens, words, commandName);
2118
+ // `printf %.0s.env x` prints `.env`: the conversion eats the argument and
2119
+ // the rest of the format is the output. Reading only the substituted
2120
+ // argument produced `x.env`, a name matching no protected pattern, so the
2121
+ // file the command actually names went unseen. Inside the data-only guard
2122
+ // with everything else -- a `printf` of its own prints to stdout, and its
2123
+ // format is text until something downstream opens it.
2124
+ if (commandName === "printf") {
2125
+ for (const literal of printfFormatLiterals(argumentTokens[0]?.value ?? "")) addCandidate(literal);
2126
+ // And the output itself. The literal text and the arguments were both
2127
+ // offered as candidates, but never what they make when printf puts them
2128
+ // together: `.e%.1sv nv` is `.env`, and the pieces on offer were `.ev`
2129
+ // and `nv`. Whether the rendering is exact enough to *refuse* a
2130
+ // destructive target is a separate question -- here an approximate name
2131
+ // costs a refusal with the reason on screen, and a missing one costs the
2132
+ // check.
2133
+ for (const word of staticDataOutput(segment, assignments, "printf").words) addCandidate(word, false);
2134
+ }
888
2135
  let searchPatternPending = SEARCH_COMMANDS.has(commandName);
889
2136
  for (let index = 0; index < argumentTokens.length; index += 1) {
890
2137
  const word = argumentTokens[index].value;
891
2138
  if (word === "<<" || word === "<<<") {
892
2139
  if (commandName === "xargs" && word === "<<<" && argumentTokens[index + 1]) {
893
2140
  const input = argumentTokens[index + 1];
894
- addCandidate(input.value, input.variableActive);
2141
+ addCandidate(input.value, input.variableActive, input.braceActive);
895
2142
  }
896
2143
  index += 1;
897
2144
  continue;
898
2145
  }
899
2146
  if (PATH_REDIRECT_OPERATORS.has(word) && argumentTokens[index + 1]) {
900
2147
  const target = argumentTokens[index + 1];
901
- addCandidate(target.value, target.variableActive);
2148
+ addCandidate(target.value, target.variableActive, target.braceActive);
902
2149
  index += 1;
903
2150
  continue;
904
2151
  }
905
2152
  if (/^(?:\d*)>>?/.test(word) || /^&>>?/.test(word)) {
906
2153
  const pathPart = word.replace(/^(?:\d*|&)>>?/, "");
907
- if (pathPart) addCandidate(pathPart, argumentTokens[index].variableActive);
2154
+ if (pathPart) addCandidate(pathPart, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
908
2155
  continue;
909
2156
  }
910
2157
  const optionEquals = word.indexOf("=");
@@ -919,7 +2166,7 @@ export function extractShellPathCandidates(command) {
919
2166
  ? word.slice(2)
920
2167
  : undefined;
921
2168
  if (attachedGrepFile) {
922
- addCandidate(attachedGrepFile, argumentTokens[index].variableActive);
2169
+ addCandidate(attachedGrepFile, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
923
2170
  searchPatternPending = false;
924
2171
  continue;
925
2172
  }
@@ -929,20 +2176,20 @@ export function extractShellPathCandidates(command) {
929
2176
  continue;
930
2177
  }
931
2178
  if (attachedRgOption?.option === "-f") {
932
- addCandidate(attachedRgOption.value, argumentTokens[index].variableActive);
2179
+ addCandidate(attachedRgOption.value, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
933
2180
  searchPatternPending = false;
934
2181
  continue;
935
2182
  }
936
2183
  if (attachedRgOption?.option === "-g") {
937
- addCandidate(attachedRgOption.value, argumentTokens[index].variableActive);
2184
+ addCandidate(attachedRgOption.value, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
938
2185
  continue;
939
2186
  }
940
2187
  if (SEARCH_COMMANDS.has(commandName) && (SEARCH_FILE_OPTIONS.has(optionName) || SEARCH_GLOB_OPTIONS.has(optionName))) {
941
2188
  if (inlineOptionValue !== undefined) {
942
- addCandidate(inlineOptionValue, argumentTokens[index].variableActive);
2189
+ addCandidate(inlineOptionValue, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
943
2190
  } else {
944
2191
  const target = argumentTokens[index + 1];
945
- if (target) addCandidate(target.value, target.variableActive);
2192
+ if (target) addCandidate(target.value, target.variableActive, target.braceActive);
946
2193
  index += 1;
947
2194
  }
948
2195
  if (SEARCH_FILE_OPTIONS.has(optionName) && (optionName === "-f" || optionName === "--file")) searchPatternPending = false;
@@ -950,7 +2197,7 @@ export function extractShellPathCandidates(command) {
950
2197
  }
951
2198
  const optionPath = pathCandidateFromOption(word);
952
2199
  if (optionPath) {
953
- addCandidate(optionPath, argumentTokens[index].variableActive);
2200
+ addCandidate(optionPath, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
954
2201
  continue;
955
2202
  }
956
2203
  if (word.startsWith("-")) continue;
@@ -958,12 +2205,27 @@ export function extractShellPathCandidates(command) {
958
2205
  searchPatternPending = false;
959
2206
  continue;
960
2207
  }
961
- if (isFilesystemArgument(word)) addCandidate(word, argumentTokens[index].variableActive);
2208
+ const operandPath = operandValuePath(word);
2209
+ if (operandPath) {
2210
+ addCandidate(operandPath, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
2211
+ continue;
2212
+ }
2213
+ if (isFilesystemArgument(word)) addCandidate(word, argumentTokens[index].variableActive, argumentTokens[index].braceActive);
962
2214
  }
963
2215
  }
964
- if (depth < 4) {
965
- for (const nestedCommand of extractNestedCommands(segment, words)) {
2216
+ if (depth < MAX_NESTED_DEPTH) {
2217
+ for (const nestedCommand of extractNestedCommands(braceText, expandedWords)) {
966
2218
  for (const nestedSegment of splitShellSegments(nestedCommand)) {
2219
+ // `$(echo -e '.en\x76')` prints `.env`, and `printf` decodes its
2220
+ // format the same way. The backslash is gone by the time the body is
2221
+ // tokenized, so the escape is read back off the raw text -- the same
2222
+ // reading the `xargs` producer already gets.
2223
+ const nestedWords = shellWords(nestedSegment);
2224
+ const nestedName = commandBasename(stripWrapper(expandedSegment(nestedSegment, assignments).words)[0] ?? "");
2225
+ const nestedIndex = producerCommandIndex(nestedWords, nestedName);
2226
+ if (nestedName === "printf" || (nestedName === "echo" && echoEscapeMode(nestedWords, nestedIndex))) {
2227
+ for (const literal of escapedLiteralCandidates(nestedSegment)) addCandidate(literal, false);
2228
+ }
967
2229
  pending.push({ segment: nestedSegment, depth: depth + 1 });
968
2230
  }
969
2231
  }
@@ -975,14 +2237,49 @@ export function extractShellPathCandidates(command) {
975
2237
  export function extractShellGlobCandidates(command) {
976
2238
  const candidates = [];
977
2239
  const assignments = new Map();
978
- const pending = splitShellSegments(command).map((segment) => ({ segment, depth: 0 }));
2240
+ const segments = splitShellSegments(command);
2241
+ // A pattern printed into an `xargs` that opens files is opened, and the
2242
+ // producer that printed it only prints -- so `echo .env* | xargs cat` reads
2243
+ // the secret with the glob sitting in a segment this would otherwise skip.
2244
+ const pending = segments.map((segment, index) => ({
2245
+ segment,
2246
+ depth: 0,
2247
+ piped: segments[index + 1] !== undefined && feedsFileOperandConsumer(segments[index + 1], assignments)
2248
+ }));
979
2249
  while (pending.length > 0) {
980
- const { segment, depth } = pending.shift();
981
- const tokens = shellWordTokens(segment);
982
- const words = tokens.map((token) => token.value);
2250
+ const { segment, depth, piped } = pending.shift();
2251
+ // The same expanded, resolved stream every other reader takes. Reading the
2252
+ // raw tokens here left the glob layer behind while the literal layer moved
2253
+ // on -- and a pattern is precisely what the literal layer cannot answer for.
2254
+ // `.env*` matches no protected literal, so `{cat,.env*}` had its command in
2255
+ // one unexpanded word, produced no glob candidate at all, and read the
2256
+ // secret. `{grep,-f,.env*,README.md}`, `cat $({echo,.env*})`,
2257
+ // `{bash,} -c 'cat .env*'` and `echo .env* | xargs cat` went the same way.
2258
+ const { words, tokens, braceText } = expandedSegment(segment, assignments);
983
2259
  rememberLeadingShellAssignments(words, assignments);
984
2260
  const commandName = commandBasename(stripWrapper(words)[0] ?? "");
985
- if (!DATA_ONLY_COMMANDS.has(commandName) || depth > 0) {
2261
+
2262
+ // A redirection target is a file whatever the command does with its
2263
+ // arguments, so it is read before the data-only skip below rather than
2264
+ // inside it: `printf pwned > .en*` truncates whatever `.en*` matches, and
2265
+ // `printf` being a data-only command says nothing about that. Read through
2266
+ // the same scanner the path candidates use, so the operator prefix is off
2267
+ // and `>|`, `>&` and ANSI-C quoting are already handled.
2268
+ // Read through the same word rule as the path candidates, and for the same
2269
+ // reason: a target that expands to two words is an ambiguous redirect that
2270
+ // opens nothing, so `printf x > "{.env*,}"{,}` had no file to report. What
2271
+ // survives that rule is matched against filenames only where the pattern is
2272
+ // unquoted -- `> "{.env*,}"` names one oddly-spelled file -- while a glob
2273
+ // that did survive stays a candidate, since it may still match exactly one.
2274
+ for (const word of extractAttachedRedirectionPaths(segment).flatMap(redirectionTargetWords)) {
2275
+ const expanded = expandKnownShellVariables(word.value, assignments);
2276
+ const substituted = expanded !== word.value;
2277
+ if (wordHasActiveGlob(word.value, word.mask) || (substituted && /[*?[]/.test(expanded))) {
2278
+ candidates.push(expanded);
2279
+ }
2280
+ }
2281
+
2282
+ if (!DATA_ONLY_COMMANDS.has(commandName) || depth > 0 || piped) {
986
2283
  const argumentTokens = executableArgumentTokens(tokens, words, commandName);
987
2284
  let searchPatternPending = SEARCH_COMMANDS.has(commandName);
988
2285
  for (let index = 0; index < argumentTokens.length; index += 1) {
@@ -1052,8 +2349,13 @@ export function extractShellGlobCandidates(command) {
1052
2349
  if (token.activeGlob || (token.unquotedVariable && /[*?{\[]/.test(expanded))) candidates.push(expanded);
1053
2350
  }
1054
2351
  }
1055
- if (depth < 4) {
1056
- for (const nestedCommand of extractNestedCommands(segment, words)) {
2352
+ if (depth < MAX_NESTED_DEPTH) {
2353
+ // Brace-expanded text, the same as the other two readers take. No case
2354
+ // currently distinguishes it from the raw segment here -- an interpreter
2355
+ // payload is found in the word list rather than the text, and brace
2356
+ // expansion does not move a substitution body -- but a reader deriving
2357
+ // its own view of a segment is the whole of what went wrong before.
2358
+ for (const nestedCommand of extractNestedCommands(braceText, words)) {
1057
2359
  for (const nestedSegment of splitShellSegments(nestedCommand)) pending.push({ segment: nestedSegment, depth: depth + 1 });
1058
2360
  }
1059
2361
  }
@@ -1061,6 +2363,192 @@ export function extractShellGlobCandidates(command) {
1061
2363
  return [...new Set(candidates.map(normalizePathCandidate).filter(Boolean))];
1062
2364
  }
1063
2365
 
2366
+ /**
2367
+ * Words in a file position whose final path segment this module could not
2368
+ * resolve to a name.
2369
+ *
2370
+ * Modelling one more expansion operator each time one is reported is a losing
2371
+ * game: bash has a dozen of them, plus arrays, plus command substitution, and
2372
+ * each unmodelled form reads as "this word names no protected path" — the one
2373
+ * answer that is never safe to guess. So the rule is inverted here. A word that
2374
+ * still holds an expansion where its filename lives is *unresolved*, and an
2375
+ * unresolved filename is refused rather than assumed harmless.
2376
+ *
2377
+ * Two conditions narrow it to words that are filenames being assembled, rather
2378
+ * than to every word holding an expansion.
2379
+ *
2380
+ * Only the last path segment counts. `$HOME/notes.txt` names a known file in an
2381
+ * unknown directory, and every protected pattern that could match it is anchored
2382
+ * on the basename anyway, so flagging it would refuse ordinary work for nothing.
2383
+ *
2384
+ * And the expansion has to be *concatenated with literal text*. A word that is
2385
+ * nothing but an expansion — `$(date)`, `${MESSAGE}` — is as likely to be a
2386
+ * commit message as a path, and a pure substitution is already followed into by
2387
+ * `extractNestedCommands`, which reads `cat $(echo .env)` correctly. What that
2388
+ * cannot do is put the pieces back together: `.en$(echo v)` and `$(echo .)env`
2389
+ * are filenames half-written in plain sight, and only a rule about the joining
2390
+ * sees them.
2391
+ *
2392
+ * Quoting decides whether a `$` is an expansion at all, so this reads the
2393
+ * tokenizer's own verdict rather than looking for the character — `cat '$F'`
2394
+ * passes a literal and is not flagged.
2395
+ *
2396
+ * @param {string} command
2397
+ * @returns {string[]} the unresolved words, in the order they appear
2398
+ */
2399
+ // Strip the expansions out; whatever text is left is the literal part the author
2400
+ // wrote around them. Literal text plus an expansion is a filename being
2401
+ // assembled; an expansion on its own is a value.
2402
+ const EXPANSION_FORM = /\$\{[^{}]*\}|\$\([^()]*\)|`[^`]*`|\$[A-Za-z_][A-Za-z0-9_]*/g;
2403
+
2404
+ /**
2405
+ * True when the text still holds an expansion this module could not resolve.
2406
+ *
2407
+ * Only parameter expansions count. A command substitution carries a command,
2408
+ * and the nested scan reads it -- `cat $(gh issue create ...)` is answered as
2409
+ * an external write, and answering it here instead would replace that with a
2410
+ * vaguer reason. A parameter expansion carries nothing for anyone else to read,
2411
+ * so if it is not resolved here it is not resolved at all.
2412
+ *
2413
+ * @param {string} text
2414
+ * @returns {boolean}
2415
+ */
2416
+ function holdsUnresolvedParameter(text) {
2417
+ return /\$\{|\$[A-Za-z_]/.test(text);
2418
+ }
2419
+
2420
+ /**
2421
+ * True when the name is being built out of pieces rather than named outright.
2422
+ *
2423
+ * Literal text beside an expansion is the obvious form. Two expansions with
2424
+ * nothing between them is the same act -- `$(printf %.1sen .Z)$(printf %.1s vZ)`
2425
+ * spells `.env` and neither half contains it -- and nobody writes a value that
2426
+ * way, so the count is as good a signal as the literal.
2427
+ *
2428
+ * @param {string} basename
2429
+ * @returns {boolean}
2430
+ */
2431
+ function assemblesFilename(basename) {
2432
+ const expansions = basename.match(EXPANSION_FORM) ?? [];
2433
+ if (expansions.length > 1) return true;
2434
+ const literal = basename.replace(EXPANSION_FORM, "");
2435
+ return literal.length > 0 && expansions.length > 0;
2436
+ }
2437
+
2438
+ /**
2439
+ * True when this segment hands what it reads to a command that opens files.
2440
+ *
2441
+ * `echo $F | xargs cat` opens whatever `$F` names, and neither segment shows it:
2442
+ * the producer is `echo`, which only prints, and the consumer's operand list is
2443
+ * empty because the filename arrives on stdin. Judged one segment at a time the
2444
+ * pipeline says nothing, so the producer is judged against the command at the
2445
+ * far end of the pipe instead.
2446
+ *
2447
+ * Every word after `xargs` is examined rather than just the command word,
2448
+ * because the option values are not modelled here and mistaking `-n 1 cat` for
2449
+ * a command named `1` would lose the `cat`. The cost of reading one word too
2450
+ * many is a question about a pipeline that already holds an expansion nobody
2451
+ * can resolve.
2452
+ *
2453
+ * @param {string} segment
2454
+ * @returns {boolean}
2455
+ */
2456
+ function feedsFileOperandConsumer(segment, assignments) {
2457
+ const words = stripWrapper(expandedSegment(segment, assignments).words);
2458
+ const name = commandBasename(words[0] ?? "");
2459
+ if (name !== "xargs" && !commandNameIsDynamic(name)) return false;
2460
+ return words.slice(1).some((word) => FILE_OPERAND_COMMANDS.has(commandBasename(word)));
2461
+ }
2462
+
2463
+ export function unresolvedPathExpansions(command) {
2464
+ const unresolved = [];
2465
+ const assignments = new Map();
2466
+ const pending = [];
2467
+ // Segments are queued in pipeline order so each one still knows what consumes
2468
+ // it; read on their own they lose the only thing that says what they name.
2469
+ const enqueue = (text, depth) => {
2470
+ const segments = splitShellSegments(text);
2471
+ for (let index = 0; index < segments.length; index += 1) {
2472
+ const piped = segments[index + 1] !== undefined && feedsFileOperandConsumer(segments[index + 1], assignments);
2473
+ pending.push({ segment: segments[index], depth, piped });
2474
+ }
2475
+ };
2476
+ enqueue(command, 0);
2477
+
2478
+ while (pending.length > 0) {
2479
+ const { segment, depth, piped } = pending.shift();
2480
+ // The same single stream the exec policy and the path candidates read.
2481
+ const { words, text, tokens, braceText, evaluable } = expandedSegment(segment, assignments);
2482
+ const expandedWords = words;
2483
+ rememberLeadingShellAssignments(words, assignments);
2484
+ const commandName = commandBasename(stripWrapper(words)[0] ?? "");
2485
+ // A redirection target is a file the shell creates whatever the command is,
2486
+ // so it is read before the data-only skip below.
2487
+ for (const word of extractAttachedRedirectionPaths(segment).flatMap(redirectionTargetWords)) {
2488
+ const expanded = expandKnownShellVariables(word.value, assignments);
2489
+ const basename = expanded.slice(expanded.lastIndexOf("/") + 1);
2490
+ // A redirection target is a file every time, so an expansion anywhere in
2491
+ // it is enough -- there is no reading under which `> $OUT` is a message
2492
+ // rather than a path. Command substitutions count here even though they
2493
+ // do not as operands: whatever the inner command is classified as, its
2494
+ // output still becomes a filename that gets truncated.
2495
+ if (assemblesFilename(basename) || /\$\{|\$\(|`|\$[A-Za-z_]/.test(expanded)) {
2496
+ unresolved.push(word.value);
2497
+ }
2498
+ }
2499
+ // `echo ${a[@]}` prints a name, it does not open one -- unless something
2500
+ // downstream opens what it printed.
2501
+ if (!DATA_ONLY_COMMANDS.has(commandName) || depth > 0 || piped) {
2502
+ const argumentTokens = executableArgumentTokens(tokens, words, commandName);
2503
+ for (let index = 0; index < argumentTokens.length; index += 1) {
2504
+ const token = argumentTokens[index];
2505
+ if (!token.variableActive) continue;
2506
+ if (token.value.startsWith("-")) continue;
2507
+ // `grep`'s first bare operand is the pattern, so the command does not
2508
+ // open files by name -- but the word after `-f` does, and
2509
+ // `grep -f ${X:+.env} README.md` reads a protected file with the
2510
+ // command sitting outside every list that says so.
2511
+ const namedByFlag = SEARCH_COMMANDS.has(commandName)
2512
+ && SEARCH_FILE_OPTIONS.has(argumentTokens[index - 1]?.value ?? "");
2513
+ // `dd if=.en$(echo v)` hides a filename in an operand value. Reading the
2514
+ // key as literal text would refuse every `TAG=build$(date +%s)` too, so
2515
+ // the value is taken on its own and only when it is shaped like a path.
2516
+ const operandValue = operandValuePath(token.value);
2517
+ const word = operandValue ?? token.value;
2518
+ if (operandValue !== undefined
2519
+ && !operandValue.includes("/")
2520
+ && !operandValue.startsWith(".")) continue;
2521
+ if (word.startsWith("-")) continue;
2522
+ const expanded = expandKnownShellVariables(word, assignments);
2523
+ const basename = expanded.slice(expanded.lastIndexOf("/") + 1);
2524
+ // A name assembled out of pieces is a filename wherever it appears. A
2525
+ // word that is *nothing but* one parameter expansion is a filename only
2526
+ // where the command says so -- and there it is refused too, because the
2527
+ // whole point of this net is that a form nobody has modelled yet should
2528
+ // cost a question rather than a miss. Widening it to every command would
2529
+ // ask about `git commit -m $MSG`. Words printed into an `xargs cat` are
2530
+ // operands of that `cat`, whatever printed them. And a name nobody can
2531
+ // evaluate could be a command that opens files: `${X:+cat} ${X:+.env}`
2532
+ // reads a protected file whenever the variable is set, and both halves
2533
+ // read as words naming nothing. This only fires when the operand is
2534
+ // unresolved too, so a named command with a literal operand is untouched.
2535
+ const opensFiles = FILE_OPERAND_COMMANDS.has(commandName)
2536
+ || piped
2537
+ || namedByFlag
2538
+ || commandNameIsDynamic(commandName);
2539
+ const bare = opensFiles && holdsUnresolvedParameter(basename);
2540
+ if (assemblesFilename(basename) || bare) unresolved.push(token.value);
2541
+ }
2542
+ }
2543
+ if (depth < MAX_NESTED_DEPTH) {
2544
+ for (const nestedCommand of extractNestedCommands(braceText, expandedWords)) {
2545
+ enqueue(nestedCommand, depth + 1);
2546
+ }
2547
+ }
2548
+ }
2549
+ return unresolved;
2550
+ }
2551
+
1064
2552
  export function findProtectedPathInCommand(command, protectedPatterns) {
1065
2553
  for (const candidate of extractShellPathCandidates(command)) {
1066
2554
  const pattern = matchesProtectedPath(candidate, protectedPatterns);