@westbayberry/dg 2.3.2 → 2.3.4

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 (122) hide show
  1. package/README.md +95 -208
  2. package/dist/agents/copilot-cli.js +7 -2
  3. package/dist/agents/persistence.js +83 -8
  4. package/dist/agents/registry.js +5 -2
  5. package/dist/agents/routing.js +31 -14
  6. package/dist/api/analyze.js +138 -41
  7. package/dist/audit/deep.js +2 -1
  8. package/dist/audit/detectors.js +23 -2
  9. package/dist/audit/rules.js +4 -1
  10. package/dist/audit-ui/export.js +5 -4
  11. package/dist/auth/device-login.js +33 -11
  12. package/dist/auth/login-app.js +17 -12
  13. package/dist/auth/store.js +65 -9
  14. package/dist/bin/dg.js +1 -1
  15. package/dist/commands/audit.js +12 -20
  16. package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
  17. package/dist/commands/cooldown.js +2 -1
  18. package/dist/commands/decisions.js +8 -7
  19. package/dist/commands/help.js +11 -3
  20. package/dist/commands/licenses.js +17 -8
  21. package/dist/commands/login.js +58 -21
  22. package/dist/commands/router.js +12 -2
  23. package/dist/commands/sbom.js +20 -5
  24. package/dist/commands/scan.js +5 -4
  25. package/dist/commands/service.js +1 -1
  26. package/dist/commands/setup.js +12 -12
  27. package/dist/commands/status.js +4 -4
  28. package/dist/commands/uninstall.js +1 -1
  29. package/dist/commands/update.js +20 -0
  30. package/dist/commands/verify.js +4 -4
  31. package/dist/config/settings.js +81 -12
  32. package/dist/export-ui/ExportDialog.js +1 -1
  33. package/dist/gate/cooldown-request.js +22 -0
  34. package/dist/gate/egress-guard.js +185 -0
  35. package/dist/gate/host-match.js +48 -0
  36. package/dist/gate/verdict-core.js +101 -0
  37. package/dist/gate/verdict-fetch.js +214 -0
  38. package/dist/launcher/agent-check.js +1096 -42
  39. package/dist/launcher/agent-hook-exec.js +8 -1
  40. package/dist/launcher/agent-hook-io.js +5 -3
  41. package/dist/launcher/classify.js +110 -26
  42. package/dist/launcher/env.js +84 -18
  43. package/dist/launcher/install-preflight.js +26 -3
  44. package/dist/launcher/live-install.js +4 -2
  45. package/dist/launcher/manifest-screen.js +88 -7
  46. package/dist/launcher/output-redaction.js +153 -15
  47. package/dist/launcher/preflight-prompt.js +1 -1
  48. package/dist/launcher/resolve-real-binary.js +8 -3
  49. package/dist/launcher/run.js +237 -40
  50. package/dist/origin/artifact-cache.js +55 -0
  51. package/dist/origin/cargo-config.js +12 -0
  52. package/dist/origin/gate-mode.js +7 -0
  53. package/dist/origin/platform-paths.js +14 -0
  54. package/dist/origin/precedence.js +345 -0
  55. package/dist/origin/server.js +611 -0
  56. package/dist/origin/spike-worker.js +35 -0
  57. package/dist/origin/worker.js +116 -0
  58. package/dist/presentation/package-page.js +21 -1
  59. package/dist/presentation/safe-version.js +11 -0
  60. package/dist/presentation/sarif.js +109 -0
  61. package/dist/project/dgfile.js +54 -17
  62. package/dist/project/override-trust.js +0 -0
  63. package/dist/proxy/enforcement.js +27 -7
  64. package/dist/proxy/metadata-map.js +33 -7
  65. package/dist/proxy/preverified.js +0 -3
  66. package/dist/proxy/server.js +104 -446
  67. package/dist/proxy/upstream-proxy.js +23 -4
  68. package/dist/proxy/worker.js +3 -2
  69. package/dist/publish-set/collect.js +6 -0
  70. package/dist/runtime/fatal.js +2 -1
  71. package/dist/runtime/first-run.js +5 -1
  72. package/dist/runtime/node-version.js +51 -0
  73. package/dist/runtime/nudges.js +27 -1
  74. package/dist/sbom/cyclonedx.js +120 -33
  75. package/dist/sbom/graph.js +236 -0
  76. package/dist/sbom/spdx.js +53 -0
  77. package/dist/sbom-ui/SbomApp.js +5 -3
  78. package/dist/sbom-ui/inventory.js +8 -10
  79. package/dist/scan/analyze-worker.js +3 -1
  80. package/dist/scan/collect.js +38 -8
  81. package/dist/scan/command.js +77 -27
  82. package/dist/scan/discovery.js +18 -7
  83. package/dist/scan/installed.js +202 -0
  84. package/dist/scan/manifest-coverage.js +128 -0
  85. package/dist/scan/render.js +125 -40
  86. package/dist/scan/scanner-report.js +92 -22
  87. package/dist/scan/staged.js +68 -15
  88. package/dist/scan/types.js +7 -1
  89. package/dist/scan-ui/LegacyApp.js +3 -3
  90. package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
  91. package/dist/scan-ui/hooks/useScan.js +37 -11
  92. package/dist/scan-ui/launch.js +2 -2
  93. package/dist/scan-ui/logo.js +6 -2
  94. package/dist/scan-ui/shims.js +6 -6
  95. package/dist/scripts/gate.js +53 -35
  96. package/dist/security/csv.js +9 -0
  97. package/dist/security/sanitize.js +11 -2
  98. package/dist/service/state.js +120 -37
  99. package/dist/service/trust-refresh.js +12 -1
  100. package/dist/service/trust-store.js +36 -5
  101. package/dist/service/worker.js +5 -7
  102. package/dist/setup/git-hook.js +2 -2
  103. package/dist/setup/install-method.js +36 -0
  104. package/dist/setup/plan.js +119 -57
  105. package/dist/setup/uninstall-standalone.js +7 -2
  106. package/dist/setup-ui/gate.js +1 -8
  107. package/dist/setup-ui/offer.js +14 -7
  108. package/dist/setup-ui/selector.js +1 -1
  109. package/dist/setup-ui/wizard.js +73 -78
  110. package/dist/standalone/uninstall.mjs +1114 -386
  111. package/dist/state/cleanup-registry.js +14 -3
  112. package/dist/state/cooldown-held.js +36 -13
  113. package/dist/state/locks.js +239 -34
  114. package/dist/state/sessions.js +2 -2
  115. package/dist/util/sh-escape.js +6 -0
  116. package/dist/util/tty-prompt.js +13 -4
  117. package/dist/verify/local.js +60 -10
  118. package/dist/verify/package-check.js +35 -49
  119. package/dist/verify/preflight.js +59 -33
  120. package/dist/verify/render.js +11 -12
  121. package/npm-shrinkwrap.json +714 -213
  122. package/package.json +4 -2
@@ -101,6 +101,14 @@ function splitSegments(line) {
101
101
  let buf = "";
102
102
  let quote = null;
103
103
  let ansiC = false;
104
+ // Track depth INSIDE substitution groups — `$(…)` command-sub and `<(…)`/`>(…)`
105
+ // process-sub — whose bodies collectSegments re-extracts and re-screens as a
106
+ // whole. A separator there (`sh <(true | echo npm i evil)`) must not shear the
107
+ // group, or the body extractor never sees a balanced span. A BARE `(…)` subshell
108
+ // is NOT tracked: its `&&`/`;`-joined install (`(cd x && npm i evil)`) has no
109
+ // separate extractor, so it must still split normally.
110
+ const parenTracked = [];
111
+ let substDepth = 0;
104
112
  const push = () => {
105
113
  const t = buf.trim();
106
114
  if (t) {
@@ -160,6 +168,27 @@ function splitSegments(line) {
160
168
  buf += ch;
161
169
  continue;
162
170
  }
171
+ if (ch === "(") {
172
+ const tracked = line[i - 1] === "<" || line[i - 1] === ">" || line[i - 1] === "$";
173
+ parenTracked.push(tracked);
174
+ if (tracked) {
175
+ substDepth += 1;
176
+ }
177
+ buf += ch;
178
+ continue;
179
+ }
180
+ if (ch === ")") {
181
+ if (parenTracked.pop() === true && substDepth > 0) {
182
+ substDepth -= 1;
183
+ }
184
+ buf += ch;
185
+ continue;
186
+ }
187
+ if (substDepth > 0) {
188
+ // Inside a substitution group: keep the body intact for its extractor.
189
+ buf += ch;
190
+ continue;
191
+ }
163
192
  if (ch === "#" && (i === 0 || /\s/.test(line[i - 1] ?? ""))) {
164
193
  // A bash comment begins at an unquoted `#` on a word boundary and runs to
165
194
  // end of line; the shell never executes that text, so splitting it into a
@@ -200,25 +229,149 @@ function splitSegments(line) {
200
229
  push();
201
230
  return segments;
202
231
  }
232
+ // Extract a balanced `(…)` span starting at the index of its opening paren,
233
+ // honoring nested parens and quotes inside (so a `)` inside a quoted string does
234
+ // not close it early — `$(echo "(" ; npm install evil)` yields the whole body,
235
+ // not a truncated one that would drop the install). Returns null if unbalanced.
236
+ function extractParenBody(line, openParen) {
237
+ let depth = 0;
238
+ let q = null;
239
+ for (let j = openParen; j < line.length; j += 1) {
240
+ const c = line[j] ?? "";
241
+ if (q === "'") {
242
+ if (c === "'") {
243
+ q = null;
244
+ }
245
+ continue;
246
+ }
247
+ if (q === '"') {
248
+ if (c === "\\" && j + 1 < line.length) {
249
+ j += 1;
250
+ continue;
251
+ }
252
+ if (c === '"') {
253
+ q = null;
254
+ }
255
+ continue;
256
+ }
257
+ if (c === "\\" && j + 1 < line.length) {
258
+ j += 1;
259
+ continue;
260
+ }
261
+ if (c === "'" || c === '"') {
262
+ q = c;
263
+ continue;
264
+ }
265
+ if (c === "(") {
266
+ depth += 1;
267
+ }
268
+ else if (c === ")") {
269
+ depth -= 1;
270
+ if (depth === 0) {
271
+ return { text: line.slice(openParen + 1, j).trim(), end: j };
272
+ }
273
+ }
274
+ }
275
+ return null;
276
+ }
277
+ function extractBacktick(line, openTick) {
278
+ for (let j = openTick + 1; j < line.length; j += 1) {
279
+ const c = line[j] ?? "";
280
+ if (c === "\\" && j + 1 < line.length) {
281
+ j += 1;
282
+ continue;
283
+ }
284
+ if (c === "`") {
285
+ return { text: line.slice(openTick + 1, j).trim(), end: j };
286
+ }
287
+ }
288
+ return null;
289
+ }
290
+ // `$(…)`, backtick, and process substitution `<(…)` / `>(…)` run their body as a
291
+ // command, so an install hidden inside one must be re-screened — but only where
292
+ // the shell would actually execute it. A substitution inside SINGLE quotes is
293
+ // literal text the shell never runs (`git commit -m '$(npm install x)'`), so
294
+ // extracting it is a false block. Double-quoted `$(…)`/backtick still execute,
295
+ // so those are kept; `<(…)`/`>(…)` are a word-level construct only when unquoted.
203
296
  function substitutionBodies(line) {
204
297
  const bodies = [];
205
- // `$(…)`, backtick, and process substitution `<(…)` / `>(…)` all run their
206
- // body as a command, so an install hidden inside one must be re-screened.
207
- const patterns = [/\$\(([^)]*)\)/g, /`([^`]*)`/g, /<\(([^)]*)\)/g, />\(([^)]*)\)/g];
208
- for (const pattern of patterns) {
209
- let m;
210
- while ((m = pattern.exec(line)) !== null) {
211
- const body = (m[1] ?? "").trim();
212
- if (body) {
213
- bodies.push(body);
298
+ let quote = null;
299
+ let ansiC = false;
300
+ const take = (body, i) => {
301
+ if (body) {
302
+ if (body.text) {
303
+ bodies.push(body.text);
304
+ }
305
+ return body.end;
306
+ }
307
+ return i;
308
+ };
309
+ for (let i = 0; i < line.length; i += 1) {
310
+ const ch = line[i] ?? "";
311
+ if (ansiC) {
312
+ if (ch === "\\" && i + 1 < line.length) {
313
+ i += 1;
314
+ continue;
315
+ }
316
+ if (ch === "'") {
317
+ ansiC = false;
318
+ }
319
+ continue;
320
+ }
321
+ if (quote === "'") {
322
+ if (ch === "'") {
323
+ quote = null;
324
+ }
325
+ continue;
326
+ }
327
+ if (quote === '"') {
328
+ if (ch === "\\" && i + 1 < line.length) {
329
+ i += 1;
330
+ continue;
331
+ }
332
+ if (ch === '"') {
333
+ quote = null;
334
+ continue;
335
+ }
336
+ if (ch === "$" && line[i + 1] === "(") {
337
+ i = take(extractParenBody(line, i + 1), i);
338
+ continue;
339
+ }
340
+ if (ch === "`") {
341
+ i = take(extractBacktick(line, i), i);
214
342
  }
343
+ continue;
344
+ }
345
+ if (ch === "\\" && i + 1 < line.length) {
346
+ i += 1;
347
+ continue;
348
+ }
349
+ if (ch === "$" && line[i + 1] === "'") {
350
+ ansiC = true;
351
+ i += 1;
352
+ continue;
353
+ }
354
+ if (ch === "'" || ch === '"') {
355
+ quote = ch;
356
+ continue;
357
+ }
358
+ if (ch === "$" && line[i + 1] === "(") {
359
+ i = take(extractParenBody(line, i + 1), i);
360
+ continue;
361
+ }
362
+ if ((ch === "<" || ch === ">") && line[i + 1] === "(") {
363
+ i = take(extractParenBody(line, i + 1), i);
364
+ continue;
365
+ }
366
+ if (ch === "`") {
367
+ i = take(extractBacktick(line, i), i);
215
368
  }
216
369
  }
217
370
  return bodies;
218
371
  }
219
372
  function collectSegments(line) {
220
373
  const texts = [];
221
- const pending = [line];
374
+ const pending = [stripHeredocBodies(line)];
222
375
  while (pending.length > 0) {
223
376
  const current = pending.pop();
224
377
  if (current === undefined || texts.includes(current)) {
@@ -226,6 +379,9 @@ function collectSegments(line) {
226
379
  }
227
380
  texts.push(current);
228
381
  pending.push(...substitutionBodies(current));
382
+ pending.push(...hereStringBodies(current));
383
+ pending.push(...pipedLiteralScripts(current));
384
+ pending.push(...processSubScripts(current));
229
385
  }
230
386
  const segments = [];
231
387
  for (const segment of texts.flatMap(splitSegments)) {
@@ -338,7 +494,15 @@ function lexSegment(segment) {
338
494
  }
339
495
  if (c === "\\" && i + 1 < n) {
340
496
  const nx = segment[i + 1] ?? "";
341
- if (nx === '"' || nx === "\\" || nx === "`" || nx === "$" || nx === "\n") {
497
+ if (nx === "\n") {
498
+ i += 2;
499
+ continue;
500
+ }
501
+ if (nx === "\r") {
502
+ i += segment[i + 2] === "\n" ? 3 : 2;
503
+ continue;
504
+ }
505
+ if (nx === '"' || nx === "\\" || nx === "`" || nx === "$") {
342
506
  cur += nx;
343
507
  i += 2;
344
508
  continue;
@@ -384,6 +548,15 @@ function lexSegment(segment) {
384
548
  continue;
385
549
  }
386
550
  if (ch === "\\") {
551
+ const nx = segment[i + 1];
552
+ if (nx === "\n") {
553
+ i += 2;
554
+ continue;
555
+ }
556
+ if (nx === "\r") {
557
+ i += segment[i + 2] === "\n" ? 3 : 2;
558
+ continue;
559
+ }
387
560
  if (i + 1 < n) {
388
561
  cur += segment[i + 1];
389
562
  started = true;
@@ -431,6 +604,11 @@ function lexSegment(segment) {
431
604
  return { tokens, ok: true };
432
605
  }
433
606
  const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
607
+ const BWRAP_SPEC = {
608
+ valueFlags: new Set(["--args", "--argv0", "--chdir", "--unsetenv", "--uid", "--gid", "--hostname", "--tmpfs", "--dir", "--proc", "--dev", "--mqueue", "--remount-ro", "--size", "--perms", "--seccomp", "--userns", "--userns2", "--pidns", "--cap-add", "--cap-drop", "--exec-label", "--file-label", "--block-fd", "--userns-block-fd", "--info-fd", "--json-status-fd", "--sync-fd", "--overlay-src", "--tmp-overlay", "--ro-overlay"]),
609
+ dualValueFlags: new Set(["--bind", "--ro-bind", "--dev-bind", "--bind-try", "--ro-bind-try", "--dev-bind-try", "--bind-fd", "--ro-bind-fd", "--bind-data", "--ro-bind-data", "--file", "--symlink", "--setenv", "--chmod"]),
610
+ tripleValueFlags: new Set(["--overlay"]),
611
+ };
434
612
  const WRAPPERS = new Map([
435
613
  ["sudo", { valueFlags: new Set(["-u", "--user", "-g", "--group", "-p", "--prompt", "-C", "-h", "--host", "-R", "--chroot", "-D", "--chdir"]) }],
436
614
  ["doas", { valueFlags: new Set(["-u", "-C", "-a"]) }],
@@ -444,31 +622,534 @@ const WRAPPERS = new Map([
444
622
  ["ionice", { valueFlags: new Set(["-c", "--class", "-n", "--classdata", "-p", "--pid"]) }],
445
623
  ["nohup", { valueFlags: new Set() }],
446
624
  ["setsid", { valueFlags: new Set() }],
447
- ["chrt", { valueFlags: new Set(["-T", "-P", "-D"]) }],
625
+ ["chrt", { valueFlags: new Set(["-T", "-P", "-D"]), bareValue: /^\d+$/ }],
448
626
  ["time", { valueFlags: new Set(["-o", "--output", "-f", "--format"]) }],
449
627
  ["timeout", { valueFlags: new Set(["-s", "--signal", "-k", "--kill-after"]), bareValue: /^\d+(\.\d+)?[smhd]?$/ }],
450
628
  ["stdbuf", { valueFlags: new Set(["-i", "--input", "-o", "--output", "-e", "--error"]) }],
629
+ // macOS keeps-awake prefix (`caffeinate npm i x`) — a built-in on the platform
630
+ // dg ships to, so a real exec wrapper that otherwise waves the install through.
631
+ ["caffeinate", { valueFlags: new Set(["-t", "-w"]) }],
632
+ // macOS architecture launcher (`arch -x86_64 npm i x`, `arch -arch x86_64 …`).
633
+ ["arch", { valueFlags: new Set(["-arch"]) }],
634
+ ["eatmydata", { valueFlags: new Set() }],
635
+ ["proxychains", { valueFlags: new Set(["-f"]) }],
636
+ ["proxychains4", { valueFlags: new Set(["-f"]) }],
637
+ ["torsocks", { valueFlags: new Set(["-p", "--port", "-a", "--address", "-c", "--config"]) }],
638
+ ["numactl", { valueFlags: new Set(["-N", "--cpunodebind", "-m", "--membind", "-C", "--physcpubind", "-i", "--interleave", "-p", "--preferred", "-s", "--shm"]) }],
639
+ // GNU watch runs its operand through `sh -c`, so `watch npm install x` is a real
640
+ // install; the remaining tokens ARE that command, so a plain unwrap is correct.
641
+ ["watch", { valueFlags: new Set(["-n", "--interval"]) }],
642
+ // taskset/setarch carry one bare operand (a CPU mask / an arch name) before the
643
+ // command; a package-manager head never matches those shapes, so consuming the
644
+ // shaped operand cannot swallow the real command head.
645
+ ["taskset", { valueFlags: new Set(["-c", "--cpu-list", "-p", "--pid"]), bareValue: /^(?:0x[0-9a-fA-F]+|[0-9][0-9,-]*)$/ }],
646
+ ["setarch", { valueFlags: new Set(), bareValue: /^(?:i[3-6]86|x86_64|ppc(?:64(?:le)?)?|s390x?|sparc(?:64)?|mips(?:64)?|ia64|aarch64|arm(?:v[0-9]+l)?|linux32|linux64|uname-2\.6)$/ }],
647
+ // `runuser -u user -- npm i x` execs the command after `--` directly (the `--`
648
+ // token starts with `-`, so the wrapper loop consumes it); the `-c '<script>'`
649
+ // shell form is caught by the su/runuser/flock branch in commandTokens first.
650
+ ["runuser", { valueFlags: new Set(["-u", "--user", "-g", "--group", "-G", "--supp-group", "-s", "--shell"]) }],
651
+ // PolicyKit sibling of sudo/doas; container init/exec wrappers. gosu/su-exec
652
+ // carry one bare user[:group] operand and are handled in commandTokens below.
653
+ ["pkexec", { valueFlags: new Set(["--user"]) }],
654
+ ["tini", { valueFlags: new Set(["-e", "-p"]) }],
655
+ ["dumb-init", { valueFlags: new Set(["-r", "--rewrite"]) }],
451
656
  ["xargs", { valueFlags: new Set(["-a", "--arg-file", "-d", "--delimiter", "-E", "-e", "--eof", "-I", "-i", "--replace", "-L", "-l", "--max-lines", "-n", "--max-args", "-P", "--max-procs", "-s", "--max-chars"]) }],
657
+ // busybox is a multi-call binary: `busybox sh -c '…'` runs the sh applet. With
658
+ // no value flags the applet name becomes the next head, so `sh` re-enters the
659
+ // SHELL_EXEC `-c` extraction below.
660
+ ["busybox", { valueFlags: new Set() }],
661
+ ["unshare", { valueFlags: new Set(["-S", "--setuid", "-G", "--setgid", "--map-user", "--map-group", "--setgroups", "-w", "--wd"]) }],
662
+ ["nsenter", { valueFlags: new Set(["-t", "--target", "-S", "--setuid", "-G", "--setgid", "-w", "--wd"]) }],
663
+ ["setpriv", { valueFlags: new Set(["--reuid", "--regid", "--groups", "--bounding-set", "--inh-caps", "--ambient-caps", "--securebits", "--pdeathsig", "--selinux-label", "--apparmor-profile"]) }],
664
+ ["flatpak-spawn", { valueFlags: new Set(["--env", "--directory", "--forward-fd"]) }],
665
+ // bwrap/bubblewrap is the standard Linux sandbox (Flatpak/CI). Its bind/setenv
666
+ // family takes TWO operands (SRC DEST / VAR VALUE) and --overlay takes THREE, so
667
+ // the multi-value sets are load-bearing: a short skip parks on a later operand
668
+ // and waves the wrapped `npm install` through. Zero-arg flags (--unshare-*,
669
+ // --die-with-parent, --clearenv, …) are intentionally absent so they are
670
+ // consumed flag-only. Shared by both spellings so the two can't drift.
671
+ ["bwrap", BWRAP_SPEC],
672
+ ["bubblewrap", BWRAP_SPEC],
673
+ ]);
674
+ // Shells that run their `-c` operand as a command (so `ksh -c 'npm i x'` must be
675
+ // re-parsed). script(1) runs its `-c`/`--command` value the same way.
676
+ const SHELL_EXEC = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "mksh", "fish", "script"]);
677
+ // Commands that take a package-manager NAME as literal DATA (search/print/inspect),
678
+ // not as a command to run. The unknown-wrapper backstop skips these so a benign
679
+ // `grep npm install …` / `echo npm install` is not deferred.
680
+ const DATA_HEADS = new Set([
681
+ "echo", "printf", "grep", "egrep", "fgrep", "rg", "ag", "cat", "man", "which", "type",
682
+ "whereis", "find", "ls", "head", "tail", "sed", "awk", "test", "[", "basename", "dirname",
683
+ "wc", "sort", "uniq", "tr", "cut", "tee", "less", "more", "cmp", "diff",
452
684
  ]);
453
- const SHELL_EXEC = new Set(["sh", "bash", "zsh", "dash", "ash"]);
454
- // Control-flow keywords that introduce a command (`if …; then <cmd>`, `for …;
455
- // do <cmd>`, `! <cmd>`). The segmenter splits on `;`, so the command after one
456
- // lands at the head of its segment behind the keyword — strip it or a real
457
- // `do npm install <pkg>` is read as the no-op command `do`.
458
- const SHELL_KEYWORDS = new Set(["then", "do", "else", "elif", "!"]);
685
+ // Control-flow keywords that introduce a command. The segmenter splits on `;`,
686
+ // so the command after one lands at the head of its segment behind the keyword
687
+ // strip it or a real install behind it is read as the no-op keyword. This
688
+ // covers both the body introducers (`then`/`do`/`else`/`elif`) and the
689
+ // condition introducers (`if`/`while`/`until`): bash runs the condition pipeline
690
+ // of `if npm install x; then …` and `until npm install x; do …`, so the install
691
+ // there must be classified, not waved through. `!` negates the next command.
692
+ const SHELL_KEYWORDS = new Set(["then", "do", "else", "elif", "!", "if", "while", "until"]);
459
693
  // A `case … in <pat>) <cmd> ;;` arm label, e.g. `a)`, `*)`, `*.js)`, `a|b)`.
460
694
  const CASE_LABEL = /^[A-Za-z0-9_*?.\][|@!+-]+\)$/;
695
+ // Commands that run their heredoc body as shell input. `.`/`source` read it as a
696
+ // script; `eval` evaluates it; the shells exec it. A heredoc fed to one of these
697
+ // (`sh <<EOF … EOF`, `. /dev/stdin <<EOF`) executes the body, so the body must be
698
+ // screened. A heredoc to a data sink (cat/tee/wc/ssh) is inert text.
699
+ const HEREDOC_EXEC_HEADS = new Set([...SHELL_EXEC, "eval", "source", "."]);
700
+ // Locate heredoc introducers (`<<WORD`, `<<-WORD`, `<<"WORD"`, `<<'WORD'`) on a
701
+ // line, quote-aware so a `<<` inside a string is not one, and excluding the
702
+ // here-string `<<<`.
703
+ function heredocDelimiters(introLine) {
704
+ const result = [];
705
+ let quote = null;
706
+ for (let i = 0; i < introLine.length; i += 1) {
707
+ const ch = introLine[i] ?? "";
708
+ if (quote) {
709
+ if (ch === "\\" && quote === '"' && i + 1 < introLine.length) {
710
+ i += 1;
711
+ }
712
+ else if (ch === quote) {
713
+ quote = null;
714
+ }
715
+ continue;
716
+ }
717
+ if (ch === "\\") {
718
+ i += 1;
719
+ continue;
720
+ }
721
+ if (ch === "'" || ch === '"') {
722
+ quote = ch;
723
+ continue;
724
+ }
725
+ if (ch === "<" && introLine[i + 1] === "<" && introLine[i + 2] !== "<") {
726
+ let j = i + 2;
727
+ let strip = false;
728
+ if (introLine[j] === "-") {
729
+ strip = true;
730
+ j += 1;
731
+ }
732
+ while (introLine[j] === " " || introLine[j] === "\t") {
733
+ j += 1;
734
+ }
735
+ let wordQuote = null;
736
+ if (introLine[j] === '"' || introLine[j] === "'") {
737
+ wordQuote = introLine[j] ?? null;
738
+ j += 1;
739
+ }
740
+ let delim = "";
741
+ while (j < introLine.length) {
742
+ const c = introLine[j] ?? "";
743
+ if (wordQuote) {
744
+ if (c === wordQuote) {
745
+ j += 1;
746
+ break;
747
+ }
748
+ delim += c;
749
+ j += 1;
750
+ continue;
751
+ }
752
+ if (/[A-Za-z0-9_]/.test(c)) {
753
+ delim += c;
754
+ j += 1;
755
+ continue;
756
+ }
757
+ break;
758
+ }
759
+ if (delim) {
760
+ result.push({ delim, strip });
761
+ i = j - 1;
762
+ }
763
+ }
764
+ }
765
+ return result;
766
+ }
767
+ function hasUnquotedPipe(line) {
768
+ let quote = null;
769
+ for (let i = 0; i < line.length; i += 1) {
770
+ const ch = line[i] ?? "";
771
+ if (quote) {
772
+ if (ch === "\\" && quote === '"' && i + 1 < line.length) {
773
+ i += 1;
774
+ }
775
+ else if (ch === quote) {
776
+ quote = null;
777
+ }
778
+ continue;
779
+ }
780
+ if (ch === "\\") {
781
+ i += 1;
782
+ continue;
783
+ }
784
+ if (ch === "'" || ch === '"') {
785
+ quote = ch;
786
+ continue;
787
+ }
788
+ if (ch === "|") {
789
+ return true;
790
+ }
791
+ }
792
+ return false;
793
+ }
794
+ // Would the shell run the heredoc body attached to this introducer line? A pipe
795
+ // could feed it to a downstream shell (fail closed), and any command whose real
796
+ // head (wrappers unwrapped: `env sh`, `timeout sh`, `xargs sh`) is a shell/eval
797
+ // /source executes it. Otherwise the body is inert data to a sink.
798
+ function introducerExecutesBody(introLine) {
799
+ if (hasUnquotedPipe(introLine)) {
800
+ return true;
801
+ }
802
+ for (const seg of splitSegments(introLine)) {
803
+ const head = commandBasename(commandTokens(seg).tokens[0] ?? "");
804
+ if (HEREDOC_EXEC_HEADS.has(head)) {
805
+ return true;
806
+ }
807
+ }
808
+ return false;
809
+ }
810
+ // Drop heredoc bodies the shell never executes so their lines are not mis-read as
811
+ // command segments (`cat <<EOF … npm install x … EOF` is data to cat, not an
812
+ // install). Bodies a shell would run are kept for screening; an unterminated
813
+ // heredoc is kept too (fail closed).
814
+ function stripHeredocBodies(line) {
815
+ if (!line.includes("<<")) {
816
+ return line;
817
+ }
818
+ const physical = line.split("\n");
819
+ const out = [];
820
+ let i = 0;
821
+ while (i < physical.length) {
822
+ const cur = physical[i] ?? "";
823
+ out.push(cur);
824
+ i += 1;
825
+ const introducers = heredocDelimiters(cur);
826
+ if (introducers.length === 0) {
827
+ continue;
828
+ }
829
+ const executes = introducerExecutesBody(cur);
830
+ for (const introducer of introducers) {
831
+ const body = [];
832
+ let terminated = false;
833
+ while (i < physical.length) {
834
+ const bodyLine = physical[i] ?? "";
835
+ const probe = introducer.strip ? bodyLine.replace(/^\t+/, "") : bodyLine;
836
+ i += 1;
837
+ if (probe === introducer.delim) {
838
+ terminated = true;
839
+ break;
840
+ }
841
+ body.push(bodyLine);
842
+ }
843
+ if (executes || !terminated) {
844
+ out.push(...body);
845
+ }
846
+ }
847
+ }
848
+ return out.join("\n");
849
+ }
850
+ // Quote/escape-aware index of the first `<<<` here-string operator, or -1.
851
+ function firstHereString(seg) {
852
+ let quote = null;
853
+ for (let i = 0; i < seg.length; i += 1) {
854
+ const ch = seg[i] ?? "";
855
+ if (quote === "'") {
856
+ if (ch === "'")
857
+ quote = null;
858
+ continue;
859
+ }
860
+ if (quote === '"') {
861
+ if (ch === "\\" && i + 1 < seg.length) {
862
+ i += 1;
863
+ continue;
864
+ }
865
+ if (ch === '"')
866
+ quote = null;
867
+ continue;
868
+ }
869
+ if (ch === "\\" && i + 1 < seg.length) {
870
+ i += 1;
871
+ continue;
872
+ }
873
+ if (ch === "'" || ch === '"') {
874
+ quote = ch;
875
+ continue;
876
+ }
877
+ if (ch === "<" && seg[i + 1] === "<" && seg[i + 2] === "<")
878
+ return i;
879
+ }
880
+ return -1;
881
+ }
882
+ // `<head> <<< WORD` feeds WORD to head's stdin; a shell/eval/source head runs it
883
+ // as a script. The lexer drops the `<<<` redirection target, and heredoc parsing
884
+ // skips `<<<`, so an install hidden in a here-string is never screened. Recover
885
+ // the (dequoted) word for those heads so it is re-parsed; a here-string to a sink
886
+ // (cat/grep) is inert and ignored.
887
+ function hereStringBodies(line) {
888
+ const out = [];
889
+ for (const seg of splitSegments(line)) {
890
+ const at = firstHereString(seg);
891
+ if (at < 0) {
892
+ continue;
893
+ }
894
+ const head = commandBasename(commandTokens(seg.slice(0, at)).tokens[0] ?? "");
895
+ if (!HEREDOC_EXEC_HEADS.has(head)) {
896
+ continue;
897
+ }
898
+ const word = lexSegment(seg.slice(at + 3)).tokens[0];
899
+ if (word) {
900
+ out.push(word);
901
+ }
902
+ }
903
+ return out;
904
+ }
905
+ // Split a line into pipeline stages, recording whether each stage feeds the next
906
+ // through a single `|` (not `||`/`;`/`&&`/`&`). Quote/escape/ANSI-C aware so a
907
+ // `|` inside a string is data, not a stage boundary.
908
+ function splitPipeStages(line) {
909
+ const stages = [];
910
+ let buf = "";
911
+ let quote = null;
912
+ let ansiC = false;
913
+ const parenTracked = [];
914
+ let substDepth = 0;
915
+ const push = (pipedToNext) => {
916
+ stages.push({ text: buf.trim(), pipedToNext });
917
+ buf = "";
918
+ };
919
+ for (let i = 0; i < line.length; i += 1) {
920
+ const ch = line[i] ?? "";
921
+ if (ansiC) {
922
+ if (ch === "\\" && i + 1 < line.length) {
923
+ buf += ch + line[i + 1];
924
+ i += 1;
925
+ continue;
926
+ }
927
+ buf += ch;
928
+ if (ch === "'")
929
+ ansiC = false;
930
+ continue;
931
+ }
932
+ if (quote === "'") {
933
+ buf += ch;
934
+ if (ch === "'")
935
+ quote = null;
936
+ continue;
937
+ }
938
+ if (quote === '"') {
939
+ if (ch === "\\" && i + 1 < line.length) {
940
+ buf += ch + line[i + 1];
941
+ i += 1;
942
+ continue;
943
+ }
944
+ buf += ch;
945
+ if (ch === '"')
946
+ quote = null;
947
+ continue;
948
+ }
949
+ if (ch === "$" && line[i + 1] === "'") {
950
+ ansiC = true;
951
+ buf += "$'";
952
+ i += 1;
953
+ continue;
954
+ }
955
+ if (ch === "\\" && i + 1 < line.length) {
956
+ buf += ch + line[i + 1];
957
+ i += 1;
958
+ continue;
959
+ }
960
+ if (ch === "'" || ch === '"') {
961
+ quote = ch;
962
+ buf += ch;
963
+ continue;
964
+ }
965
+ if (ch === "(") {
966
+ const tracked = line[i - 1] === "<" || line[i - 1] === ">" || line[i - 1] === "$";
967
+ parenTracked.push(tracked);
968
+ if (tracked)
969
+ substDepth += 1;
970
+ buf += ch;
971
+ continue;
972
+ }
973
+ if (ch === ")") {
974
+ if (parenTracked.pop() === true && substDepth > 0)
975
+ substDepth -= 1;
976
+ buf += ch;
977
+ continue;
978
+ }
979
+ if (substDepth > 0) {
980
+ buf += ch;
981
+ continue;
982
+ }
983
+ if (ch === "|") {
984
+ if (line[i + 1] === "|") {
985
+ push(false);
986
+ i += 1;
987
+ continue;
988
+ }
989
+ push(true);
990
+ continue;
991
+ }
992
+ if (ch === "\n" || ch === "\r" || ch === ";") {
993
+ push(false);
994
+ continue;
995
+ }
996
+ if (ch === "&") {
997
+ if (line[i + 1] === "&") {
998
+ push(false);
999
+ i += 1;
1000
+ continue;
1001
+ }
1002
+ if (line[i - 1] === ">" || line[i + 1] === ">") {
1003
+ buf += ch;
1004
+ continue;
1005
+ }
1006
+ push(false);
1007
+ continue;
1008
+ }
1009
+ buf += ch;
1010
+ }
1011
+ push(false);
1012
+ return stages;
1013
+ }
1014
+ // `echo/printf "<script>" | … | sh` (bash/eval/source/.): the printed literal is
1015
+ // run by the downstream shell, so the install in it must be screened. Scan the
1016
+ // WHOLE pipeline (not just the stage adjacent to the shell) so an interposed
1017
+ // passthrough filter (`echo x | cat | sh`) can't hide the producer, and emit
1018
+ // several candidate scripts per producer so a printf format string
1019
+ // (`printf '%s\n' 'npm install evil'`) can't shield the install. A pipeline into
1020
+ // a non-shell (grep/wc) stays inert.
1021
+ function pipedLiteralScripts(line) {
1022
+ const out = [];
1023
+ const stages = splitPipeStages(line);
1024
+ let i = 0;
1025
+ while (i < stages.length) {
1026
+ let j = i;
1027
+ while (j < stages.length && stages[j]?.pipedToNext) {
1028
+ j += 1;
1029
+ }
1030
+ const pipeline = stages.slice(i, j + 1);
1031
+ i = j + 1;
1032
+ if (pipeline.length < 2) {
1033
+ continue;
1034
+ }
1035
+ const lastHead = commandBasename(commandTokens(pipeline[pipeline.length - 1]?.text ?? "").tokens[0] ?? "");
1036
+ if (!HEREDOC_EXEC_HEADS.has(lastHead)) {
1037
+ continue;
1038
+ }
1039
+ for (let k = 0; k < pipeline.length - 1; k += 1) {
1040
+ const stageText = pipeline[k]?.text ?? "";
1041
+ const prod = commandTokens(stageText);
1042
+ const phead = commandBasename(prod.tokens[0] ?? "");
1043
+ // A producer that relays a process-sub file's bytes downstream
1044
+ // (`cat <(echo npm i evil) | sh`) feeds the printed text to the shell too;
1045
+ // already scoped to a shell-terminated pipeline by the lastHead guard above.
1046
+ out.push(...procSubLiteralBodies(stageText));
1047
+ if (phead !== "echo" && phead !== "printf") {
1048
+ continue;
1049
+ }
1050
+ const args = prod.tokens.slice(1).filter((t) => !t.startsWith("-"));
1051
+ if (args.length === 0) {
1052
+ continue;
1053
+ }
1054
+ out.push(args.join(" "));
1055
+ if (phead === "printf" && args.length > 1) {
1056
+ // printf's first non-flag arg is the format; the install may live in the
1057
+ // operands it substitutes, so also screen the operands alone.
1058
+ out.push(args.slice(1).join(" "));
1059
+ for (const operand of args.slice(1)) {
1060
+ out.push(operand);
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+ return out;
1066
+ }
1067
+ // `<head> <(echo|printf …)` — a shell/eval/source head runs the PRODUCER's output
1068
+ // as a script (bash reads the /dev/fd the substitution resolves to). The lexer
1069
+ // drops `<(…)` as a redirection, so the install in the producer is otherwise
1070
+ // never screened. This is the process-substitution sibling of the `echo … | sh`
1071
+ // (pipedLiteralScripts) and `sh <<< …` (hereStringBodies) cases.
1072
+ function procSubLiteralBodies(text, depth = 0) {
1073
+ const out = [];
1074
+ if (depth > 8) {
1075
+ return out;
1076
+ }
1077
+ let quote = null;
1078
+ for (let i = 0; i < text.length; i += 1) {
1079
+ const ch = text[i] ?? "";
1080
+ if (quote) {
1081
+ if (ch === "\\" && quote === '"' && i + 1 < text.length) {
1082
+ i += 1;
1083
+ continue;
1084
+ }
1085
+ if (ch === quote) {
1086
+ quote = null;
1087
+ }
1088
+ continue;
1089
+ }
1090
+ if (ch === "\\" && i + 1 < text.length) {
1091
+ i += 1;
1092
+ continue;
1093
+ }
1094
+ if (ch === "'" || ch === '"') {
1095
+ quote = ch;
1096
+ continue;
1097
+ }
1098
+ if ((ch === "<" || ch === ">") && text[i + 1] === "(") {
1099
+ const body = extractParenBody(text, i + 1);
1100
+ if (!body) {
1101
+ continue;
1102
+ }
1103
+ // The body is run and its OUTPUT becomes the consumer's script, so harvest
1104
+ // an echo/printf literal from ANY pipe stage of the body (`<(true | echo
1105
+ // npm i evil)`), not just its head. A relay producer wrapping a nested
1106
+ // process-sub (`<(cat <(echo npm i evil))`) is reached by the recursion.
1107
+ for (const stage of splitPipeStages(body.text)) {
1108
+ const prod = commandTokens(stage.text);
1109
+ const phead = commandBasename(prod.tokens[0] ?? "");
1110
+ if (phead === "echo" || phead === "printf") {
1111
+ const args = prod.tokens.slice(1).filter((t) => !t.startsWith("-"));
1112
+ if (args.length > 0) {
1113
+ out.push(args.join(" "));
1114
+ if (phead === "printf" && args.length > 1) {
1115
+ out.push(args.slice(1).join(" "));
1116
+ for (const operand of args.slice(1)) {
1117
+ out.push(operand);
1118
+ }
1119
+ }
1120
+ }
1121
+ }
1122
+ }
1123
+ out.push(...procSubLiteralBodies(body.text, depth + 1));
1124
+ i = body.end;
1125
+ }
1126
+ }
1127
+ return out;
1128
+ }
1129
+ function processSubScripts(line) {
1130
+ const out = [];
1131
+ for (const seg of splitSegments(line)) {
1132
+ const head = commandBasename(commandTokens(seg).tokens[0] ?? "");
1133
+ if (!HEREDOC_EXEC_HEADS.has(head)) {
1134
+ continue;
1135
+ }
1136
+ out.push(...procSubLiteralBodies(seg));
1137
+ }
1138
+ return out;
1139
+ }
461
1140
  // Package managers dg doesn't statically screen (no shim, no classifier). A
462
1141
  // recognized install verb for one of these can't be screened by the hook, so it
463
1142
  // defers to the runtime gate rather than waving it through silently.
464
1143
  const UNSUPPORTED_INSTALLS = new Map([
465
- ["bun", new Set(["add", "install", "i", "x"])],
466
- ["deno", new Set(["install", "add"])],
467
- ["poetry", new Set(["add", "install"])],
468
- ["pdm", new Set(["add", "install"])],
469
- ["conda", new Set(["install", "create"])],
470
- ["mamba", new Set(["install", "create"])],
471
- ["gem", new Set(["install", "i"])],
1144
+ ["bun", new Set(["add", "install", "i", "x", "create"])],
1145
+ ["deno", new Set(["install", "add", "cache"])],
1146
+ ["poetry", new Set(["add", "install", "update"])],
1147
+ ["pdm", new Set(["add", "install", "update"])],
1148
+ ["conda", new Set(["install", "create", "update"])],
1149
+ ["mamba", new Set(["install", "create", "update"])],
1150
+ ["micromamba", new Set(["install", "create", "update"])],
1151
+ ["pipenv", new Set(["install", "update", "sync"])],
1152
+ ["gem", new Set(["install", "i", "fetch"])],
472
1153
  // `go install`/`go get` fetch and build remote modules; `go build`/`run`/`test`
473
1154
  // operate on already-resolved local code, so they stay passthrough.
474
1155
  ["go", new Set(["install", "get"])],
@@ -477,10 +1158,64 @@ const UNSUPPORTED_INSTALLS = new Map([
477
1158
  // fetched and executed, so a recognized-but-unsupported runner with a target
478
1159
  // defers to the runtime gate instead of a silent allow (`bunx <pkg>`).
479
1160
  const UNSUPPORTED_RUNNERS = new Set(["bunx"]);
1161
+ // Verb-form fetch-and-run launchers: `npm exec <tool> <tool-args…>`, `pnpm dlx`,
1162
+ // `yarn dlx`, `pnpm exec`, `pipx run`. Only the first positional (the fetched
1163
+ // tool) is a package; the rest are arguments to that tool, not packages to
1164
+ // screen. (The no-verb forms `npx`/`pnpx`/`uvx` already slice to the first.)
1165
+ const RUNNER_VERBS = new Set(["exec", "dlx", "run"]);
1166
+ // Install/fetch subcommands across the managers. Used only to decide whether a
1167
+ // segment whose head is dynamic (`$M install x`, `$(which npm) install x`)
1168
+ // looks like an install and must defer rather than wave through.
1169
+ const INSTALL_VERBS = new Set([
1170
+ "install", "i", "ci", "add", "get", "download", "wheel", "sync", "upgrade", "dlx", "inject", "create",
1171
+ "exec", "x", "up", "update", "fetch", "dedupe", "tool",
1172
+ "in", "ins", "inst", "insta", "instal", "isnt", "isnta", "isntal", "isntall", "it", "cit", "sit", "ic",
1173
+ ]);
1174
+ // `${IFS}` / `$IFS` expand to whitespace, so an attacker glues a command with
1175
+ // them to dodge word-splitting (`npm${IFS}install${IFS}evil`). Default IFS is
1176
+ // space/tab/newline; normalize the obfuscation back to a space before lexing.
1177
+ // `$IFS` is only the variable when not followed by another word character.
1178
+ const IFS_OBFUSCATION = /\$\{IFS\}|\$IFS(?![A-Za-z0-9_])/g;
480
1179
  function commandBasename(token) {
481
1180
  const slash = token.lastIndexOf("/");
482
1181
  return slash === -1 ? token : token.slice(slash + 1);
483
1182
  }
1183
+ // Bash allows whitespace between a function name and its `()` (`f () {…}`,
1184
+ // `f ( ) {…}`, `function f () {…}`), which the lexer splits into separate
1185
+ // `(`/`)`/`()`/`(){` tokens. Collapse that leading run into the glued `name()`
1186
+ // shape so the function-definition strip in commandTokens recognizes it. Only
1187
+ // fires when a `{` body follows the parens — a subshell command list `(cmd)`
1188
+ // has no name token before the paren, so it is untouched.
1189
+ function glueFunctionDefParens(tokens) {
1190
+ const i = tokens[0] === "function" ? 1 : 0;
1191
+ const name = tokens[i] ?? "";
1192
+ if (!/^[A-Za-z_][\w-]*$/.test(name)) {
1193
+ return [...tokens];
1194
+ }
1195
+ let j = i + 1;
1196
+ let sawParen = false;
1197
+ while (j < tokens.length && (tokens[j] === "(" || tokens[j] === ")")) {
1198
+ sawParen = true;
1199
+ j += 1;
1200
+ }
1201
+ let bracePart = "";
1202
+ if (j < tokens.length && (tokens[j] === "()" || tokens[j] === "(){")) {
1203
+ sawParen = true;
1204
+ if (tokens[j] === "(){") {
1205
+ bracePart = "{";
1206
+ }
1207
+ j += 1;
1208
+ }
1209
+ if (!sawParen || j === i + 1) {
1210
+ return [...tokens];
1211
+ }
1212
+ const braceFollows = bracePart === "{" || (j < tokens.length && (tokens[j] === "{" || (tokens[j] ?? "").startsWith("{")));
1213
+ if (!braceFollows) {
1214
+ return [...tokens];
1215
+ }
1216
+ const collapsed = bracePart === "{" ? `${name}(){` : `${name}()`;
1217
+ return [...tokens.slice(0, i), collapsed, ...tokens.slice(j)];
1218
+ }
484
1219
  // Drop shell grouping punctuation: a subshell `(…)`, a brace group `{ …; }`, and
485
1220
  // stray `;` so the wrapped command underneath is reached.
486
1221
  function stripGrouping(tokens) {
@@ -492,15 +1227,40 @@ function stripGrouping(tokens) {
492
1227
  return out.filter((t) => t.length > 0);
493
1228
  }
494
1229
  function commandTokens(segment) {
495
- const lexed = lexSegment(segment);
496
- let tokens = stripGrouping(lexed.tokens);
1230
+ const lexed = lexSegment(segment.replace(IFS_OBFUSCATION, " "));
1231
+ const normalized = glueFunctionDefParens(lexed.tokens);
1232
+ let tokens = stripGrouping(normalized);
497
1233
  let viaXargs = false;
498
- const x = () => (viaXargs ? { viaXargs: true } : {});
1234
+ const inlineEnv = {};
1235
+ const x = () => ({
1236
+ ...(viaXargs ? { viaXargs: true } : {}),
1237
+ ...(Object.keys(inlineEnv).length > 0 ? { inlineEnv } : {}),
1238
+ });
499
1239
  for (;;) {
500
1240
  while (tokens.length > 0 && ENV_ASSIGNMENT.test(tokens[0] ?? "")) {
1241
+ // An inline per-command assignment (`NPM_CONFIG_REGISTRY=https://evil npm i x`,
1242
+ // also behind `env VAR=val …`) is applied by the shell to the install, so a
1243
+ // registry/index override here must defer just like the --flag / process-env
1244
+ // forms. Capture it for the alternate-index check rather than dropping it.
1245
+ const assignment = tokens[0] ?? "";
1246
+ const eq = assignment.indexOf("=");
1247
+ inlineEnv[assignment.slice(0, eq)] = assignment.slice(eq + 1);
501
1248
  tokens = tokens.slice(1);
502
1249
  }
503
1250
  const kw = tokens[0] ?? "";
1251
+ if (kw === "coproc") {
1252
+ // `coproc <cmd>`, anonymous `coproc { <cmd>; }`, and named `coproc NAME
1253
+ // { <cmd>; }`. Only the NAMED form has a name token to drop — detect it in
1254
+ // the pre-stripGrouping order (a bare name immediately followed by `{`), so
1255
+ // the anonymous form's command head (`npm`) is never mistaken for the name.
1256
+ const ci = normalized.indexOf("coproc");
1257
+ const named = ci >= 0 && normalized[ci + 2] === "{" && /^[A-Za-z_]\w*$/.test(normalized[ci + 1] ?? "");
1258
+ tokens = tokens.slice(1);
1259
+ if (named) {
1260
+ tokens = tokens.slice(1);
1261
+ }
1262
+ continue;
1263
+ }
504
1264
  if (SHELL_KEYWORDS.has(kw)) {
505
1265
  tokens = tokens.slice(1);
506
1266
  continue;
@@ -516,13 +1276,52 @@ function commandTokens(segment) {
516
1276
  tokens = tokens.slice(1);
517
1277
  continue;
518
1278
  }
1279
+ // A function DEFINITION whose body holds a real install: `f(){ npm i x; }`,
1280
+ // `f() { … }`, `function f { … }`, `function f() { … }`. The segmenter split
1281
+ // on `;`, so the body lands in this segment behind the def prefix — strip the
1282
+ // prefix (and any glued `{`) so the body command is classified, not the def.
1283
+ if (kw === "function") {
1284
+ tokens = tokens.slice(1);
1285
+ if (/^[A-Za-z_][\w-]*(?:\(\))?\{?$/.test(tokens[0] ?? "")) {
1286
+ tokens = tokens.slice(1);
1287
+ }
1288
+ continue;
1289
+ }
1290
+ if (/^[A-Za-z_][\w-]*\(\)\{?$/.test(kw)) {
1291
+ tokens = tokens.slice(1);
1292
+ continue;
1293
+ }
519
1294
  const head = commandBasename(tokens[0] ?? "");
520
1295
  if (head === "eval" && tokens.length > 1) {
521
1296
  return { tokens, ok: lexed.ok, shellScript: tokens.slice(1).join(" "), ...x() };
522
1297
  }
1298
+ if (head === "trap") {
1299
+ // `trap 'COMMANDS' SIGSPEC` runs COMMANDS when the trap fires — the same
1300
+ // run-attacker-text-as-script shape as eval/sh -c. Re-parse the action.
1301
+ // Skip the list/print flags (-l/-p) and the `--` separator; the reset form
1302
+ // (`trap - EXIT`) and the bare list forms carry no action to screen.
1303
+ let k = 1;
1304
+ while (k < tokens.length && (tokens[k] ?? "").startsWith("-") && tokens[k] !== "-") {
1305
+ const consumedSeparator = tokens[k] === "--";
1306
+ k += 1;
1307
+ if (consumedSeparator) {
1308
+ break;
1309
+ }
1310
+ }
1311
+ const action = tokens[k];
1312
+ if (action !== undefined && action !== "-") {
1313
+ return { tokens, ok: lexed.ok, shellScript: action, ...x() };
1314
+ }
1315
+ }
523
1316
  if (SHELL_EXEC.has(head)) {
524
1317
  const ci = tokens.indexOf("-c");
525
- const script = ci >= 0 ? tokens[ci + 1] : undefined;
1318
+ let script = ci >= 0 ? tokens[ci + 1] : undefined;
1319
+ if (script === undefined && tokens.slice(1).some((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t))) {
1320
+ // A combined short-flag cluster carrying -c (`bash -lc`, `sh -cx`,
1321
+ // `bash -ic`): bash reads the command string from the first operand
1322
+ // after the option group, so it is the first non-flag token.
1323
+ script = tokens.slice(1).find((t) => !t.startsWith("-"));
1324
+ }
526
1325
  if (script !== undefined) {
527
1326
  return { tokens, ok: lexed.ok, shellScript: script, ...x() };
528
1327
  }
@@ -552,6 +1351,67 @@ function commandTokens(segment) {
552
1351
  continue;
553
1352
  }
554
1353
  }
1354
+ if (head === "env") {
1355
+ // `env -S '<cmd args>'` / `env --split-string=<cmd>` splits the string
1356
+ // into argv and runs it — a real exec path, so re-parse it as a script.
1357
+ for (let k = 1; k < tokens.length; k += 1) {
1358
+ const t = tokens[k] ?? "";
1359
+ const next = tokens[k + 1];
1360
+ if ((t === "-S" || t === "--split-string") && next !== undefined) {
1361
+ return { tokens, ok: lexed.ok, shellScript: next, ...x() };
1362
+ }
1363
+ const glued = /^(?:-S|--split-string=)(.+)$/.exec(t);
1364
+ if (glued && glued[1]) {
1365
+ return { tokens, ok: lexed.ok, shellScript: glued[1], ...x() };
1366
+ }
1367
+ }
1368
+ }
1369
+ if (head === "su" || head === "runuser" || head === "flock") {
1370
+ // su/runuser run a `-c '<script>'` (also `--session-command`) value through
1371
+ // a login shell; flock runs its `-c '<command>'` value through $SHELL. Find
1372
+ // that script and re-parse it so a wrapped install can't hide behind the
1373
+ // privilege / lock prefix.
1374
+ for (let k = 1; k < tokens.length; k += 1) {
1375
+ const t = tokens[k] ?? "";
1376
+ const next = tokens[k + 1];
1377
+ if ((t === "-c" || t === "--command" || t === "--session-command") && next !== undefined) {
1378
+ return { tokens, ok: lexed.ok, shellScript: next, ...x() };
1379
+ }
1380
+ const glued = /^(?:-c|--command=|--session-command=)(.+)$/.exec(t);
1381
+ if (glued && glued[1]) {
1382
+ return { tokens, ok: lexed.ok, shellScript: glued[1], ...x() };
1383
+ }
1384
+ }
1385
+ }
1386
+ if (head === "flock") {
1387
+ // No `-c` form: `flock [opts] <lockfile|fd> <command...>`. Skip flock's own
1388
+ // options (and the values of -w/-E), drop the one mandatory lock operand,
1389
+ // then re-enter the loop with the wrapped command at the head.
1390
+ const flockValueFlags = new Set(["-w", "--timeout", "-E", "--conflict-exit-code"]);
1391
+ let k = 1;
1392
+ while (k < tokens.length && (tokens[k] ?? "").startsWith("-")) {
1393
+ const t = tokens[k] ?? "";
1394
+ k += 1;
1395
+ if (flockValueFlags.has(t) && k < tokens.length && !(tokens[k] ?? "").startsWith("-")) {
1396
+ k += 1;
1397
+ }
1398
+ }
1399
+ if (k < tokens.length) {
1400
+ k += 1;
1401
+ }
1402
+ tokens = tokens.slice(k);
1403
+ continue;
1404
+ }
1405
+ if (head === "gosu" || head === "su-exec") {
1406
+ // `gosu user[:group] <command...>` / `su-exec` — drop the head and the one
1407
+ // mandatory user operand, then re-enter the loop with the wrapped command.
1408
+ let k = 1;
1409
+ if (k < tokens.length && !(tokens[k] ?? "").startsWith("-")) {
1410
+ k += 1;
1411
+ }
1412
+ tokens = tokens.slice(k);
1413
+ continue;
1414
+ }
555
1415
  const spec = WRAPPERS.get(head);
556
1416
  if (head === "" || spec === undefined) {
557
1417
  return { tokens, ok: lexed.ok, ...x() };
@@ -560,35 +1420,57 @@ function commandTokens(segment) {
560
1420
  viaXargs = true;
561
1421
  }
562
1422
  tokens = tokens.slice(1);
563
- if (spec.bareValue && tokens[0] !== undefined && !tokens[0].startsWith("-") && spec.bareValue.test(tokens[0])) {
564
- tokens = tokens.slice(1);
565
- }
566
- while (tokens.length > 0 && (tokens[0] ?? "").startsWith("-")) {
567
- const flag = tokens[0] ?? "";
568
- tokens = tokens.slice(1);
569
- if (spec.valueFlags.has(flag) && tokens.length > 0 && !(tokens[0] ?? "").startsWith("-")) {
1423
+ // Consume leading wrapper option words and a bare value (timeout's
1424
+ // duration, chrt's priority) in any order, so the real command head is
1425
+ // reached even when the bare value follows the flags (`timeout -s KILL 60
1426
+ // npm i x`).
1427
+ for (;;) {
1428
+ const t0 = tokens[0];
1429
+ if (t0 !== undefined && spec.bareValue && !t0.startsWith("-") && spec.bareValue.test(t0)) {
570
1430
  tokens = tokens.slice(1);
1431
+ continue;
571
1432
  }
1433
+ if (t0 !== undefined && t0.startsWith("-")) {
1434
+ tokens = tokens.slice(1);
1435
+ const consume = spec.tripleValueFlags?.has(t0) ? 3 : spec.dualValueFlags?.has(t0) ? 2 : spec.valueFlags.has(t0) ? 1 : 0;
1436
+ for (let c = 0; c < consume && tokens.length > 0 && !(tokens[0] ?? "").startsWith("-"); c += 1) {
1437
+ tokens = tokens.slice(1);
1438
+ }
1439
+ continue;
1440
+ }
1441
+ break;
572
1442
  }
573
1443
  }
574
1444
  }
575
1445
  function parseSpecToken(eco, token) {
576
1446
  if (eco === "npm") {
1447
+ // `alias@npm:real-pkg[@version]` installs real-pkg under a local alias, so
1448
+ // the package that actually lands is the alias target — screen THAT, not the
1449
+ // alias name.
1450
+ const alias = token.indexOf("@npm:");
1451
+ if (alias > 0) {
1452
+ return parseSpecToken("npm", token.slice(alias + "@npm:".length));
1453
+ }
577
1454
  const at = token.lastIndexOf("@");
578
1455
  if (at > 0) {
579
1456
  return { name: token.slice(0, at), version: token.slice(at + 1) || null };
580
1457
  }
581
1458
  return { name: token, version: null };
582
1459
  }
583
- // pypi: only an exact `name==version` is a pinned version; ranges are unpinned.
584
- const exact = /^([A-Za-z0-9._-]+)==([^,;\s]+)$/.exec(token);
1460
+ // pypi: only an exact `name[extras]==version` is a pinned version; ranges are
1461
+ // unpinned. The optional `[extras]` is dropped from the screened name.
1462
+ const exact = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?==([^,;\s]+)$/.exec(token);
585
1463
  if (exact && exact[1] && exact[2]) {
586
1464
  return { name: exact[1], version: exact[2] };
587
1465
  }
588
- const ranged = /^([A-Za-z0-9._-]+)\s*(?:===|>=|<=|~=|!=|<|>)/.exec(token);
1466
+ const ranged = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?\s*(?:===|>=|<=|~=|!=|<|>)/.exec(token);
589
1467
  if (ranged && ranged[1]) {
590
1468
  return { name: ranged[1], version: null };
591
1469
  }
1470
+ const bare = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?$/.exec(token);
1471
+ if (bare && bare[1]) {
1472
+ return { name: bare[1], version: null };
1473
+ }
592
1474
  return { name: token, version: null };
593
1475
  }
594
1476
  // A token built from a shell variable or substitution — its real value is only
@@ -656,6 +1538,83 @@ function extractUvRunWithSpecs(rawArgs) {
656
1538
  }
657
1539
  return { specs, remoteUnverifiable, dynamic };
658
1540
  }
1541
+ // A single brace list `pre{a,b,c}post` expands to `prea bpost cpost` in bash, so
1542
+ // `npm install {evil,lodash}` really installs both — expand it or the literal
1543
+ // `{evil,lodash}` is screened as one bogus name and the real packages are
1544
+ // missed. Sequence braces (`{1..5}`) and nesting are left intact (no comma).
1545
+ // A leftover comma-brace after expandBraces means the expansion was incomplete
1546
+ // (hit the cap, or malformed) — the caller defers rather than trusting the verb.
1547
+ const UNEXPANDED_BRACE = /\{[^{}]*,[^{}]*\}/;
1548
+ const BRACE_EXPANSION_CAP = 1024;
1549
+ // Full bash-style brace expansion: a word is the cartesian product over EVERY
1550
+ // brace group in it (recursively), e.g. `{i,ci}{,x}` -> i ix ci cix. The old
1551
+ // single-group form let an adjacent-group verb (`npm {i,ci}{,x} evil`) slip past
1552
+ // classification. Sequence braces (`{1..5}`, no top-level comma) and malformed/
1553
+ // comma-less groups stay literal. Over the cap we return the token literal; the
1554
+ // caller treats a surviving comma-brace as undecidable (fail closed).
1555
+ function expandBraces(token) {
1556
+ const out = expandBracesInto(token, []);
1557
+ return out.length > 0 && out.length <= BRACE_EXPANSION_CAP ? out : [token];
1558
+ }
1559
+ function expandBracesInto(token, acc) {
1560
+ if (acc.length > BRACE_EXPANSION_CAP) {
1561
+ return acc;
1562
+ }
1563
+ let depth = 0;
1564
+ let open = -1;
1565
+ for (let i = 0; i < token.length; i += 1) {
1566
+ const c = token[i];
1567
+ if (c === "{") {
1568
+ if (depth === 0) {
1569
+ open = i;
1570
+ }
1571
+ depth += 1;
1572
+ }
1573
+ else if (c === "}") {
1574
+ depth -= 1;
1575
+ if (depth === 0 && open >= 0) {
1576
+ const inner = token.slice(open + 1, i);
1577
+ const parts = splitTopLevelCommas(inner);
1578
+ if (parts.length > 1) {
1579
+ const prefix = token.slice(0, open);
1580
+ const suffix = token.slice(i + 1);
1581
+ for (const part of parts) {
1582
+ expandBracesInto(`${prefix}${part}${suffix}`, acc);
1583
+ if (acc.length > BRACE_EXPANSION_CAP) {
1584
+ return acc;
1585
+ }
1586
+ }
1587
+ return acc;
1588
+ }
1589
+ open = -1;
1590
+ }
1591
+ }
1592
+ }
1593
+ acc.push(token);
1594
+ return acc;
1595
+ }
1596
+ // Split on commas that are NOT inside a nested {...}; returns one element when
1597
+ // there is no top-level comma (so `{1..5}` / `{a}` stay a single literal group).
1598
+ function splitTopLevelCommas(inner) {
1599
+ const parts = [];
1600
+ let depth = 0;
1601
+ let start = 0;
1602
+ for (let i = 0; i < inner.length; i += 1) {
1603
+ const c = inner[i];
1604
+ if (c === "{") {
1605
+ depth += 1;
1606
+ }
1607
+ else if (c === "}") {
1608
+ depth -= 1;
1609
+ }
1610
+ else if (c === "," && depth === 0) {
1611
+ parts.push(inner.slice(start, i));
1612
+ start = i + 1;
1613
+ }
1614
+ }
1615
+ parts.push(inner.slice(start));
1616
+ return parts.length > 1 ? parts : [];
1617
+ }
659
1618
  function extractSpecs(manager, eco, rawArgs) {
660
1619
  if (manager === "uv" && rawArgs.find((a) => !a.startsWith("-")) === "run") {
661
1620
  return extractUvRunWithSpecs(rawArgs);
@@ -663,6 +1622,11 @@ function extractSpecs(manager, eco, rawArgs) {
663
1622
  const valueFlags = eco === "pypi" ? PIP_VALUE_FLAGS : NPM_VALUE_FLAGS;
664
1623
  const noVerb = manager === "npx" || manager === "pnpx" || manager === "uvx";
665
1624
  const positionals = [];
1625
+ // `npx/npm exec/pnpm dlx --package=<pkg>` and `pipx run --spec=<pkg>` fetch the
1626
+ // flag's value, not the trailing bin — screen those too or `--package=evil`
1627
+ // rides through while only the benign bin name is checked (FW-15).
1628
+ const packageFlagLong = eco === "pypi" ? "--spec" : "--package";
1629
+ const packageFlagTokens = [];
666
1630
  // Most managers have a one-word verb (`pip install <pkg>`). `uv pip install`,
667
1631
  // `uv tool install`, and `pipx inject <venv> <pkg>` are two leading tokens
668
1632
  // before the package, so consume the extra leading positional or the scan
@@ -670,12 +1634,28 @@ function extractSpecs(manager, eco, rawArgs) {
670
1634
  let verbWords = noVerb ? 0 : 1;
671
1635
  let consumed = 0;
672
1636
  let firstSeen = false;
1637
+ let verb = "";
673
1638
  for (let i = 0; i < rawArgs.length; i += 1) {
674
1639
  const a = rawArgs[i];
675
1640
  if (a === undefined) {
676
1641
  continue;
677
1642
  }
678
1643
  if (a.startsWith("-")) {
1644
+ const eq = a.indexOf("=");
1645
+ const flagName = eq >= 0 ? a.slice(0, eq) : a;
1646
+ if (flagName === packageFlagLong || (noVerb && eco !== "pypi" && flagName === "-p")) {
1647
+ if (eq >= 0) {
1648
+ packageFlagTokens.push(a.slice(eq + 1));
1649
+ }
1650
+ else {
1651
+ const next = rawArgs[i + 1];
1652
+ if (next !== undefined && !next.startsWith("-")) {
1653
+ packageFlagTokens.push(next);
1654
+ i += 1;
1655
+ }
1656
+ }
1657
+ continue;
1658
+ }
679
1659
  if (valueFlags.has(a)) {
680
1660
  i += 1;
681
1661
  }
@@ -683,6 +1663,7 @@ function extractSpecs(manager, eco, rawArgs) {
683
1663
  }
684
1664
  if (!firstSeen) {
685
1665
  firstSeen = true;
1666
+ verb = a;
686
1667
  if (manager === "uv" && (a === "pip" || a === "tool")) {
687
1668
  verbWords = 2;
688
1669
  }
@@ -696,7 +1677,11 @@ function extractSpecs(manager, eco, rawArgs) {
696
1677
  }
697
1678
  positionals.push(a);
698
1679
  }
699
- const tokens = noVerb ? positionals.slice(0, 1) : positionals;
1680
+ const onlyFirstPositional = noVerb || RUNNER_VERBS.has(verb);
1681
+ const tokens = [
1682
+ ...(onlyFirstPositional ? positionals.slice(0, 1) : positionals),
1683
+ ...packageFlagTokens,
1684
+ ].flatMap(expandBraces);
700
1685
  const specs = [];
701
1686
  const remoteUnverifiable = [];
702
1687
  const dynamic = [];
@@ -768,6 +1753,11 @@ function extractAlternateIndexes(eco, rawArgs, env) {
768
1753
  value = a.slice(f.length + 1);
769
1754
  break;
770
1755
  }
1756
+ // Glued short form: -ihttps://… / -fhttps://… (pip accepts no separator).
1757
+ if (f.length === 2 && a.startsWith(f) && a.length > 2 && a[2] !== "=") {
1758
+ value = a.slice(2);
1759
+ break;
1760
+ }
771
1761
  }
772
1762
  }
773
1763
  if (value !== undefined && isAlternateIndexValue(value)) {
@@ -856,6 +1846,52 @@ async function checkSegment(segment, env, cwd, fetchImpl, depth = 0) {
856
1846
  if (UNSUPPORTED_RUNNERS.has(manager) && verb0 && !isDynamicToken(verb0)) {
857
1847
  return undecidableVerdict(env, `dg cannot statically screen a ${manager} fetch-and-run; it is covered only when the network gate is on`);
858
1848
  }
1849
+ const head0 = tokens[0] ?? "";
1850
+ // A command name built from a substitution (`$(echo npm) install x`,
1851
+ // `` `which npm` install x ``, `sh -c "$(…)"`) runs whatever it outputs —
1852
+ // statically undecidable, so always defer. A bare `$VAR` head only defers
1853
+ // when an install verb follows, so a benign `$EDITOR file` is not nagged.
1854
+ if (head0.startsWith("$(") || head0.includes("`") || (isDynamicToken(head0) && tokens.slice(1).some((t) => INSTALL_VERBS.has(t)))) {
1855
+ return undecidableVerdict(env, `dg cannot statically resolve a dynamically-built command (${head0}) that may install packages`);
1856
+ }
1857
+ // Backstop for an UNKNOWN exec-prefix wrapper (a privilege/sandbox/init tool
1858
+ // not in WRAPPERS): if its operand is itself a package-manager install
1859
+ // (`<wrapper> [flags] <pm> <install-verb> …`), defer rather than wave it
1860
+ // through, so a wrapper the allowlist hasn't enumerated can't silently re-open
1861
+ // the firewall. The `<wrapper> <pm> <verb>` shape skips tools with a
1862
+ // subcommand/target before the manager (docker run …, ssh host …); DATA_HEADS
1863
+ // skips commands that take a manager NAME as literal data (grep/echo/…).
1864
+ if (manager && !DATA_HEADS.has(manager) && !isDynamicToken(head0)) {
1865
+ const rest = tokens.slice(1);
1866
+ let i = 0;
1867
+ while (i < rest.length && (rest[i] ?? "").startsWith("-")) {
1868
+ const flag = rest[i] ?? "";
1869
+ i += 1;
1870
+ // A bare (non `=`) leading flag of an unknown wrapper may take a separate
1871
+ // value (`systemd-run -p MemoryMax=1G …`, `proot -b /tmp …`); consume it
1872
+ // so the scan reaches the real wrapped head — but never swallow another
1873
+ // flag or a package-manager token, so `ssh -p 22 host …` still stops at
1874
+ // the host and `wrapper --foo npm …` still stops at npm.
1875
+ if (!flag.includes("=")) {
1876
+ const value = rest[i];
1877
+ if (value !== undefined &&
1878
+ !value.startsWith("-") &&
1879
+ !isSupportedPackageManager(normalizeManagerName(commandBasename(value)))) {
1880
+ i += 1;
1881
+ }
1882
+ }
1883
+ }
1884
+ const operandManager = normalizeManagerName(commandBasename(rest[i] ?? ""));
1885
+ // Reuse the classifier (the single source of truth) rather than re-deriving
1886
+ // install-ness from a verb table — that catches bare `yarn` (no verb, but an
1887
+ // install of every lockfile dep), npx/pnpx/uvx, and any future protected
1888
+ // shape, with no drift. A non-protected operand (yarn --version, a non-pm
1889
+ // subcommand like `docker run`) stays allow.
1890
+ if (isSupportedPackageManager(operandManager) &&
1891
+ classifyPackageManagerInvocation(operandManager, rest.slice(i + 1)).kind === "protected") {
1892
+ return undecidableVerdict(env, `dg cannot statically screen an install behind an unrecognized wrapper (${manager}); it is covered only when the network gate is on`);
1893
+ }
1894
+ }
859
1895
  return ALLOW;
860
1896
  }
861
1897
  if (!ok) {
@@ -863,7 +1899,16 @@ async function checkSegment(segment, env, cwd, fetchImpl, depth = 0) {
863
1899
  // (unbalanced quote / trailing escape). Never wave it through.
864
1900
  return { decision: "ask", reason: `dg could not safely parse this ${manager} command, so it cannot be verified` };
865
1901
  }
866
- const args = tokens.slice(1);
1902
+ // Brace-expand the args the shell would (`npm {install,ci} evil` -> install +
1903
+ // ci) BEFORE classification: the lexer keeps a comma-brace as one literal
1904
+ // token, so a braced verb (`{install,ci}`) otherwise matches no install verb
1905
+ // and waves the install through. extractSpecs re-expands the specs idempotently.
1906
+ const args = tokens.slice(1).flatMap(expandBraces);
1907
+ if (args.some((a) => UNEXPANDED_BRACE.test(a))) {
1908
+ // A comma-brace survived expansion (over the cap, or malformed) — we can't
1909
+ // know which install the shell would run, so defer rather than wave it through.
1910
+ return undecidableVerdict(env, "dg cannot statically expand a brace-heavy command that may install packages");
1911
+ }
867
1912
  const verb = args.find((a) => !a.startsWith("-")) ?? "";
868
1913
  if (isDynamicToken(verb)) {
869
1914
  // A dynamically-built subcommand (`npm $i pkg`) can't be classified — defer,
@@ -893,17 +1938,24 @@ async function checkSegment(segment, env, cwd, fetchImpl, depth = 0) {
893
1938
  // hostile repo can't slip a malicious direct package past the static hook;
894
1939
  // the transitive tree stays the runtime network gate's job.
895
1940
  let manifestTruncated = false;
1941
+ let manifestAlternateIndexes = [];
896
1942
  if (specs.length === 0 && remoteUnverifiable.length === 0) {
897
1943
  const manifest = eco === "npm" ? readNpmManifestSpecs(cwd) : readPipRequirementSpecs(args, cwd);
898
1944
  if (manifest) {
899
1945
  specs = manifest.specs;
900
1946
  manifestTruncated = manifest.truncated;
1947
+ manifestAlternateIndexes = manifest.alternateIndexes ?? [];
901
1948
  }
902
1949
  }
903
1950
  if (specs.some((s) => CONTROL_CHARS.test(s.name) || (s.version !== null && CONTROL_CHARS.test(s.version)))) {
904
1951
  return { decision: "deny", reason: `dg refused a ${manager} install with a malformed package spec` };
905
1952
  }
906
- const alternateIndexes = extractAlternateIndexes(eco, args, env);
1953
+ const alternateIndexes = extractAlternateIndexes(eco, args, { ...env, ...(parsed.inlineEnv ?? {}) });
1954
+ for (const idx of manifestAlternateIndexes) {
1955
+ if (isAlternateIndexValue(idx)) {
1956
+ alternateIndexes.push(idx);
1957
+ }
1958
+ }
907
1959
  const unverifiableReasons = [];
908
1960
  if (remoteUnverifiable.length > 0) {
909
1961
  unverifiableReasons.push(`non-registry source${remoteUnverifiable.length > 1 ? "s" : ""}: ${remoteUnverifiable.join(", ")}`);
@@ -948,6 +2000,8 @@ async function checkSegment(segment, env, cwd, fetchImpl, depth = 0) {
948
2000
  ecosystem: eco,
949
2001
  env,
950
2002
  fetchImpl,
2003
+ publicFallback: true,
2004
+ bill: false,
951
2005
  ...(cooldown ? { cooldown: cooldown.param } : {}),
952
2006
  });
953
2007
  }