@westbayberry/dg 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/agents/claude-code.js +10 -0
  2. package/dist/agents/codex.js +1 -1
  3. package/dist/agents/cursor.js +6 -1
  4. package/dist/agents/gate-posture.js +21 -0
  5. package/dist/agents/persistence.js +68 -2
  6. package/dist/agents/registry.js +4 -3
  7. package/dist/agents/routing.js +118 -0
  8. package/dist/agents/windsurf.js +1 -1
  9. package/dist/audit/rules.js +6 -2
  10. package/dist/auth/store.js +9 -2
  11. package/dist/commands/agents.js +45 -1
  12. package/dist/commands/audit.js +6 -0
  13. package/dist/commands/setup.js +1 -2
  14. package/dist/commands/uninstall.js +2 -1
  15. package/dist/config/settings.js +35 -4
  16. package/dist/install-ui/LiveInstall.js +3 -2
  17. package/dist/install-ui/block-render.js +17 -13
  18. package/dist/launcher/agent-check.js +455 -25
  19. package/dist/launcher/agent-hook-exec.js +1 -1
  20. package/dist/launcher/agent-hook-io.js +12 -4
  21. package/dist/launcher/classify.js +27 -1
  22. package/dist/launcher/env.js +39 -7
  23. package/dist/launcher/install-preflight.js +15 -6
  24. package/dist/launcher/live-install.js +4 -3
  25. package/dist/launcher/manifest-screen.js +171 -0
  26. package/dist/launcher/output-redaction.js +4 -1
  27. package/dist/launcher/preflight-prompt.js +4 -3
  28. package/dist/launcher/run.js +90 -18
  29. package/dist/project/dgfile.js +2 -1
  30. package/dist/project/override-trust.js +0 -0
  31. package/dist/proxy/metadata-map.js +29 -13
  32. package/dist/proxy/server.js +130 -14
  33. package/dist/runtime/first-run.js +2 -1
  34. package/dist/sbom-ui/inventory.js +5 -1
  35. package/dist/scan/staged.js +31 -8
  36. package/dist/scan-ui/hooks/useScan.js +4 -1
  37. package/dist/security/sanitize.js +8 -4
  38. package/dist/service/state.js +1 -1
  39. package/dist/service/trust-store.js +5 -9
  40. package/dist/service/worker.js +3 -3
  41. package/dist/setup/plan.js +156 -12
  42. package/dist/setup/uninstall-standalone.js +25 -0
  43. package/dist/setup-ui/wizard.js +9 -1
  44. package/dist/standalone/uninstall.mjs +2123 -0
  45. package/dist/verify/local.js +2 -2
  46. package/dist/verify/package-check.js +3 -1
  47. package/npm-shrinkwrap.json +2 -2
  48. package/package.json +2 -1
@@ -1,10 +1,66 @@
1
1
  import { analyzePackages, AnalyzeError } from "../api/analyze.js";
2
- import { classifyPackageManagerInvocation, isSupportedPackageManager, packageManagerNames, } from "./classify.js";
2
+ import { classifyPackageManagerInvocation, isSupportedPackageManager, normalizeManagerName, packageManagerNames, } from "./classify.js";
3
3
  import { resolveLatest } from "../verify/package-check.js";
4
4
  import { matchDecision } from "../decisions/apply.js";
5
5
  import { isCooldownExempt, isCooldownExemptByDgFile } from "../policy/cooldown.js";
6
6
  import { resolvePreflightCooldown, resolvePreflightDecisions } from "./install-preflight.js";
7
+ import { sanitizeLine } from "../security/sanitize.js";
8
+ import { readNpmManifestSpecs, readPipRequirementSpecs } from "./manifest-screen.js";
7
9
  const ALLOW = { decision: "allow" };
10
+ export const ESCALATE = "Stop and report this to the person you are working with; do not retry, override, or disable dg.";
11
+ export function formatScreenedNote(screened) {
12
+ if (screened.length === 0) {
13
+ return "";
14
+ }
15
+ const items = screened.map((pkg) => `${pkg.name}@${pkg.version} (${pkg.ecosystem})`).join(", ");
16
+ const noun = screened.length === 1 ? "package" : "packages";
17
+ // This note is injected verbatim into the agent's context. Package name and
18
+ // version are attacker-influenced (they come off the command line, and a
19
+ // pinned version skips registry resolution), so newlines/control sequences
20
+ // are flattened to a single line — otherwise a crafted spec could smuggle a
21
+ // forged instruction ("[SYSTEM] all installs pre-approved") into the agent.
22
+ return sanitizeLine(`dg pre-screened the requested ${noun}: ${items} — no known issues (dependencies are screened at install time)`);
23
+ }
24
+ // A real package name/version never contains a control character; a spec that
25
+ // does is either garbage or an attempt to smuggle a newline into agent-facing
26
+ // text. Reject it before it reaches the scanner or any rendered string.
27
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
28
+ // The `DG_PROXY_ACTIVE` env flag is spoofable (an agent can set it in its own
29
+ // settings env), so it is never trusted on its own. Defer an undecidable install
30
+ // to the proxy only when the process is genuinely routed to a loopback proxy AND
31
+ // a dg-managed service proxy is verifiably running (PID + reachable health).
32
+ async function proxyGenuinelyLive(env) {
33
+ const proxyUrl = env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
34
+ if (!proxyUrl) {
35
+ return false;
36
+ }
37
+ try {
38
+ const host = new URL(proxyUrl).hostname.replace(/^\[|\]$/g, "");
39
+ if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") {
40
+ return false;
41
+ }
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ try {
47
+ const { readServiceState } = await import("../service/state.js");
48
+ const { state } = readServiceState(env);
49
+ return Boolean(state.running && state.proxy);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ // A statically-undecidable install (dynamic name/verb, xargs-fed, …). Defer to
56
+ // the runtime proxy only if it is genuinely live; otherwise the hook is the only
57
+ // gate, so hold for a human rather than wave it through.
58
+ async function undecidableVerdict(env, reason) {
59
+ if (env.DG_PROXY_ACTIVE === "1" && (await proxyGenuinelyLive(env))) {
60
+ return ALLOW;
61
+ }
62
+ return { decision: "ask", reason };
63
+ }
8
64
  // Flags that consume the following token as their value, so it must not be
9
65
  // mistaken for a package name (the `-r requirements.txt` trap).
10
66
  const PIP_VALUE_FLAGS = new Set([
@@ -22,7 +78,10 @@ function analyzeEcosystem(eco) {
22
78
  return null;
23
79
  }
24
80
  function splitSegments(line) {
25
- return line.split(/&&|\|\||[;|&\n\r]/).map((s) => s.trim()).filter(Boolean);
81
+ // Split on shell control operators, but not on a `&` that is part of a
82
+ // redirection (`2>&1`, `>&2`, `&>file`) where the `&` is glued to a `>` —
83
+ // shearing there turns a redirection into a phantom package token.
84
+ return line.split(/&&|\|\||;|\||[\n\r]|(?<!>)&(?!>)/).map((s) => s.trim()).filter(Boolean);
26
85
  }
27
86
  function substitutionBodies(line) {
28
87
  const bodies = [];
@@ -215,6 +274,36 @@ function lexSegment(segment) {
215
274
  flush();
216
275
  return { tokens, ok: false };
217
276
  }
277
+ if (ch === ">" || ch === "<") {
278
+ // Shell redirection. A bare numeric fd already in `cur` (the `2` in `2>`)
279
+ // is part of the operator, not a word; otherwise `cur` is a real word
280
+ // (echo foo>bar) and flushes. Consume the operator and its target so a
281
+ // redirection (2>&1, >/dev/null, >>log, <<<here) is never read as a package.
282
+ if (started && /^[0-9]+$/.test(cur)) {
283
+ cur = "";
284
+ started = false;
285
+ }
286
+ else {
287
+ flush();
288
+ }
289
+ i += 1;
290
+ if (segment[i] === ch) {
291
+ i += 1;
292
+ }
293
+ if (ch === "<" && segment[i] === "<") {
294
+ i += 1;
295
+ }
296
+ while (i < n && (segment[i] === " " || segment[i] === "\t")) {
297
+ i += 1;
298
+ }
299
+ if (segment[i] === "&") {
300
+ i += 1;
301
+ }
302
+ while (i < n && !" \t\n\r<>|&;".includes(segment[i] ?? "")) {
303
+ i += 1;
304
+ }
305
+ continue;
306
+ }
218
307
  cur += ch;
219
308
  started = true;
220
309
  i += 1;
@@ -223,25 +312,121 @@ function lexSegment(segment) {
223
312
  return { tokens, ok: true };
224
313
  }
225
314
  const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
226
- const WRAPPER_COMMANDS = new Set(["sudo", "command", "exec", "env", "nice", "nohup", "time", "xargs"]);
315
+ const WRAPPERS = new Map([
316
+ ["sudo", { valueFlags: new Set(["-u", "--user", "-g", "--group", "-p", "--prompt", "-C", "-h", "--host", "-R", "--chroot", "-D", "--chdir"]) }],
317
+ ["doas", { valueFlags: new Set(["-u", "-C", "-a"]) }],
318
+ // corepack is Node's official launcher for pnpm/yarn/npm: `corepack pnpm add x`
319
+ // is really a pnpm install. Unwrap it so the underlying manager is classified.
320
+ ["corepack", { valueFlags: new Set() }],
321
+ ["env", { valueFlags: new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]) }],
322
+ ["command", { valueFlags: new Set() }],
323
+ ["exec", { valueFlags: new Set(["-a"]) }],
324
+ ["nice", { valueFlags: new Set(["-n", "--adjustment"]) }],
325
+ ["ionice", { valueFlags: new Set(["-c", "--class", "-n", "--classdata", "-p", "--pid"]) }],
326
+ ["nohup", { valueFlags: new Set() }],
327
+ ["setsid", { valueFlags: new Set() }],
328
+ ["chrt", { valueFlags: new Set(["-T", "-P", "-D"]) }],
329
+ ["time", { valueFlags: new Set(["-o", "--output", "-f", "--format"]) }],
330
+ ["timeout", { valueFlags: new Set(["-s", "--signal", "-k", "--kill-after"]), bareValue: /^\d+(\.\d+)?[smhd]?$/ }],
331
+ ["stdbuf", { valueFlags: new Set(["-i", "--input", "-o", "--output", "-e", "--error"]) }],
332
+ ["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"]) }],
333
+ ]);
334
+ const SHELL_EXEC = new Set(["sh", "bash", "zsh", "dash", "ash"]);
335
+ // Package managers dg doesn't statically screen (no shim, no classifier). A
336
+ // recognized install verb for one of these can't be screened by the hook, so it
337
+ // defers to the runtime gate rather than waving it through silently.
338
+ const UNSUPPORTED_INSTALLS = new Map([
339
+ ["bun", new Set(["add", "install", "i", "x"])],
340
+ ["deno", new Set(["install", "add"])],
341
+ ["poetry", new Set(["add", "install"])],
342
+ ["pdm", new Set(["add", "install"])],
343
+ ["conda", new Set(["install", "create"])],
344
+ ["mamba", new Set(["install", "create"])],
345
+ ["gem", new Set(["install", "i"])],
346
+ // `go install`/`go get` fetch and build remote modules; `go build`/`run`/`test`
347
+ // operate on already-resolved local code, so they stay passthrough.
348
+ ["go", new Set(["install", "get"])],
349
+ ]);
350
+ // Fetch-and-run launchers dg doesn't statically screen: any package argument is
351
+ // fetched and executed, so a recognized-but-unsupported runner with a target
352
+ // defers to the runtime gate instead of a silent allow (`bunx <pkg>`).
353
+ const UNSUPPORTED_RUNNERS = new Set(["bunx"]);
227
354
  function commandBasename(token) {
228
355
  const slash = token.lastIndexOf("/");
229
356
  return slash === -1 ? token : token.slice(slash + 1);
230
357
  }
358
+ // Drop shell grouping punctuation: a subshell `(…)`, a brace group `{ …; }`, and
359
+ // stray `;` so the wrapped command underneath is reached.
360
+ function stripGrouping(tokens) {
361
+ const out = tokens.filter((t) => t !== "(" && t !== ")" && t !== "{" && t !== "}" && t !== ";");
362
+ if (out.length > 0) {
363
+ out[0] = (out[0] ?? "").replace(/^\(+/, "");
364
+ out[out.length - 1] = (out[out.length - 1] ?? "").replace(/\)+$/, "");
365
+ }
366
+ return out.filter((t) => t.length > 0);
367
+ }
231
368
  function commandTokens(segment) {
232
369
  const lexed = lexSegment(segment);
233
- let tokens = lexed.tokens;
370
+ let tokens = stripGrouping(lexed.tokens);
371
+ let viaXargs = false;
372
+ const x = () => (viaXargs ? { viaXargs: true } : {});
234
373
  for (;;) {
235
374
  while (tokens.length > 0 && ENV_ASSIGNMENT.test(tokens[0] ?? "")) {
236
375
  tokens = tokens.slice(1);
237
376
  }
238
- const head = tokens[0];
239
- if (head === undefined || !WRAPPER_COMMANDS.has(commandBasename(head))) {
240
- return { tokens, ok: lexed.ok };
377
+ const head = commandBasename(tokens[0] ?? "");
378
+ if (head === "eval" && tokens.length > 1) {
379
+ return { tokens, ok: lexed.ok, shellScript: tokens.slice(1).join(" "), ...x() };
380
+ }
381
+ if (SHELL_EXEC.has(head)) {
382
+ const ci = tokens.indexOf("-c");
383
+ const script = ci >= 0 ? tokens[ci + 1] : undefined;
384
+ if (script !== undefined) {
385
+ return { tokens, ok: lexed.ok, shellScript: script, ...x() };
386
+ }
387
+ }
388
+ if (/^python[0-9.]*$/.test(head)) {
389
+ // `-m pip`, glued `-mpip`, and `-m=pip` all run the module. Normalize the
390
+ // module name too so `python -m pip3` resolves to pip.
391
+ let mod;
392
+ let rest = [];
393
+ for (let k = 1; k < tokens.length; k += 1) {
394
+ const t = tokens[k] ?? "";
395
+ if (t === "-m") {
396
+ mod = tokens[k + 1];
397
+ rest = tokens.slice(k + 2);
398
+ break;
399
+ }
400
+ const glued = /^-m=?(.+)$/.exec(t);
401
+ if (glued && glued[1]) {
402
+ mod = glued[1];
403
+ rest = tokens.slice(k + 1);
404
+ break;
405
+ }
406
+ }
407
+ const nmod = mod ? normalizeManagerName(mod) : undefined;
408
+ if (nmod === "pip" || nmod === "uv" || nmod === "pipx") {
409
+ tokens = [nmod, ...rest];
410
+ continue;
411
+ }
412
+ }
413
+ const spec = WRAPPERS.get(head);
414
+ if (head === "" || spec === undefined) {
415
+ return { tokens, ok: lexed.ok, ...x() };
416
+ }
417
+ if (head === "xargs") {
418
+ viaXargs = true;
241
419
  }
242
420
  tokens = tokens.slice(1);
421
+ if (spec.bareValue && tokens[0] !== undefined && !tokens[0].startsWith("-") && spec.bareValue.test(tokens[0])) {
422
+ tokens = tokens.slice(1);
423
+ }
243
424
  while (tokens.length > 0 && (tokens[0] ?? "").startsWith("-")) {
425
+ const flag = tokens[0] ?? "";
244
426
  tokens = tokens.slice(1);
427
+ if (spec.valueFlags.has(flag) && tokens.length > 0 && !(tokens[0] ?? "").startsWith("-")) {
428
+ tokens = tokens.slice(1);
429
+ }
245
430
  }
246
431
  }
247
432
  }
@@ -264,6 +449,12 @@ function parseSpecToken(eco, token) {
264
449
  }
265
450
  return { name: token, version: null };
266
451
  }
452
+ // A token built from a shell variable or substitution — its real value is only
453
+ // known at run time, so it can't be screened statically (applies equally to a
454
+ // package name and to the install verb: `npm $i pkg`).
455
+ function isDynamicToken(t) {
456
+ return t.includes("$") || t.includes("`");
457
+ }
267
458
  function isLocalSpecToken(t) {
268
459
  return (t.startsWith(".") ||
269
460
  t.startsWith("/") ||
@@ -271,11 +462,72 @@ function isLocalSpecToken(t) {
271
462
  t.startsWith("link:") ||
272
463
  t.startsWith("workspace:"));
273
464
  }
465
+ // `uv run --with <pkg> <cmd>` installs the `--with` values, not the positional
466
+ // command. Extract only the --with specs; a --with-requirements <file> points at
467
+ // a file whose contents are unknowable statically, so it defers.
468
+ function extractUvRunWithSpecs(rawArgs) {
469
+ const specs = [];
470
+ const remoteUnverifiable = [];
471
+ const dynamic = [];
472
+ const addToken = (raw) => {
473
+ for (const part of raw.split(",")) {
474
+ const t = part.trim();
475
+ if (t.length === 0) {
476
+ continue;
477
+ }
478
+ if (isDynamicToken(t)) {
479
+ dynamic.push(t);
480
+ }
481
+ else if (t.includes("://") || t.startsWith("git+")) {
482
+ remoteUnverifiable.push(t);
483
+ }
484
+ else if (!isLocalSpecToken(t)) {
485
+ const spec = parseSpecToken("pypi", t);
486
+ if (spec.name.length > 0) {
487
+ specs.push(spec);
488
+ }
489
+ }
490
+ }
491
+ };
492
+ for (let i = 0; i < rawArgs.length; i += 1) {
493
+ const a = rawArgs[i];
494
+ if (a === undefined) {
495
+ continue;
496
+ }
497
+ if (a === "--with" || a === "--with-editable") {
498
+ const v = rawArgs[i + 1];
499
+ i += 1;
500
+ if (v !== undefined) {
501
+ addToken(v);
502
+ }
503
+ }
504
+ else if (a.startsWith("--with=") || a.startsWith("--with-editable=")) {
505
+ addToken(a.slice(a.indexOf("=") + 1));
506
+ }
507
+ else if (a === "--with-requirements") {
508
+ dynamic.push(rawArgs[i + 1] ?? "--with-requirements");
509
+ i += 1;
510
+ }
511
+ else if (a.startsWith("--with-requirements=")) {
512
+ dynamic.push(a.slice(a.indexOf("=") + 1));
513
+ }
514
+ }
515
+ return { specs, remoteUnverifiable, dynamic };
516
+ }
274
517
  function extractSpecs(manager, eco, rawArgs) {
518
+ if (manager === "uv" && rawArgs.find((a) => !a.startsWith("-")) === "run") {
519
+ return extractUvRunWithSpecs(rawArgs);
520
+ }
275
521
  const valueFlags = eco === "pypi" ? PIP_VALUE_FLAGS : NPM_VALUE_FLAGS;
276
522
  const noVerb = manager === "npx" || manager === "pnpx" || manager === "uvx";
277
523
  const positionals = [];
278
- let seenVerb = noVerb;
524
+ // Most managers have a one-word verb (`pip install <pkg>`). `uv pip install`,
525
+ // `uv tool install`, and `pipx inject <venv> <pkg>` are two leading tokens
526
+ // before the package, so consume the extra leading positional or the scan
527
+ // targets the verb/venv instead of the real package set.
528
+ let verbWords = noVerb ? 0 : 1;
529
+ let consumed = 0;
530
+ let firstSeen = false;
279
531
  for (let i = 0; i < rawArgs.length; i += 1) {
280
532
  const a = rawArgs[i];
281
533
  if (a === undefined) {
@@ -287,8 +539,17 @@ function extractSpecs(manager, eco, rawArgs) {
287
539
  }
288
540
  continue;
289
541
  }
290
- if (!seenVerb) {
291
- seenVerb = true;
542
+ if (!firstSeen) {
543
+ firstSeen = true;
544
+ if (manager === "uv" && (a === "pip" || a === "tool")) {
545
+ verbWords = 2;
546
+ }
547
+ else if (manager === "pipx" && a === "inject") {
548
+ verbWords = 2;
549
+ }
550
+ }
551
+ if (consumed < verbWords) {
552
+ consumed += 1;
292
553
  continue;
293
554
  }
294
555
  positionals.push(a);
@@ -296,10 +557,15 @@ function extractSpecs(manager, eco, rawArgs) {
296
557
  const tokens = noVerb ? positionals.slice(0, 1) : positionals;
297
558
  const specs = [];
298
559
  const remoteUnverifiable = [];
560
+ const dynamic = [];
299
561
  for (const t of tokens) {
300
562
  if (t.length === 0) {
301
563
  continue;
302
564
  }
565
+ if (isDynamicToken(t)) {
566
+ dynamic.push(t);
567
+ continue;
568
+ }
303
569
  if (t.includes("://") || t.startsWith("git+")) {
304
570
  remoteUnverifiable.push(t);
305
571
  continue;
@@ -312,7 +578,67 @@ function extractSpecs(manager, eco, rawArgs) {
312
578
  specs.push(spec);
313
579
  }
314
580
  }
315
- return { specs, remoteUnverifiable };
581
+ return { specs, remoteUnverifiable, dynamic };
582
+ }
583
+ // Registry hosts whose artifacts the canonical scan actually covers. Anything
584
+ // else is a different source than the one dg verifies against.
585
+ const DEFAULT_INDEX_HOSTS = new Set([
586
+ "registry.npmjs.org",
587
+ "registry.yarnpkg.com",
588
+ "pypi.org",
589
+ "files.pythonhosted.org",
590
+ ]);
591
+ const PIP_INDEX_FLAGS = new Set(["-i", "--index-url", "--extra-index-url", "-f", "--find-links"]);
592
+ const NPM_INDEX_FLAGS = new Set(["--registry"]);
593
+ const PIP_INDEX_ENV = ["PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_INDEX"];
594
+ const NPM_INDEX_ENV = ["NPM_CONFIG_REGISTRY", "npm_config_registry", "YARN_REGISTRY", "yarn_registry"];
595
+ // True only for a URL pointing at a non-default registry host. A bare local path
596
+ // (--find-links ./wheels) is not flagged here; local artifacts are governed by
597
+ // the same on-disk-already policy as file: specs.
598
+ function isAlternateIndexValue(value) {
599
+ try {
600
+ return !DEFAULT_INDEX_HOSTS.has(new URL(value).hostname);
601
+ }
602
+ catch {
603
+ return false;
604
+ }
605
+ }
606
+ // A package screened against the canonical registry but actually fetched from a
607
+ // different index/registry was not really screened — the bytes come from
608
+ // elsewhere. Surface every alternate source so the verdict can't claim a clean
609
+ // pass it didn't earn.
610
+ function extractAlternateIndexes(eco, rawArgs, env) {
611
+ const out = [];
612
+ const flags = eco === "pypi" ? PIP_INDEX_FLAGS : NPM_INDEX_FLAGS;
613
+ for (let i = 0; i < rawArgs.length; i += 1) {
614
+ const a = rawArgs[i];
615
+ if (a === undefined) {
616
+ continue;
617
+ }
618
+ let value;
619
+ if (flags.has(a)) {
620
+ value = rawArgs[i + 1];
621
+ i += 1;
622
+ }
623
+ else {
624
+ for (const f of flags) {
625
+ if (a.startsWith(`${f}=`)) {
626
+ value = a.slice(f.length + 1);
627
+ break;
628
+ }
629
+ }
630
+ }
631
+ if (value !== undefined && isAlternateIndexValue(value)) {
632
+ out.push(value);
633
+ }
634
+ }
635
+ for (const key of eco === "pypi" ? PIP_INDEX_ENV : NPM_INDEX_ENV) {
636
+ const v = env[key];
637
+ if (v && isAlternateIndexValue(v)) {
638
+ out.push(v);
639
+ }
640
+ }
641
+ return out;
316
642
  }
317
643
  function normalizeName(eco, name) {
318
644
  const lower = name.toLowerCase();
@@ -345,35 +671,109 @@ function combinePackages(verdicts) {
345
671
  const list = verdicts.map((v) => `${v.name}@${v.version} (${v.why})`).join("; ");
346
672
  return { decision: "ask", reason: `DG flagged for review — ${list}`, packages: verdicts };
347
673
  }
674
+ function combineRaw(results) {
675
+ const blocked = results.find((v) => v.decision === "deny") ?? results.find((v) => v.decision === "ask");
676
+ if (blocked) {
677
+ return blocked;
678
+ }
679
+ const screened = results.flatMap((v) => v.screened ?? []);
680
+ return screened.length > 0 ? { decision: "allow", screened } : ALLOW;
681
+ }
348
682
  function combine(results) {
349
- return results.find((v) => v.decision === "deny") ?? results.find((v) => v.decision === "ask") ?? ALLOW;
683
+ const v = combineRaw(results);
684
+ // The reason is rendered into the agent's context (permissionDecisionReason)
685
+ // and the terminal. It interpolates attacker-influenced package names, so
686
+ // flatten control sequences before it leaves the firewall.
687
+ return v.decision !== "allow" && v.reason ? { ...v, reason: `${sanitizeLine(v.reason)} ${ESCALATE}` } : v;
350
688
  }
351
- async function checkSegment(segment, env, cwd, fetchImpl) {
352
- const { tokens, ok } = commandTokens(segment);
353
- const manager = commandBasename(tokens[0] ?? "");
689
+ async function checkSegment(segment, env, cwd, fetchImpl, depth = 0) {
690
+ const parsed = commandTokens(segment);
691
+ if (parsed.shellScript !== undefined) {
692
+ if (depth < 8) {
693
+ // sh -c '<script>' / eval '<script>': re-parse the wrapped script so an
694
+ // install can't hide behind a shell-exec wrapper.
695
+ const inner = collectSegments(parsed.shellScript);
696
+ const results = await Promise.all(inner.map((s) => checkSegment(s, env, cwd, fetchImpl, depth + 1)));
697
+ return combineRaw(results);
698
+ }
699
+ // Recursion limit hit with an unparsed shell-exec wrapper still present: we
700
+ // cannot see what the innermost command runs, so deferring/holding is the
701
+ // only safe move. Falling through here would reach the ALLOW below (the
702
+ // wrapper head `sh`/`eval` is not a package manager) — a fail-OPEN that a
703
+ // deeply nested `sh -c '… sh -c "… npm install evil …"'` would exploit.
704
+ return undecidableVerdict(env, "dg cannot statically unwrap a deeply nested shell-exec command");
705
+ }
706
+ const { tokens, ok } = parsed;
707
+ const manager = normalizeManagerName(commandBasename(tokens[0] ?? ""));
354
708
  if (!manager || !packageManagerNames().includes(manager) || !isSupportedPackageManager(manager)) {
709
+ const verb0 = tokens.slice(1).find((a) => !a.startsWith("-")) ?? "";
710
+ const unsupportedVerbs = UNSUPPORTED_INSTALLS.get(manager);
711
+ if (unsupportedVerbs && unsupportedVerbs.has(verb0)) {
712
+ return undecidableVerdict(env, `dg cannot statically screen ${manager} installs; they are covered only when the network gate is on`);
713
+ }
714
+ if (UNSUPPORTED_RUNNERS.has(manager) && verb0 && !isDynamicToken(verb0)) {
715
+ return undecidableVerdict(env, `dg cannot statically screen a ${manager} fetch-and-run; it is covered only when the network gate is on`);
716
+ }
355
717
  return ALLOW;
356
718
  }
357
719
  if (!ok) {
358
720
  // Head is a package manager but the segment could not be parsed safely
359
721
  // (unbalanced quote / trailing escape). Never wave it through.
360
- return { decision: "ask", reason: `dg could not safely parse this ${manager} command; proceed only if you trust it` };
722
+ return { decision: "ask", reason: `dg could not safely parse this ${manager} command, so it cannot be verified` };
361
723
  }
362
724
  const args = tokens.slice(1);
725
+ const verb = args.find((a) => !a.startsWith("-")) ?? "";
726
+ if (isDynamicToken(verb)) {
727
+ // A dynamically-built subcommand (`npm $i pkg`) can't be classified — defer,
728
+ // don't silently allow (symmetry with a dynamic package name).
729
+ return undecidableVerdict(env, `dg cannot statically verify a dynamically-built ${manager} subcommand (${verb})`);
730
+ }
363
731
  const classification = classifyPackageManagerInvocation(manager, args);
364
732
  if (classification.kind !== "protected") {
365
733
  return ALLOW;
366
734
  }
367
735
  const eco = analyzeEcosystem(classification.ecosystem);
368
736
  if (eco === null) {
369
- return { decision: "ask", reason: `dg cannot yet verify ${manager} packages; proceed manually if you trust them` };
737
+ return { decision: "ask", reason: `dg cannot yet verify ${manager} packages` };
738
+ }
739
+ if (parsed.viaXargs) {
740
+ // xargs feeds the real package list on stdin, unknowable statically.
741
+ return undecidableVerdict(env, `dg cannot statically verify an xargs-fed ${manager} install (packages arrive on stdin)`);
742
+ }
743
+ const extracted = extractSpecs(manager, eco, args);
744
+ const { remoteUnverifiable, dynamic } = extracted;
745
+ let specs = extracted.specs;
746
+ if (dynamic.length > 0) {
747
+ return undecidableVerdict(env, `dg cannot statically verify a dynamically-built package name (${dynamic.join(", ")})`);
370
748
  }
371
- const { specs, remoteUnverifiable } = extractSpecs(manager, eco, args);
372
- const unverifiable = remoteUnverifiable.length > 0
373
- ? {
374
- decision: "ask",
375
- reason: `dg cannot verify non-registry source${remoteUnverifiable.length > 1 ? "s" : ""}: ${remoteUnverifiable.join(", ")}; proceed only if you trust ${remoteUnverifiable.length > 1 ? "them" : "it"}`,
749
+ // No package named: a manifest install (npm install / npm ci / bare yarn,
750
+ // pip install -r). Screen the manifest's direct dependencies so a cloned
751
+ // hostile repo can't slip a malicious direct package past the static hook;
752
+ // the transitive tree stays the runtime network gate's job.
753
+ let manifestTruncated = false;
754
+ if (specs.length === 0 && remoteUnverifiable.length === 0) {
755
+ const manifest = eco === "npm" ? readNpmManifestSpecs(cwd) : readPipRequirementSpecs(args, cwd);
756
+ if (manifest) {
757
+ specs = manifest.specs;
758
+ manifestTruncated = manifest.truncated;
376
759
  }
760
+ }
761
+ if (specs.some((s) => CONTROL_CHARS.test(s.name) || (s.version !== null && CONTROL_CHARS.test(s.version)))) {
762
+ return { decision: "deny", reason: `dg refused a ${manager} install with a malformed package spec` };
763
+ }
764
+ const alternateIndexes = extractAlternateIndexes(eco, args, env);
765
+ const unverifiableReasons = [];
766
+ if (remoteUnverifiable.length > 0) {
767
+ unverifiableReasons.push(`non-registry source${remoteUnverifiable.length > 1 ? "s" : ""}: ${remoteUnverifiable.join(", ")}`);
768
+ }
769
+ if (alternateIndexes.length > 0) {
770
+ unverifiableReasons.push(`a non-default index/registry dg does not screen against: ${alternateIndexes.join(", ")}`);
771
+ }
772
+ if (manifestTruncated) {
773
+ unverifiableReasons.push("more manifest dependencies than the static hook screens — enable the network gate for full coverage");
774
+ }
775
+ const unverifiable = unverifiableReasons.length > 0
776
+ ? { decision: "ask", reason: `dg cannot fully verify this install — ${unverifiableReasons.join("; ")}` }
377
777
  : null;
378
778
  if (specs.length === 0) {
379
779
  return unverifiable ?? ALLOW;
@@ -386,7 +786,7 @@ async function checkSegment(segment, env, cwd, fetchImpl) {
386
786
  if (!version) {
387
787
  return {
388
788
  decision: "deny",
389
- reason: `could not resolve a version to verify ${spec.name} on ${eco}; refusing under the firewall`,
789
+ reason: `could not resolve a version to verify ${spec.name} on ${eco}, so the install is blocked`,
390
790
  };
391
791
  }
392
792
  }
@@ -413,7 +813,7 @@ async function checkSegment(segment, env, cwd, fetchImpl) {
413
813
  const message = error instanceof AnalyzeError ? error.message : "the dg scanner could not be reached";
414
814
  return {
415
815
  decision: "deny",
416
- reason: `could not verify ${resolved.map((r) => r.name).join(", ")}: ${message}; blocked under the firewall (disable: dg hook <agent> off)`,
816
+ reason: `could not verify ${resolved.map((r) => r.name).join(", ")}: ${message}; the install is blocked`,
417
817
  };
418
818
  }
419
819
  const verdicts = [];
@@ -448,9 +848,15 @@ async function checkSegment(segment, env, cwd, fetchImpl) {
448
848
  if (unverifiable && base.decision === "allow") {
449
849
  return unverifiable;
450
850
  }
851
+ if (base.decision === "allow" && resolved.length > 0) {
852
+ return {
853
+ decision: "allow",
854
+ screened: resolved.map((r) => ({ name: r.name, version: r.version, ecosystem: eco })),
855
+ };
856
+ }
451
857
  return base;
452
858
  }
453
- export async function agentCheckCommand(input) {
859
+ async function runAgentCheck(input) {
454
860
  const env = input.env ?? process.env;
455
861
  const cwd = input.cwd ?? process.cwd();
456
862
  const fetchImpl = input.fetchImpl ?? fetch;
@@ -464,3 +870,27 @@ export async function agentCheckCommand(input) {
464
870
  }
465
871
  return combine(results);
466
872
  }
873
+ // The PreToolUse hook is killed by the agent at HOOK_TIMEOUT_MS (60s). A scanner
874
+ // or registry that is slow but not dead would let the hook die with no decision,
875
+ // which most agents read as allow — a silent fail-open. Race the whole check
876
+ // against a deadline comfortably under the hook budget and deny on expiry, so a
877
+ // slow path fails closed with margin to actually emit the verdict.
878
+ const CHECK_DEADLINE_MS = 45_000;
879
+ export async function agentCheckCommand(input) {
880
+ const deadlineMs = input.deadlineMs ?? CHECK_DEADLINE_MS;
881
+ let timer;
882
+ const deadline = new Promise((resolve) => {
883
+ timer = setTimeout(() => resolve({ decision: "deny", reason: `dg could not verify this install in time, so it is blocked. ${ESCALATE}` }), deadlineMs);
884
+ if (typeof timer.unref === "function") {
885
+ timer.unref();
886
+ }
887
+ });
888
+ try {
889
+ return await Promise.race([runAgentCheck(input), deadline]);
890
+ }
891
+ finally {
892
+ if (timer) {
893
+ clearTimeout(timer);
894
+ }
895
+ }
896
+ }
@@ -36,7 +36,7 @@ export async function maybeAgentHookExec(args, opts = {}) {
36
36
  result: {
37
37
  exitCode: 2,
38
38
  stdout: "",
39
- stderr: `dg hook-exec: unknown agent '${agent ?? ""}' — blocked under the firewall; update dg (npm i -g @westbayberry/dg) or run 'dg agents off'\n`
39
+ stderr: `dg hook-exec: unknown agent '${agent ?? ""}' — blocked under the firewall; update dg (npm i -g @westbayberry/dg)\n`
40
40
  }
41
41
  };
42
42
  }
@@ -1,12 +1,13 @@
1
- import { agentCheckCommand } from "./agent-check.js";
1
+ import { agentCheckCommand, ESCALATE, formatScreenedNote } from "./agent-check.js";
2
2
  import { getAgent } from "../agents/registry.js";
3
+ import { redactSecrets } from "./output-redaction.js";
3
4
  export async function runAgentHookExec(agent, stdin, deps = {}) {
4
5
  const integration = getAgent(agent);
5
6
  const parsed = integration.parseInput(stdin);
6
7
  if (!parsed) {
7
8
  // Unreadable payload -> fail closed (deny) so a malformed hook input can
8
9
  // never slip an install through unverified.
9
- const verdict = { decision: "deny", reason: "dg hook: could not read the tool command; blocked under the firewall" };
10
+ const verdict = { decision: "deny", reason: `dg hook: could not read the tool command, so the install is blocked. ${ESCALATE}` };
10
11
  const emitted = integration.emitDecision(verdict);
11
12
  return { stdout: emitted.stdout, stderr: "dg hook: malformed hook payload\n", exitCode: emitted.exitCode };
12
13
  }
@@ -23,9 +24,16 @@ export async function runAgentHookExec(agent, stdin, deps = {}) {
23
24
  // Any failure computing the verdict must fail closed: an unhandled throw
24
25
  // would otherwise exit non-zero with no decision, which most agents read
25
26
  // as allow.
26
- verdict = { decision: "deny", reason: "dg hook: firewall check failed; blocked under the firewall (disable: dg agents off)" };
27
+ verdict = { decision: "deny", reason: `dg hook: the firewall check failed, so the install is blocked. ${ESCALATE}` };
27
28
  }
28
29
  const emitted = integration.emitDecision(verdict);
29
- const stderr = verdict.decision === "allow" ? "" : ` ${verdict.reason ?? "DG firewall"}\n`;
30
+ const stderr = allowStderr(verdict);
30
31
  return { stdout: emitted.stdout, stderr, exitCode: emitted.exitCode };
31
32
  }
33
+ function allowStderr(verdict) {
34
+ if (verdict.decision !== "allow") {
35
+ return ` ${redactSecrets(verdict.reason ?? "DG firewall")}\n`;
36
+ }
37
+ const note = verdict.screened ? formatScreenedNote(verdict.screened) : "";
38
+ return note ? ` ${redactSecrets(note)}\n` : "";
39
+ }