@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
@@ -3,13 +3,20 @@ import { runAgentHookExec } from "./agent-hook-io.js";
3
3
  import { getAgent, isAgentId } from "../agents/registry.js";
4
4
  import { resolveDgPaths } from "../state/index.js";
5
5
  import { writeJsonAtomic } from "../util/json-file.js";
6
+ const MAX_HOOK_STDIN_BYTES = 8 * 1024 * 1024;
6
7
  async function readStdin(stream = process.stdin) {
7
8
  if (stream.isTTY) {
8
9
  return "";
9
10
  }
10
11
  const chunks = [];
12
+ let total = 0;
11
13
  for await (const chunk of stream) {
12
- chunks.push(chunk);
14
+ const buffer = chunk;
15
+ total += buffer.length;
16
+ if (total > MAX_HOOK_STDIN_BYTES) {
17
+ throw new Error(`hook stdin exceeded ${MAX_HOOK_STDIN_BYTES} bytes`);
18
+ }
19
+ chunks.push(buffer);
13
20
  }
14
21
  return Buffer.concat(chunks).toString("utf8");
15
22
  }
@@ -1,6 +1,7 @@
1
1
  import { agentCheckCommand, ESCALATE, formatScreenedNote } from "./agent-check.js";
2
2
  import { getAgent } from "../agents/registry.js";
3
3
  import { redactSecrets } from "./output-redaction.js";
4
+ import { maybeUpdateNudge } from "../runtime/nudges.js";
4
5
  export async function runAgentHookExec(agent, stdin, deps = {}) {
5
6
  const integration = getAgent(agent);
6
7
  const parsed = integration.parseInput(stdin);
@@ -27,13 +28,14 @@ export async function runAgentHookExec(agent, stdin, deps = {}) {
27
28
  verdict = { decision: "deny", reason: `dg hook: the firewall check failed, so the install is blocked. ${ESCALATE}` };
28
29
  }
29
30
  const emitted = integration.emitDecision(verdict);
30
- const stderr = allowStderr(verdict);
31
+ const stderr = allowStderr(verdict, deps.env);
31
32
  return { stdout: emitted.stdout, stderr, exitCode: emitted.exitCode };
32
33
  }
33
- function allowStderr(verdict) {
34
+ function allowStderr(verdict, env) {
34
35
  if (verdict.decision !== "allow") {
35
36
  return ` ${redactSecrets(verdict.reason ?? "DG firewall")}\n`;
36
37
  }
37
38
  const note = verdict.screened ? formatScreenedNote(verdict.screened) : "";
38
- return note ? ` ${redactSecrets(note)}\n` : "";
39
+ const noteLine = note ? ` ${redactSecrets(note)}\n` : "";
40
+ return noteLine + maybeUpdateNudge(env ? { env } : {});
39
41
  }
@@ -1,17 +1,35 @@
1
1
  import { optionalPackageManagerNames, optionalSupportGate } from "../setup/optional-support.js";
2
2
  const supportedManagers = ["npm", "npx", "pnpm", "pnpx", "yarn", "pip", "pipx", "uv", "uvx", "cargo"];
3
3
  const gatedManagers = optionalPackageManagerNames();
4
- const jsProtected = new Set(["install", "i", "ci", "add", "update", "dedupe", "exec", "create"]);
5
- const pnpmProtected = new Set(["install", "i", "add", "update", "dlx", "exec", "create"]);
6
- const yarnProtected = new Set(["add", "install", "upgrade", "dlx", "create"]);
4
+ // npm resolves many spellings to the same fetch/run command via its cmd-list.js
5
+ // alias table `x`=exec (remote fetch-and-run, like npx), `in`/`ins`/`isntall`/…
6
+ // =install, `it`=install-test, `cit`/`sit`=install-ci-test, `ic`=ci, `up`=update.
7
+ // Each fetches or runs package code, so each must gate exactly like its canonical
8
+ // verb; otherwise `npm x evil` / `npm in evil` install ungated. Spelled out (not
9
+ // abbrev-derived) so the gate set stays reviewable; classify-aliases.test.ts pins
10
+ // it against npm's own table.
11
+ const jsProtected = new Set([
12
+ "install", "i", "in", "ins", "inst", "insta", "instal", "isnt", "isnta", "isntal", "isntall",
13
+ "ci", "ic", "clean-install", "install-clean", "isntall-clean",
14
+ "install-test", "it",
15
+ "install-ci-test", "cit", "sit", "clean-install-test",
16
+ "add", "update", "up", "upgrade", "udpate", "dedupe", "ddp",
17
+ "exec", "x", "create",
18
+ ]);
19
+ const pnpmProtected = new Set(["install", "i", "add", "update", "up", "upgrade", "dlx", "fetch", "exec", "create"]);
20
+ const yarnProtected = new Set(["add", "install", "up", "upgrade", "dlx", "create"]);
7
21
  const pipProtected = new Set(["install", "download", "wheel"]);
8
- const pipxProtected = new Set(["install", "upgrade", "inject", "run"]);
22
+ // `upgrade-all`/`reinstall`/`reinstall-all`/`install-all` each re-fetch and run
23
+ // package code just like `upgrade`/`install`; without them the `-all` and
24
+ // `reinstall` forms slip past as passthrough (the prefix gap that let
25
+ // `pipx upgrade` gate but `pipx upgrade-all` install unscanned).
26
+ const pipxProtected = new Set(["install", "install-all", "upgrade", "upgrade-all", "reinstall", "reinstall-all", "inject", "run"]);
9
27
  const uvProtected = new Set(["add", "sync"]);
10
28
  // Only the dependency-set-changing verbs. `build`/`test`/`run`/`check` compile
11
29
  // local code (the npm `run`/`build` analog, which is passthrough) and crates are
12
30
  // not analyzable yet, so gating them just interrupts every Rust build with no
13
31
  // screening value.
14
- const cargoProtected = new Set(["add", "install", "fetch", "update"]);
32
+ const cargoProtected = new Set(["add", "install", "fetch", "update", "binstall"]);
15
33
  const passthrough = new Set(["help", "--help", "-h", "version", "--version", "-v", "list", "ls", "show", "freeze"]);
16
34
  // Recognized read-only / local / script subcommands across the managers. Used
17
35
  // only to locate the real subcommand when a global value-flag puts a non-verb
@@ -29,20 +47,44 @@ const READ_ONLY_VERBS = new Set([
29
47
  "logout", "login", "adduser", "owner", "set", "init", "licenses",
30
48
  "freeze", "check", "uninstall", "debug", "hash", "inspect", "index", "tree", "lock", "venv", "export", "python", "self",
31
49
  ]);
32
- function findAction(args, protectedCommands) {
33
- let firstPositional;
34
- for (const arg of args) {
35
- if (arg.startsWith("-")) {
50
+ // Verbs/subcommands that always indicate a uv install/fetch path — the guard set
51
+ // for uv so positional resolution never skips over a real install token.
52
+ const UV_GUARD = new Set(["pip", "tool", "run", "build", "add", "sync", "install", "upgrade", "download", "wheel"]);
53
+ // Positional (non-flag) tokens, with each flag's value argument skipped so a
54
+ // flag VALUE can't masquerade as the subcommand/verb (`npm --user-agent run
55
+ // install x`, `uv --cache-dir /tmp tool install x`). We conservatively treat
56
+ // EVERY non-attached flag as potentially value-consuming rather than maintain a
57
+ // per-manager flag allowlist (which always drifts and re-opens the bypass). Two
58
+ // invariants keep this fail-closed: never skip a token that is itself a protected
59
+ // (install) command, and never skip another flag — so a misjudged boolean flag
60
+ // can at worst over-screen the rare `<flag> run <install-named-script>` form, it
61
+ // can never wave a real install through.
62
+ function positionalArgs(args, protectedCommands) {
63
+ const out = [];
64
+ for (let i = 0; i < args.length; i++) {
65
+ const a = args[i];
66
+ if (a === undefined) {
36
67
  continue;
37
68
  }
38
- if (firstPositional === undefined) {
39
- firstPositional = arg;
69
+ if (a.startsWith("-")) {
70
+ const next = args[i + 1];
71
+ if (!a.includes("=") && next !== undefined && !next.startsWith("-") && !protectedCommands.has(next)) {
72
+ i++;
73
+ }
74
+ continue;
40
75
  }
41
- if (protectedCommands.has(arg) || READ_ONLY_VERBS.has(arg)) {
42
- return arg;
76
+ out.push(a);
77
+ }
78
+ return out;
79
+ }
80
+ function findAction(args, protectedCommands) {
81
+ const positionals = positionalArgs(args, protectedCommands);
82
+ for (const token of positionals) {
83
+ if (protectedCommands.has(token) || READ_ONLY_VERBS.has(token)) {
84
+ return token;
43
85
  }
44
86
  }
45
- return firstPositional ?? "";
87
+ return positionals[0] ?? "";
46
88
  }
47
89
  // pip3, pip3.12, python3, python3.11 are the canonical install commands on
48
90
  // macOS/Homebrew and many Linux distros (see BINARY_FALLBACKS in
@@ -51,8 +93,16 @@ function findAction(args, protectedCommands) {
51
93
  // through to a silent allow. `pipx`/`pipenv` are left untouched — only a bare
52
94
  // numeric/dotted version suffix is stripped.
53
95
  export function normalizeManagerName(name) {
54
- const m = /^(pip|python)(\d+(?:\.\d+)*)?$/.exec(name);
55
- return m && m[1] ? m[1] : name;
96
+ // The OS resolves a binary name case-insensitively on macOS, so `NPM`/`Pip3`
97
+ // run the real lowercase manager; fold the head toward the lowercase manager
98
+ // tables (the single source of truth) or it short-circuits to an unscreened
99
+ // ALLOW. Only the manager head is folded — package names keep their case.
100
+ // On Windows the manager is invoked through a launcher with an executable
101
+ // extension (npm.cmd, pnpm.cmd, yarn.cmd, pip.exe); strip it or the head never
102
+ // matches the manager tables and short-circuits to an unscreened ALLOW.
103
+ const lower = name.toLowerCase().replace(/\.(?:cmd|exe|bat|ps1)$/, "");
104
+ const m = /^(pip|python)(\d+(?:\.\d+)*)?$/.exec(lower);
105
+ return m && m[1] ? m[1] : lower;
56
106
  }
57
107
  export function packageManagerNames() {
58
108
  return [...supportedManagers, ...gatedManagers];
@@ -79,7 +129,7 @@ export function classifyPackageManagerInvocation(rawManager, args) {
79
129
  // dependency; classify it protected rather than passthrough. Read-only
80
130
  // version/help invocations stay passthrough.
81
131
  const readOnlyFlag = args.some((a) => a === "--version" || a === "-v" || a === "--help" || a === "-h");
82
- if (firstCommand(args) === "" && !readOnlyFlag) {
132
+ if (positionalArgs(args, yarnProtected).length === 0 && !readOnlyFlag) {
83
133
  return protectedClassification("yarn", args, "yarn", "bare yarn installs all lockfile dependencies");
84
134
  }
85
135
  return classifyByCommand(manager, args, "yarn", yarnProtected, "Yarn classic install/fetch command", "javascript");
@@ -119,17 +169,25 @@ function classifyByCommand(manager, args, realBinaryName, protectedCommands, pro
119
169
  };
120
170
  }
121
171
  function classifyUv(args) {
122
- const action = firstCommand(args);
123
- if (action === "pip" && pipProtected.has(args[1] ?? "")) {
124
- return protectedClassification("uv", args, "uv", "uv pip install/fetch command");
172
+ // Skip value-flags so a flag value can't masquerade as the subcommand/verb
173
+ // (`uv --cache-dir /tmp tool install x`, `uv -q tool install x`).
174
+ const positionals = positionalArgs(args, UV_GUARD);
175
+ const action = positionals[0] ?? "";
176
+ const subVerb = positionals[1] ?? "";
177
+ // `uv pip sync` installs/removes packages to match a requirements set — a
178
+ // uv-only verb regular pip lacks, so it isn't in pipProtected — and must gate
179
+ // like `uv pip install`.
180
+ if (action === "pip" && (pipProtected.has(subVerb) || subVerb === "sync")) {
181
+ return protectedClassification("uv", args, "uv", "uv pip install/sync/fetch command");
125
182
  }
126
- if (action === "tool" && ["run", "install", "upgrade"].includes(args[1] ?? "")) {
183
+ if (action === "tool" && ["run", "install", "upgrade"].includes(subVerb)) {
127
184
  return protectedClassification("uv", args, "uv", "uv tool run/install/upgrade fetches package artifacts");
128
185
  }
129
- if (action === "run" && uvRunFetchesPackage(args)) {
130
- // `uv run --with <pkg> …` fetches <pkg> from PyPI and runs it: a real
131
- // fetch-and-execute path that the bare `run` verb otherwise waves through.
132
- return protectedClassification("uv", args, "uv", "uv run --with fetches and runs a package");
186
+ if (action === "run" && !uvRunSkipsDependencyFetch(args)) {
187
+ return protectedClassification("uv", args, "uv", "uv run syncs project/script dependencies from PyPI before executing");
188
+ }
189
+ if (action === "build" && !uvArgPresent(args, "--no-build-isolation")) {
190
+ return protectedClassification("uv", args, "uv", "uv build fetches PEP 517 build backends into an isolated environment");
133
191
  }
134
192
  if (uvProtected.has(action)) {
135
193
  return protectedClassification("uv", args, "uv", "uv install/fetch command");
@@ -181,5 +239,31 @@ function initFetchesPackage(action, args, protectedCommands) {
181
239
  return args.slice(commandIndex + 1).some((arg) => arg !== "--" && !arg.startsWith("-"));
182
240
  }
183
241
  export function uvRunFetchesPackage(args) {
184
- return args.some((a) => a === "--with" || a === "--with-editable" || a === "--with-requirements" || a.startsWith("--with="));
242
+ // uv accepts the glued `--flag=value` form for every long flag, so the
243
+ // attached spellings must gate exactly like the space-separated ones or
244
+ // `uv run --with-requirements=evil.txt cmd` fetches and runs unscanned.
245
+ return args.some((a) => a === "--with" ||
246
+ a.startsWith("--with=") ||
247
+ a === "--with-editable" ||
248
+ a.startsWith("--with-editable=") ||
249
+ a === "--with-requirements" ||
250
+ a.startsWith("--with-requirements="));
251
+ }
252
+ function uvArgPresent(args, flag) {
253
+ return args.some((a) => a === flag || a.startsWith(`${flag}=`));
254
+ }
255
+ // `uv run` syncs the project's declared dependencies (pyproject.toml/uv.lock) and
256
+ // any PEP 723 inline-script deps from PyPI before executing, so it is protected by
257
+ // default. `--no-sync` alone still lets the project load; only dropping the project
258
+ // too (`--no-project`) removes the last sync path, and `--offline` forbids any
259
+ // fetch outright. An explicit `--with`/`--script` names remote deps that those
260
+ // suppressors do not cover, so it stays gated regardless.
261
+ export function uvRunSkipsDependencyFetch(args) {
262
+ if (uvArgPresent(args, "--script") || uvRunFetchesPackage(args)) {
263
+ return false;
264
+ }
265
+ if (uvArgPresent(args, "--offline")) {
266
+ return true;
267
+ }
268
+ return uvArgPresent(args, "--no-sync") && uvArgPresent(args, "--no-project");
185
269
  }
@@ -1,4 +1,4 @@
1
- import { dirname } from "node:path";
1
+ import { dirname, join } from "node:path";
2
2
  import { proxyUrlWithAuth, readProxyAuthToken } from "../proxy/auth.js";
3
3
  // The client read timeout must exceed the proxy's per-artifact verdict ceiling
4
4
  // (DG_INSTALL_VERDICT_TIMEOUT_MS, default 240s) plus download headroom, so the
@@ -50,6 +50,85 @@ export function buildAgentRoutingEnv(proxyUrl, caPath) {
50
50
  CARGO_HTTP_CAINFO: caPath,
51
51
  };
52
52
  }
53
+ // Point a manager at the local registry origin, env-only — never the user's
54
+ // real ~/.npmrc / pip.conf / ~/.cargo/config.toml. npm keeps dist.tarball
55
+ // canonical and disables host rewriting so committed lockfiles stay portable
56
+ // (the tarball is gated by the proxy tier — the npm hybrid the P0 spike settled);
57
+ // pip/uv get the loopback simple index; cargo routes through a throwaway
58
+ // CARGO_HOME whose config.toml is written separately.
59
+ export function buildRegistryOriginEnv(options) {
60
+ const env = scrubChildSecrets({ ...options.baseEnv, DG_REGISTRY_ORIGIN_ACTIVE: "1" });
61
+ const noProxy = "127.0.0.1,localhost";
62
+ env.NO_PROXY = noProxy;
63
+ env.no_proxy = noProxy;
64
+ const origin = ensureTrailingSlash(options.originUrl);
65
+ const host = new URL(options.originUrl).hostname;
66
+ if (["npm", "npx", "pnpm", "pnpx", "yarn"].includes(options.manager)) {
67
+ env.npm_config_registry = origin;
68
+ env.npm_config_replace_registry_host = "never";
69
+ if (options.manager === "yarn") {
70
+ env.YARN_NPM_REGISTRY_SERVER = options.originUrl;
71
+ }
72
+ }
73
+ else if (options.manager === "pip" || options.manager === "pipx") {
74
+ env.PIP_INDEX_URL = `${origin}simple/`;
75
+ env.PIP_TRUSTED_HOST = host;
76
+ scrubSupplementaryIndexes(env);
77
+ }
78
+ else if (options.manager === "uv" || options.manager === "uvx") {
79
+ env.UV_DEFAULT_INDEX = `${origin}simple/`;
80
+ scrubSupplementaryIndexes(env);
81
+ }
82
+ else if (options.cargoHome) {
83
+ env.CARGO_HOME = options.cargoHome;
84
+ }
85
+ applyCacheBust(env, options.manager, options.cacheDir);
86
+ return env;
87
+ }
88
+ function ensureTrailingSlash(url) {
89
+ return url.endsWith("/") ? url : `${url}/`;
90
+ }
91
+ // Precedence already steers an install with a supplementary index to the proxy
92
+ // door, but when the origin door IS used the pip/uv child must fetch ONLY from the
93
+ // origin — drop any inherited extra-index / find-links source so a precedence gap
94
+ // can never leave a second, un-fronted package source active. Defense in depth.
95
+ function scrubSupplementaryIndexes(env) {
96
+ for (const key of ["PIP_EXTRA_INDEX_URL", "PIP_FIND_LINKS", "UV_EXTRA_INDEX_URL", "UV_INDEX", "UV_FIND_LINKS"]) {
97
+ delete env[key];
98
+ }
99
+ }
100
+ // A warm package-manager cache serves an artifact with no network, skipping
101
+ // whichever door is active, so both env builders force a re-fetch through a
102
+ // throwaway cache (or PIP_NO_CACHE_DIR / UV_NO_CACHE where the manager has no
103
+ // redirectable cache dir). Shared so the proxy and origin paths cannot drift.
104
+ function applyCacheBust(env, manager, cacheDir) {
105
+ if (manager === "pip" || manager === "pipx") {
106
+ env.PIP_NO_CACHE_DIR = "1";
107
+ }
108
+ if (manager === "uv" || manager === "uvx") {
109
+ env.UV_NO_CACHE = "1";
110
+ }
111
+ if (!cacheDir) {
112
+ return;
113
+ }
114
+ if (["npm", "npx", "pnpm", "pnpx", "yarn"].includes(manager)) {
115
+ env.npm_config_cache = cacheDir;
116
+ if (manager === "pnpm" || manager === "pnpx") {
117
+ // pnpm links packages from a global content-addressable store, not the
118
+ // npm cache; a package already in the user's ~/.pnpm-store installs with
119
+ // zero network and skips the origin entirely. Point the store at a
120
+ // dg-owned dir so every package is fetched (and gated) at least once.
121
+ env.npm_config_store_dir = join(cacheDir, "pnpm-store");
122
+ }
123
+ if (manager === "yarn") {
124
+ env.YARN_CACHE_FOLDER = cacheDir;
125
+ env.YARN_GLOBAL_FOLDER = cacheDir;
126
+ }
127
+ }
128
+ if (manager === "cargo") {
129
+ env.CARGO_HOME = cacheDir;
130
+ }
131
+ }
53
132
  export function buildProxyChildEnv(options) {
54
133
  const authToken = readProxyAuthToken(dirname(options.caBundlePath));
55
134
  const proxyUrl = authToken ? proxyUrlWithAuth(options.proxyUrl, authToken) : options.proxyUrl;
@@ -88,16 +167,7 @@ export function buildProxyChildEnv(options) {
88
167
  env.YARN_HTTPS_PROXY = proxyUrl;
89
168
  env.YARN_HTTPS_CA_FILE_PATH = options.caBundlePath;
90
169
  }
91
- if (options.cacheDir) {
92
- env.npm_config_cache = options.cacheDir;
93
- if (options.manager === "yarn") {
94
- // Both: YARN_CACHE_FOLDER is Berry's per-project cache, but Berry 2+
95
- // defaults to a shared global cache (~/.yarn/berry) that a warm install
96
- // serves from without ever fetching, so redirect that too.
97
- env.YARN_CACHE_FOLDER = options.cacheDir;
98
- env.YARN_GLOBAL_FOLDER = options.cacheDir;
99
- }
100
- }
170
+ applyCacheBust(env, options.manager, options.cacheDir);
101
171
  return env;
102
172
  }
103
173
  if (options.manager === "pip" || options.manager === "pipx") {
@@ -105,8 +175,8 @@ export function buildProxyChildEnv(options) {
105
175
  env.https_proxy = proxyUrl;
106
176
  env.REQUESTS_CA_BUNDLE = options.caBundlePath;
107
177
  env.PIP_CERT = options.caBundlePath;
108
- env.PIP_NO_CACHE_DIR = "1";
109
178
  env.PIP_DEFAULT_TIMEOUT = String(readTimeoutSeconds);
179
+ applyCacheBust(env, options.manager, options.cacheDir);
110
180
  return env;
111
181
  }
112
182
  if (options.manager === "uv" || options.manager === "uvx") {
@@ -114,8 +184,8 @@ export function buildProxyChildEnv(options) {
114
184
  env.HTTPS_PROXY = proxyUrl;
115
185
  env.ALL_PROXY = proxyUrl;
116
186
  env.SSL_CERT_FILE = options.caBundlePath;
117
- env.UV_NO_CACHE = "1";
118
187
  env.UV_HTTP_TIMEOUT = String(readTimeoutSeconds);
188
+ applyCacheBust(env, options.manager, options.cacheDir);
119
189
  return env;
120
190
  }
121
191
  env.HTTPS_PROXY = proxyUrl;
@@ -123,10 +193,6 @@ export function buildProxyChildEnv(options) {
123
193
  env.http_proxy = proxyUrl;
124
194
  env.CARGO_HTTP_CAINFO = options.caBundlePath;
125
195
  env.CARGO_HTTP_TIMEOUT = String(readTimeoutSeconds);
126
- if (options.cacheDir) {
127
- // cargo serves a cached crate from ~/.cargo with zero network, skipping the
128
- // firewall; pointing CARGO_HOME at the throwaway dir forces a re-fetch.
129
- env.CARGO_HOME = options.cacheDir;
130
- }
196
+ applyCacheBust(env, options.manager, options.cacheDir);
131
197
  return env;
132
198
  }
@@ -11,6 +11,7 @@ import { cooldownRequestParam, formatCooldownDuration, formatPackageAge, isCoold
11
11
  import { findProjectRoot, loadDgFile, resolveAcceptedBy, warnUnreadableDgFile } from "../project/dgfile.js";
12
12
  import { parsePipReportInstallCount, parsePipReportInstallSet } from "./pip-report.js";
13
13
  import { resolveSpawnInvocation } from "./spawn-invocation.js";
14
+ import { scrubChildSecrets } from "./env.js";
14
15
  export function resolvePreflightCooldown(env, ecosystem) {
15
16
  // A corrupt user config must not silently disable the cooldown gate; fall back
16
17
  // to defaults so any env-configured cooldown is still applied.
@@ -54,6 +55,10 @@ export function resolvePreflightDecisions(ecosystem, cwd, env = process.env) {
54
55
  }
55
56
  const PROCEED = { proceed: true };
56
57
  const UNRESOLVED = { set: undefined, count: undefined };
58
+ // pip controls the --report shape, but a hostile index can inflate it; cap the
59
+ // captured bytes so the preflight degrades to the per-artifact proxy path rather
60
+ // than buffering an unbounded report on the dev/CI machine.
61
+ const MAX_PIP_REPORT_BYTES = 16 * 1024 * 1024;
57
62
  const approvedFlagRanks = new Map();
58
63
  const pipResolutions = new Map();
59
64
  export function resetInstallPreflightSession() {
@@ -108,8 +113,12 @@ function resolvePipInstallSet(binary, args, env) {
108
113
  let child;
109
114
  try {
110
115
  const invocation = resolveSpawnInvocation(binary, [...args, "--dry-run", "--report", "-", "--quiet"]);
116
+ // pip's dry-run resolve builds sdists without a wheel / PEP 658 metadata,
117
+ // invoking arbitrary PEP 517 build backends from the package being resolved.
118
+ // Scrub the dg credential first — the same protection every other spawn path
119
+ // applies — so DG_API_KEY/DG_API_TOKEN never reaches untrusted build code.
111
120
  child = spawn(invocation.command, [...invocation.args], {
112
- env,
121
+ env: scrubChildSecrets({ ...env }),
113
122
  stdio: ["ignore", "pipe", "ignore"],
114
123
  windowsVerbatimArguments: invocation.windowsVerbatimArguments
115
124
  });
@@ -125,7 +134,21 @@ function resolvePipInstallSet(binary, args, env) {
125
134
  catch { /* already exited */ }
126
135
  finish(UNRESOLVED);
127
136
  }, pipResolveTimeoutMs(env));
128
- child.stdout?.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
137
+ let stdoutBytes = 0;
138
+ child.stdout?.on("data", (chunk) => {
139
+ if (settled)
140
+ return;
141
+ stdoutBytes += chunk.length;
142
+ if (stdoutBytes > MAX_PIP_REPORT_BYTES) {
143
+ try {
144
+ child.kill();
145
+ }
146
+ catch { /* already exited */ }
147
+ finish(UNRESOLVED);
148
+ return;
149
+ }
150
+ stdout += chunk.toString("utf8");
151
+ });
129
152
  child.on("error", () => finish(UNRESOLVED));
130
153
  child.on("close", (code) => finish(code === 0
131
154
  ? { set: parsePipReportInstallSet(stdout), count: parsePipReportInstallCount(stdout) }
@@ -197,7 +220,7 @@ export async function runInstallPreflight(manager, binary, childArgs, env, cwd =
197
220
  const cooldownContext = resolvePreflightCooldown(env, "pypi");
198
221
  let verdicts;
199
222
  try {
200
- verdicts = await analyzePackages(set.map((pkg) => ({ name: pkg.name, version: pkg.version })), { ecosystem: "pypi", env, ...(cooldownContext ? { cooldown: cooldownContext.param } : {}) });
223
+ verdicts = await analyzePackages(set.map((pkg) => ({ name: pkg.name, version: pkg.version })), { ecosystem: "pypi", env, publicFallback: true, ...(cooldownContext ? { cooldown: cooldownContext.param } : {}) });
201
224
  }
202
225
  catch {
203
226
  process.stderr.write(" dg: pre-install check unavailable — verifying during install instead\n");
@@ -1,8 +1,9 @@
1
- import { createLaunchPlan, prepareProxyWorker, runWithProductionProxyLive, EXIT_INSTALL_BLOCKED } from "./run.js";
1
+ import { createLaunchPlan, prepareProxyWorker, runWithProductionProxyLive, meterInstallTree, EXIT_INSTALL_BLOCKED } from "./run.js";
2
2
  import { runInstallPreflight } from "./install-preflight.js";
3
3
  import { isSupportedPackageManager, normalizeManagerName } from "./classify.js";
4
4
  import { isCiEnv, resolvePresentation } from "../presentation/mode.js";
5
5
  import { startPrepSpinner } from "../install-ui/prep-spinner.js";
6
+ import { redactSecrets } from "./output-redaction.js";
6
7
  const FALL_THROUGH = { handled: false };
7
8
  export async function maybeRunLiveInstall(args, options = {}) {
8
9
  const env = options.env ?? process.env;
@@ -52,6 +53,7 @@ export async function maybeRunLiveInstall(args, options = {}) {
52
53
  try {
53
54
  const { renderLiveInstall } = await appLoading;
54
55
  const result = await renderLiveInstall((onView) => runWithProductionProxyLive(plan, childArgs, runOptions, onView, prepared));
56
+ meterInstallTree(plan, runOptions, result.exitCode);
55
57
  return { handled: true, result };
56
58
  }
57
59
  catch (error) {
@@ -61,7 +63,7 @@ export async function maybeRunLiveInstall(args, options = {}) {
61
63
  result: {
62
64
  exitCode: 1,
63
65
  stdout: "",
64
- stderr: `dg protection failed: ${error instanceof Error ? error.message : "unknown error"}\n`
66
+ stderr: `dg protection failed: ${redactSecrets(error instanceof Error ? error.message : "unknown error")}\n`
65
67
  }
66
68
  };
67
69
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { isAbsolute, join } from "node:path";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  // A bare `npm install` resolves the whole dependency tree, but enumerating every
4
4
  // transitive package in the hook would blow its time budget — that breadth is
5
5
  // the runtime network gate's job. The static hook screens the DIRECT
@@ -8,6 +8,9 @@ import { isAbsolute, join } from "node:path";
8
8
  // instead of silently passing.
9
9
  const MAX_MANIFEST_SPECS = 100;
10
10
  const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
11
+ // Cap the requirements include graph so a deep / fan-out `-r` chain defers rather
12
+ // than walking unbounded inside the hook's time budget.
13
+ const MAX_MANIFEST_FILES = 50;
11
14
  function readBoundedFile(path) {
12
15
  try {
13
16
  if (statSync(path).size > MAX_MANIFEST_BYTES) {
@@ -130,15 +133,51 @@ export function readPipRequirementSpecs(args, cwd) {
130
133
  else if (a.startsWith("-r=")) {
131
134
  files.push(a.slice("-r=".length));
132
135
  }
136
+ else if (a.startsWith("-r") && a.length > 2) {
137
+ // pip accepts the glued short form `-rrequirements.txt`.
138
+ files.push(a.slice(2));
139
+ }
133
140
  }
134
141
  if (files.length === 0) {
135
142
  return null;
136
143
  }
137
144
  const specs = [];
138
145
  const seen = new Set();
146
+ const alternateIndexes = [];
139
147
  let truncated = false;
140
- for (const file of files) {
141
- const path = isAbsolute(file) ? file : join(cwd, file);
148
+ // pip resolves a nested `-r`/`-c` include relative to the directory of the file
149
+ // that contains it; a command-line file is relative to cwd. Walk the include
150
+ // graph (cycle-guarded, depth-capped) so a `requirements.txt` whose only line
151
+ // is `-r evil.txt` does not pass clean with the pinned packages unscreened.
152
+ const visited = new Set();
153
+ const queue = files.map((file) => ({ file, base: cwd }));
154
+ let filesProcessed = 0;
155
+ outer: while (queue.length > 0) {
156
+ const next = queue.shift();
157
+ if (next === undefined) {
158
+ break;
159
+ }
160
+ if (next.file.includes("://")) {
161
+ // A remote -r URL the static hook cannot read; defer.
162
+ truncated = true;
163
+ continue;
164
+ }
165
+ const path = isAbsolute(next.file) ? next.file : resolve(next.base, next.file);
166
+ const rel = relative(cwd, path);
167
+ if (rel.startsWith("..") || isAbsolute(rel)) {
168
+ // A -r target outside the project root is not ours to open. Defer the
169
+ // install (truncated) rather than read an arbitrary path or pass clean.
170
+ truncated = true;
171
+ continue;
172
+ }
173
+ if (visited.has(path)) {
174
+ continue;
175
+ }
176
+ visited.add(path);
177
+ if (++filesProcessed > MAX_MANIFEST_FILES) {
178
+ truncated = true;
179
+ break;
180
+ }
142
181
  if (!existsSync(path)) {
143
182
  // A missing requirements file means the install will fail anyway — nothing
144
183
  // to screen, and no reason to nag the user. Leave it alone.
@@ -152,20 +191,62 @@ export function readPipRequirementSpecs(args, cwd) {
152
191
  continue;
153
192
  }
154
193
  for (const line of raw.split(/\r?\n/)) {
194
+ const include = parseRequirementInclude(line);
195
+ if (include !== null) {
196
+ queue.push({ file: include, base: dirname(path) });
197
+ continue;
198
+ }
199
+ const index = parseRequirementIndexDirective(line);
200
+ if (index !== null) {
201
+ alternateIndexes.push(index);
202
+ continue;
203
+ }
155
204
  const spec = parseRequirementLine(line);
156
205
  if (!spec || seen.has(spec.name)) {
157
206
  continue;
158
207
  }
159
208
  if (specs.length >= MAX_MANIFEST_SPECS) {
160
209
  truncated = true;
161
- break;
210
+ break outer;
162
211
  }
163
212
  seen.add(spec.name);
164
213
  specs.push(spec);
165
214
  }
166
215
  }
167
- if (specs.length === 0) {
168
- return truncated ? { specs, truncated } : null;
216
+ const indexes = alternateIndexes.length > 0 ? alternateIndexes : undefined;
217
+ if (specs.length === 0 && !truncated && !indexes) {
218
+ return null;
219
+ }
220
+ return { specs, truncated, ...(indexes ? { alternateIndexes: indexes } : {}) };
221
+ }
222
+ // An index / find-links directive declared inside a requirements/constraints
223
+ // file. pip honors `--index-url` / `--extra-index-url` / `-i` / `-f` /
224
+ // `--find-links` there, so the static screen must surface them the same as the
225
+ // command-line forms or a hostile in-file index passes clean.
226
+ function parseRequirementIndexDirective(line) {
227
+ const stripped = line.split(/(?<!\\)#/)[0]?.trim() ?? "";
228
+ const spaced = /^(?:-i|--index-url|--extra-index-url|-f|--find-links)[=\s]+(\S.*)$/.exec(stripped);
229
+ if (spaced && spaced[1]) {
230
+ return spaced[1].trim();
169
231
  }
170
- return { specs, truncated };
232
+ const glued = /^-([if])(\S.+)$/.exec(stripped);
233
+ if (glued && glued[2] && glued[2][0] !== "=") {
234
+ return glued[2].trim();
235
+ }
236
+ return null;
237
+ }
238
+ // A nested `-r FILE` / `--requirement FILE` / `-c FILE` / `--constraint FILE`
239
+ // include line; pip reads its pins, so the static hook must follow it too.
240
+ function parseRequirementInclude(line) {
241
+ const stripped = line.split(/(?<!\\)#/)[0]?.trim() ?? "";
242
+ const spaced = /^(?:-r|--requirement|-c|--constraint)[=\s]+(\S.*)$/.exec(stripped);
243
+ if (spaced && spaced[1]) {
244
+ return spaced[1].trim();
245
+ }
246
+ // Glued short form: -rnested.txt / -cconstraints.txt
247
+ const glued = /^-([rc])(\S.+)$/.exec(stripped);
248
+ if (glued && glued[2] && glued[2][0] !== "=") {
249
+ return glued[2].trim();
250
+ }
251
+ return null;
171
252
  }