@westbayberry/dg 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +123 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +118 -0
  6. package/dist/agents/gate-posture.js +21 -0
  7. package/dist/agents/gemini.js +107 -0
  8. package/dist/agents/persistence.js +351 -0
  9. package/dist/agents/registry.js +128 -0
  10. package/dist/agents/routing.js +118 -0
  11. package/dist/agents/types.js +1 -0
  12. package/dist/agents/windsurf.js +93 -0
  13. package/dist/api/analyze.js +1 -1
  14. package/dist/audit/detectors.js +0 -11
  15. package/dist/audit/events.js +5 -21
  16. package/dist/audit/rules.js +6 -2
  17. package/dist/audit-ui/AuditApp.js +2 -0
  18. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  19. package/dist/audit-ui/export.js +0 -4
  20. package/dist/audit-ui/format.js +0 -3
  21. package/dist/audit-ui/launch.js +10 -1
  22. package/dist/auth/device-login.js +4 -5
  23. package/dist/auth/login-app.js +7 -7
  24. package/dist/auth/store.js +17 -5
  25. package/dist/bin/dg.js +59 -52
  26. package/dist/commands/agents.js +275 -0
  27. package/dist/commands/audit.js +28 -3
  28. package/dist/commands/config.js +15 -7
  29. package/dist/commands/cooldown.js +389 -0
  30. package/dist/commands/decisions.js +23 -9
  31. package/dist/commands/doctor.js +1 -1
  32. package/dist/commands/help.js +1 -1
  33. package/dist/commands/licenses.js +10 -22
  34. package/dist/commands/logout.js +4 -11
  35. package/dist/commands/router.js +6 -4
  36. package/dist/commands/sbom.js +206 -0
  37. package/dist/commands/scan.js +1 -1
  38. package/dist/commands/service.js +32 -13
  39. package/dist/commands/setup.js +196 -27
  40. package/dist/commands/types.js +1 -0
  41. package/dist/commands/uninstall.js +2 -1
  42. package/dist/commands/update.js +17 -8
  43. package/dist/commands/verify.js +8 -5
  44. package/dist/config/settings.js +49 -48
  45. package/dist/decisions/remember-prompt.js +5 -10
  46. package/dist/export-ui/ExportDialog.js +198 -0
  47. package/dist/install-ui/LiveInstall.js +5 -4
  48. package/dist/install-ui/block-render.js +18 -14
  49. package/dist/install-ui/prep-spinner.js +32 -0
  50. package/dist/launcher/agent-check.js +896 -0
  51. package/dist/launcher/agent-hook-exec.js +70 -0
  52. package/dist/launcher/agent-hook-io.js +39 -0
  53. package/dist/launcher/cargo-cache.js +40 -0
  54. package/dist/launcher/classify.js +44 -7
  55. package/dist/launcher/env.js +105 -26
  56. package/dist/launcher/install-preflight.js +64 -18
  57. package/dist/launcher/live-install.js +29 -8
  58. package/dist/launcher/manifest-screen.js +171 -0
  59. package/dist/launcher/output-redaction.js +10 -4
  60. package/dist/launcher/preflight-prompt.js +18 -4
  61. package/dist/launcher/run.js +178 -103
  62. package/dist/launcher/spawn-invocation.js +21 -0
  63. package/dist/policy/cooldown.js +18 -5
  64. package/dist/policy/evaluate.js +5 -6
  65. package/dist/policy/pypi-name.js +17 -0
  66. package/dist/presentation/mode.js +3 -2
  67. package/dist/presentation/package-page.js +9 -0
  68. package/dist/presentation/theme.js +7 -7
  69. package/dist/project/dgfile.js +146 -6
  70. package/dist/project/override-trust.js +0 -0
  71. package/dist/proxy/auth.js +42 -0
  72. package/dist/proxy/ca.js +29 -9
  73. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  74. package/dist/proxy/enforcement.js +55 -16
  75. package/dist/proxy/metadata-map.js +66 -12
  76. package/dist/proxy/preverified.js +55 -0
  77. package/dist/proxy/server.js +568 -53
  78. package/dist/proxy/worker.js +16 -1
  79. package/dist/publish-set/collect.js +1 -4
  80. package/dist/publish-set/npm.js +8 -5
  81. package/dist/publish-set/pack.js +9 -3
  82. package/dist/runtime/cli.js +0 -4
  83. package/dist/runtime/fatal.js +31 -0
  84. package/dist/runtime/first-run.js +14 -12
  85. package/dist/runtime/node-version.js +43 -6
  86. package/dist/runtime/nudges.js +35 -2
  87. package/dist/sbom/cyclonedx.js +211 -0
  88. package/dist/sbom-ui/SbomApp.js +158 -0
  89. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  90. package/dist/sbom-ui/components/SbomList.js +52 -0
  91. package/dist/sbom-ui/inventory.js +132 -0
  92. package/dist/sbom-ui/launch.js +51 -0
  93. package/dist/sbom-ui/run.js +55 -0
  94. package/dist/sbom-ui/store.js +26 -0
  95. package/dist/scan/collect.js +10 -6
  96. package/dist/scan/command.js +17 -10
  97. package/dist/scan/discovery.js +11 -2
  98. package/dist/scan/render.js +28 -4
  99. package/dist/scan/scanner-report.js +15 -9
  100. package/dist/scan/staged.js +33 -9
  101. package/dist/scan-ui/LegacyApp.js +9 -13
  102. package/dist/scan-ui/alt-screen.js +5 -8
  103. package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
  104. package/dist/scan-ui/components/ProgressBar.js +3 -14
  105. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  106. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  107. package/dist/scan-ui/components/SetupBanner.js +0 -6
  108. package/dist/scan-ui/format-helpers.js +61 -5
  109. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  110. package/dist/scan-ui/hooks/useScan.js +19 -2
  111. package/dist/scan-ui/launch.js +7 -4
  112. package/dist/scan-ui/shims.js +11 -4
  113. package/dist/scripts/detect.js +7 -2
  114. package/dist/scripts/gate.js +1 -1
  115. package/dist/security/sanitize.js +8 -4
  116. package/dist/service/state.js +28 -9
  117. package/dist/service/trust-refresh.js +92 -0
  118. package/dist/service/trust-store.js +5 -9
  119. package/dist/service/worker.js +23 -1
  120. package/dist/setup/activate-shell.js +28 -0
  121. package/dist/setup/git-hook.js +49 -4
  122. package/dist/setup/plan.js +227 -49
  123. package/dist/setup/uninstall-standalone.js +25 -0
  124. package/dist/setup-ui/gate.js +39 -0
  125. package/dist/setup-ui/offer.js +42 -0
  126. package/dist/setup-ui/selector.js +27 -0
  127. package/dist/setup-ui/tasks.js +56 -0
  128. package/dist/setup-ui/wizard.js +233 -0
  129. package/dist/standalone/uninstall.mjs +2123 -0
  130. package/dist/state/cooldown-held.js +66 -0
  131. package/dist/state/index.js +1 -0
  132. package/dist/state/locks.js +4 -2
  133. package/dist/state/store.js +2 -1
  134. package/dist/util/external-tool.js +25 -0
  135. package/dist/util/git.js +10 -3
  136. package/dist/util/report-writer.js +57 -0
  137. package/dist/verify/local.js +242 -44
  138. package/dist/verify/package-check.js +87 -29
  139. package/dist/verify/preflight.js +242 -49
  140. package/dist/verify/render.js +15 -1
  141. package/npm-shrinkwrap.json +2383 -0
  142. package/package.json +7 -8
  143. package/NOTICE +0 -5
  144. package/dist/commands/completion.js +0 -117
  145. package/dist/commands/explain.js +0 -236
  146. package/dist/commands/unavailable.js +0 -11
  147. package/dist/scripts/rebuild.js +0 -28
  148. package/dist/telemetry/events.js +0 -40
@@ -0,0 +1,70 @@
1
+ import { join } from "node:path";
2
+ import { runAgentHookExec } from "./agent-hook-io.js";
3
+ import { getAgent, isAgentId } from "../agents/registry.js";
4
+ import { resolveDgPaths } from "../state/index.js";
5
+ import { writeJsonAtomic } from "../util/json-file.js";
6
+ async function readStdin(stream = process.stdin) {
7
+ if (stream.isTTY) {
8
+ return "";
9
+ }
10
+ const chunks = [];
11
+ for await (const chunk of stream) {
12
+ chunks.push(chunk);
13
+ }
14
+ return Buffer.concat(chunks).toString("utf8");
15
+ }
16
+ function recordFixture(agent, stdin) {
17
+ const path = join(resolveDgPaths().stateDir, "fixtures", `${agent}-${Date.now()}.json`);
18
+ writeJsonAtomic(path, { agent, capturedAt: new Date().toISOString(), stdin });
19
+ return path;
20
+ }
21
+ // Short-circuited before any banner/nudge so stdout carries only the decision
22
+ // JSON the agent parses.
23
+ export async function maybeAgentHookExec(args, opts = {}) {
24
+ const noop = { exitCode: 0, stdout: "", stderr: "" };
25
+ if (args[0] !== "hook-exec") {
26
+ return { handled: false, result: noop };
27
+ }
28
+ const agent = args[1];
29
+ if (!agent || !isAgentId(agent)) {
30
+ // Unknown agent (e.g. a hook written by a newer dg, run by an older one):
31
+ // fail closed. We can't emit this agent's allow shape, and a non-zero exit
32
+ // with no decision blocks across the agents we support, so an out-of-date
33
+ // dg refuses the install with a clear message rather than waving it through.
34
+ return {
35
+ handled: true,
36
+ result: {
37
+ exitCode: 2,
38
+ stdout: "",
39
+ stderr: `dg hook-exec: unknown agent '${agent ?? ""}' — blocked under the firewall; update dg (npm i -g @westbayberry/dg)\n`
40
+ }
41
+ };
42
+ }
43
+ let stdin;
44
+ try {
45
+ stdin = await readStdin(opts.stdin);
46
+ }
47
+ catch {
48
+ // A stdin stream error must not escape to the fatal handler (exit 70 with
49
+ // empty stdout reads as allow to every agent). Emit this agent's deny shape.
50
+ const emitted = getAgent(agent).emitDecision({
51
+ decision: "deny",
52
+ reason: "dg hook: could not read the tool payload from stdin; blocked under the firewall",
53
+ });
54
+ return {
55
+ handled: true,
56
+ result: { exitCode: emitted.exitCode || 2, stdout: emitted.stdout, stderr: "dg hook: stdin read error\n" },
57
+ };
58
+ }
59
+ let fixtureNote = "";
60
+ if (args[2] === "--record-fixture") {
61
+ try {
62
+ fixtureNote = `dg hook-exec: recorded fixture ${recordFixture(agent, stdin)}\n`;
63
+ }
64
+ catch {
65
+ fixtureNote = "dg hook-exec: could not record the fixture\n";
66
+ }
67
+ }
68
+ const result = await runAgentHookExec(agent, stdin);
69
+ return { handled: true, result: { exitCode: result.exitCode, stdout: result.stdout, stderr: `${fixtureNote}${result.stderr}` } };
70
+ }
@@ -0,0 +1,39 @@
1
+ import { agentCheckCommand, ESCALATE, formatScreenedNote } from "./agent-check.js";
2
+ import { getAgent } from "../agents/registry.js";
3
+ import { redactSecrets } from "./output-redaction.js";
4
+ export async function runAgentHookExec(agent, stdin, deps = {}) {
5
+ const integration = getAgent(agent);
6
+ const parsed = integration.parseInput(stdin);
7
+ if (!parsed) {
8
+ // Unreadable payload -> fail closed (deny) so a malformed hook input can
9
+ // never slip an install through unverified.
10
+ const verdict = { decision: "deny", reason: `dg hook: could not read the tool command, so the install is blocked. ${ESCALATE}` };
11
+ const emitted = integration.emitDecision(verdict);
12
+ return { stdout: emitted.stdout, stderr: "dg hook: malformed hook payload\n", exitCode: emitted.exitCode };
13
+ }
14
+ let verdict;
15
+ try {
16
+ verdict = await agentCheckCommand({
17
+ commandLine: parsed.command,
18
+ ...(parsed.cwd ? { cwd: parsed.cwd } : {}),
19
+ ...(deps.env ? { env: deps.env } : {}),
20
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
21
+ });
22
+ }
23
+ catch {
24
+ // Any failure computing the verdict must fail closed: an unhandled throw
25
+ // would otherwise exit non-zero with no decision, which most agents read
26
+ // as allow.
27
+ verdict = { decision: "deny", reason: `dg hook: the firewall check failed, so the install is blocked. ${ESCALATE}` };
28
+ }
29
+ const emitted = integration.emitDecision(verdict);
30
+ const stderr = allowStderr(verdict);
31
+ return { stdout: emitted.stdout, stderr, exitCode: emitted.exitCode };
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
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const PRESERVED_ENTRIES = ["config.toml", "config", "credentials.toml", "credentials"];
5
+ export function userCargoHome(env) {
6
+ const explicit = env.CARGO_HOME?.trim();
7
+ return explicit ? explicit : join(homedir(), ".cargo");
8
+ }
9
+ // Cargo keeps registry config and auth tokens (config.toml / credentials.toml)
10
+ // inside CARGO_HOME, next to the crate cache. dg redirects CARGO_HOME to an empty
11
+ // per-session dir so cached crates re-fetch through the firewall, which would also
12
+ // hide a private-registry user's config and tokens — so link those back in.
13
+ export function prepareCargoHome(cacheDir, source) {
14
+ mkdirSync(cacheDir, { recursive: true });
15
+ const linked = [];
16
+ for (const name of PRESERVED_ENTRIES) {
17
+ const target = join(source, name);
18
+ const link = join(cacheDir, name);
19
+ if (!existsSync(target) || pathPresent(link)) {
20
+ continue;
21
+ }
22
+ try {
23
+ symlinkSync(target, link);
24
+ linked.push(name);
25
+ }
26
+ catch {
27
+ continue;
28
+ }
29
+ }
30
+ return linked;
31
+ }
32
+ function pathPresent(path) {
33
+ try {
34
+ lstatSync(path);
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
@@ -1,21 +1,32 @@
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"]);
5
- const pnpmProtected = new Set(["install", "i", "add", "update", "dlx", "exec"]);
6
- const yarnProtected = new Set(["add", "install", "upgrade", "dlx"]);
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"]);
7
7
  const pipProtected = new Set(["install", "download", "wheel"]);
8
8
  const pipxProtected = new Set(["install", "upgrade", "inject", "run"]);
9
9
  const uvProtected = new Set(["add", "sync"]);
10
10
  const cargoProtected = new Set(["add", "install", "fetch", "update", "build", "test", "check", "run"]);
11
11
  const passthrough = new Set(["help", "--help", "-h", "version", "--version", "-v", "list", "ls", "show", "freeze"]);
12
+ // pip3, pip3.12, python3, python3.11 are the canonical install commands on
13
+ // macOS/Homebrew and many Linux distros (see BINARY_FALLBACKS in
14
+ // resolve-real-binary.ts). The classifier keys on the base name, so a
15
+ // version-suffixed spelling must normalize back to pip/python or it falls
16
+ // through to a silent allow. `pipx`/`pipenv` are left untouched — only a bare
17
+ // numeric/dotted version suffix is stripped.
18
+ export function normalizeManagerName(name) {
19
+ const m = /^(pip|python)(\d+(?:\.\d+)*)?$/.exec(name);
20
+ return m && m[1] ? m[1] : name;
21
+ }
12
22
  export function packageManagerNames() {
13
23
  return [...supportedManagers, ...gatedManagers];
14
24
  }
15
25
  export function isSupportedPackageManager(manager) {
16
26
  return supportedManagers.includes(manager);
17
27
  }
18
- export function classifyPackageManagerInvocation(manager, args) {
28
+ export function classifyPackageManagerInvocation(rawManager, args) {
29
+ const manager = normalizeManagerName(rawManager);
19
30
  if (!isSupportedPackageManager(manager)) {
20
31
  return unsupportedClassification(manager, args);
21
32
  }
@@ -29,6 +40,13 @@ export function classifyPackageManagerInvocation(manager, args) {
29
40
  return classifyByCommand(manager, args, "pnpm", pnpmProtected, "pnpm install/fetch command", "javascript");
30
41
  }
31
42
  if (manager === "yarn") {
43
+ // A bare `yarn` (no sub-command) runs `yarn install`, fetching every lockfile
44
+ // dependency; classify it protected rather than passthrough. Read-only
45
+ // version/help invocations stay passthrough.
46
+ const readOnlyFlag = args.some((a) => a === "--version" || a === "-v" || a === "--help" || a === "-h");
47
+ if (firstCommand(args) === "" && !readOnlyFlag) {
48
+ return protectedClassification("yarn", args, "yarn", "bare yarn installs all lockfile dependencies");
49
+ }
32
50
  return classifyByCommand(manager, args, "yarn", yarnProtected, "Yarn classic install/fetch command", "javascript");
33
51
  }
34
52
  if (manager === "pip") {
@@ -44,7 +62,7 @@ export function classifyPackageManagerInvocation(manager, args) {
44
62
  }
45
63
  function classifyByCommand(manager, args, realBinaryName, protectedCommands, protectedReason, ecosystem) {
46
64
  const action = firstCommand(args);
47
- if (protectedCommands.has(action) || containsFetchSpec(args)) {
65
+ if (protectedCommands.has(action) || containsFetchSpec(args) || initFetchesPackage(action, args, protectedCommands)) {
48
66
  return {
49
67
  manager,
50
68
  ecosystem,
@@ -70,8 +88,13 @@ function classifyUv(args) {
70
88
  if (action === "pip" && pipProtected.has(args[1] ?? "")) {
71
89
  return protectedClassification("uv", args, "uv", "uv pip install/fetch command");
72
90
  }
73
- if (action === "tool" && args[1] === "run") {
74
- return protectedClassification("uv", args, "uv", "uv tool run fetches package artifacts");
91
+ if (action === "tool" && ["run", "install", "upgrade"].includes(args[1] ?? "")) {
92
+ return protectedClassification("uv", args, "uv", "uv tool run/install/upgrade fetches package artifacts");
93
+ }
94
+ if (action === "run" && uvRunFetchesPackage(args)) {
95
+ // `uv run --with <pkg> …` fetches <pkg> from PyPI and runs it: a real
96
+ // fetch-and-execute path that the bare `run` verb otherwise waves through.
97
+ return protectedClassification("uv", args, "uv", "uv run --with fetches and runs a package");
75
98
  }
76
99
  if (uvProtected.has(action) || containsFetchSpec(args)) {
77
100
  return protectedClassification("uv", args, "uv", "uv install/fetch command");
@@ -111,6 +134,20 @@ function unsupportedClassification(manager, args) {
111
134
  function firstCommand(args) {
112
135
  return args.find((arg) => !arg.startsWith("-")) ?? "";
113
136
  }
137
+ // `npm/pnpm/yarn init <initializer>` aliases to fetching and running
138
+ // create-<initializer>, the same as the `create` form; bare `init` / `init -y`
139
+ // is a local scaffold with no fetch. Gated to the JS managers (their protected
140
+ // set carries "create"); cargo/pip `init` are local-only.
141
+ function initFetchesPackage(action, args, protectedCommands) {
142
+ if (action !== "init" || !protectedCommands.has("create")) {
143
+ return false;
144
+ }
145
+ const commandIndex = args.findIndex((arg) => !arg.startsWith("-"));
146
+ return args.slice(commandIndex + 1).some((arg) => arg !== "--" && !arg.startsWith("-"));
147
+ }
148
+ export function uvRunFetchesPackage(args) {
149
+ return args.some((a) => a === "--with" || a === "--with-editable" || a === "--with-requirements" || a.startsWith("--with="));
150
+ }
114
151
  function containsFetchSpec(args) {
115
152
  return args.some((arg) => /^https?:\/\//.test(arg) || /^git\+https?:\/\//.test(arg) || /\.t(ar\.)?gz(?:$|[#?])/.test(arg));
116
153
  }
@@ -1,53 +1,132 @@
1
+ import { dirname } from "node:path";
2
+ import { proxyUrlWithAuth, readProxyAuthToken } from "../proxy/auth.js";
3
+ // The client read timeout must exceed the proxy's per-artifact verdict ceiling
4
+ // (DG_INSTALL_VERDICT_TIMEOUT_MS, default 240s) plus download headroom, so the
5
+ // package manager never gives up on a legitimately-slow verify. Overridable for
6
+ // constrained environments via DG_CLIENT_READ_TIMEOUT_S.
7
+ function clientReadTimeoutSeconds(env) {
8
+ const override = Number(env.DG_CLIENT_READ_TIMEOUT_S);
9
+ if (Number.isFinite(override) && override > 0) {
10
+ return Math.ceil(override);
11
+ }
12
+ const verdictMs = Number(env.DG_INSTALL_VERDICT_TIMEOUT_MS);
13
+ const verdictSeconds = Number.isFinite(verdictMs) && verdictMs > 0 ? verdictMs / 1000 : 240;
14
+ return Math.ceil(verdictSeconds + 60);
15
+ }
16
+ // The wrapped package manager and every lifecycle/postinstall script it runs are
17
+ // untrusted. The dg account credential must never reach them; only the trusted
18
+ // proxy worker (which makes the authenticated API calls) keeps it. Applied on
19
+ // EVERY spawn path, not just the proxy one.
20
+ export function scrubChildSecrets(env) {
21
+ delete env.DG_API_KEY;
22
+ delete env.DG_API_TOKEN;
23
+ return env;
24
+ }
25
+ // The union of proxy-routing vars for an AGENT environment. Unlike a single
26
+ // wrapped install, an agent runs arbitrary package managers, so every manager's
27
+ // proxy + CA var is set at once (npm/node, pip, uv, cargo), plus DG_PROXY_ACTIVE
28
+ // so the install-hook pre-screen defers statically-undecidable commands to the
29
+ // proxy. Every value is a literal (the proxy URL, the CA path) — no shell
30
+ // expansion — so it is safe to drop into an agent's settings `env` block.
31
+ export function buildAgentRoutingEnv(proxyUrl, caPath) {
32
+ const authToken = readProxyAuthToken(dirname(caPath));
33
+ const url = authToken ? proxyUrlWithAuth(proxyUrl, authToken) : proxyUrl;
34
+ const noProxy = "127.0.0.1,localhost";
35
+ return {
36
+ DG_PROXY_ACTIVE: "1",
37
+ NO_PROXY: noProxy,
38
+ no_proxy: noProxy,
39
+ HTTP_PROXY: url,
40
+ HTTPS_PROXY: url,
41
+ http_proxy: url,
42
+ https_proxy: url,
43
+ ALL_PROXY: url,
44
+ npm_config_proxy: url,
45
+ npm_config_https_proxy: url,
46
+ NODE_EXTRA_CA_CERTS: caPath,
47
+ REQUESTS_CA_BUNDLE: caPath,
48
+ PIP_CERT: caPath,
49
+ SSL_CERT_FILE: caPath,
50
+ CARGO_HTTP_CAINFO: caPath,
51
+ };
52
+ }
1
53
  export function buildProxyChildEnv(options) {
2
- const env = {
54
+ const authToken = readProxyAuthToken(dirname(options.caBundlePath));
55
+ const proxyUrl = authToken ? proxyUrlWithAuth(options.proxyUrl, authToken) : options.proxyUrl;
56
+ const env = scrubChildSecrets({
3
57
  ...options.baseEnv,
4
58
  DG_PROXY_ACTIVE: "1"
5
- };
6
- const noProxy = mergeNoProxy(options.baseEnv.NO_PROXY ?? options.baseEnv.no_proxy, options.noProxyHosts ?? ["127.0.0.1", "localhost"]);
59
+ });
60
+ // dg fully controls NO_PROXY (loopback only). An inherited NO_PROXY that names
61
+ // the registry (or a `*`/`.org` glob) would route the manager straight past the
62
+ // firewall, so the inherited value is dropped, not merged.
63
+ const noProxy = (options.noProxyHosts ?? ["127.0.0.1", "localhost"]).join(",");
64
+ env.NO_PROXY = noProxy;
65
+ env.no_proxy = noProxy;
66
+ const readTimeoutSeconds = clientReadTimeoutSeconds(options.baseEnv);
7
67
  if (["npm", "npx", "pnpm", "pnpx", "yarn"].includes(options.manager)) {
8
- env.HTTP_PROXY = options.proxyUrl;
9
- env.HTTPS_PROXY = options.proxyUrl;
10
- env.npm_config_proxy = options.proxyUrl;
11
- env.npm_config_https_proxy = options.proxyUrl;
12
- env.NO_PROXY = noProxy;
68
+ env.HTTP_PROXY = proxyUrl;
69
+ env.HTTPS_PROXY = proxyUrl;
70
+ env.npm_config_proxy = proxyUrl;
71
+ env.npm_config_https_proxy = proxyUrl;
13
72
  env.NODE_EXTRA_CA_CERTS = options.caBundlePath;
73
+ // The proxy withholds every artifact byte until its verdict resolves, so the
74
+ // package manager sees no response data until then. Its own network read
75
+ // timeout must outlast the proxy's worst-case verdict wait or it aborts a
76
+ // legitimate install mid-verify (pip's 15s default is what made large cold
77
+ // installs fail). Set it explicitly so a user-lowered global config can't
78
+ // undercut the proxy.
79
+ env.npm_config_fetch_timeout = String(readTimeoutSeconds * 1000);
80
+ if (options.manager === "yarn") {
81
+ env.YARN_NETWORK_TIMEOUT = String(readTimeoutSeconds * 1000);
82
+ }
83
+ if (options.manager === "yarn") {
84
+ // Yarn Berry (2+) ignores HTTP(S)_PROXY/npm_config_*; without its own
85
+ // proxy + CA config it fetches straight from the registry, bypassing the
86
+ // firewall. Classic Yarn ignores these YARN_ vars, so set them for both.
87
+ env.YARN_HTTP_PROXY = proxyUrl;
88
+ env.YARN_HTTPS_PROXY = proxyUrl;
89
+ env.YARN_HTTPS_CA_FILE_PATH = options.caBundlePath;
90
+ }
14
91
  if (options.cacheDir) {
15
92
  env.npm_config_cache = options.cacheDir;
16
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.
17
97
  env.YARN_CACHE_FOLDER = options.cacheDir;
98
+ env.YARN_GLOBAL_FOLDER = options.cacheDir;
18
99
  }
19
100
  }
20
101
  return env;
21
102
  }
22
103
  if (options.manager === "pip" || options.manager === "pipx") {
23
- env.http_proxy = options.proxyUrl;
24
- env.https_proxy = options.proxyUrl;
25
- env.no_proxy = noProxy;
104
+ env.http_proxy = proxyUrl;
105
+ env.https_proxy = proxyUrl;
26
106
  env.REQUESTS_CA_BUNDLE = options.caBundlePath;
27
107
  env.PIP_CERT = options.caBundlePath;
28
108
  env.PIP_NO_CACHE_DIR = "1";
109
+ env.PIP_DEFAULT_TIMEOUT = String(readTimeoutSeconds);
29
110
  return env;
30
111
  }
31
112
  if (options.manager === "uv" || options.manager === "uvx") {
32
- env.HTTP_PROXY = options.proxyUrl;
33
- env.HTTPS_PROXY = options.proxyUrl;
34
- env.ALL_PROXY = options.proxyUrl;
35
- env.NO_PROXY = noProxy;
113
+ env.HTTP_PROXY = proxyUrl;
114
+ env.HTTPS_PROXY = proxyUrl;
115
+ env.ALL_PROXY = proxyUrl;
36
116
  env.SSL_CERT_FILE = options.caBundlePath;
37
117
  env.UV_NO_CACHE = "1";
118
+ env.UV_HTTP_TIMEOUT = String(readTimeoutSeconds);
38
119
  return env;
39
120
  }
40
- env.HTTPS_PROXY = options.proxyUrl;
41
- env.https_proxy = options.proxyUrl;
42
- env.http_proxy = options.proxyUrl;
43
- env.NO_PROXY = noProxy;
121
+ env.HTTPS_PROXY = proxyUrl;
122
+ env.https_proxy = proxyUrl;
123
+ env.http_proxy = proxyUrl;
44
124
  env.CARGO_HTTP_CAINFO = options.caBundlePath;
45
- return env;
46
- }
47
- function mergeNoProxy(existing, required) {
48
- const values = new Set((existing ?? "").split(",").map((part) => part.trim()).filter(Boolean));
49
- for (const host of required) {
50
- values.add(host);
125
+ 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;
51
130
  }
52
- return [...values].join(",");
131
+ return env;
53
132
  }
@@ -1,29 +1,35 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import { analyzePackages } from "../api/analyze.js";
4
- import { loadUserConfig } from "../config/settings.js";
4
+ import { DEFAULT_CONFIG, loadUserConfig, trustsProjectOverrides } from "../config/settings.js";
5
+ import { honoredOverrides } from "../project/override-trust.js";
5
6
  import { matchDecision, packageKey } from "../decisions/apply.js";
6
7
  import { offerRememberOnIo } from "../decisions/remember-prompt.js";
7
8
  import { provenanceDowngradeLine } from "../presentation/provenance.js";
8
9
  import { defaultPromptIo } from "../install-ui/prompt.js";
9
- import { cooldownRequestParam, formatCooldownDuration, formatPackageAge, isCooldownExempt } from "../policy/cooldown.js";
10
+ import { cooldownRequestParam, formatCooldownDuration, formatPackageAge, isCooldownExempt, isCooldownExemptByDgFile } from "../policy/cooldown.js";
10
11
  import { findProjectRoot, loadDgFile, resolveAcceptedBy, warnUnreadableDgFile } from "../project/dgfile.js";
11
12
  import { parsePipReportInstallCount, parsePipReportInstallSet } from "./pip-report.js";
12
- export function resolvePreflightCooldown(env) {
13
+ import { resolveSpawnInvocation } from "./spawn-invocation.js";
14
+ export function resolvePreflightCooldown(env, ecosystem) {
15
+ // A corrupt user config must not silently disable the cooldown gate; fall back
16
+ // to defaults so any env-configured cooldown is still applied.
17
+ let config;
13
18
  try {
14
- const config = loadUserConfig(env);
15
- const param = cooldownRequestParam(config, env, "pypi", "");
16
- return param ? { param, exempt: config.cooldown.exempt } : undefined;
19
+ config = loadUserConfig(env);
17
20
  }
18
21
  catch {
19
- return undefined;
22
+ config = DEFAULT_CONFIG;
20
23
  }
24
+ const param = cooldownRequestParam(config, env, ecosystem, "");
25
+ return param ? { param, exempt: config.cooldown.exempt, ecosystem } : undefined;
21
26
  }
22
- function isQuarantined(pkg, context) {
27
+ function isQuarantined(pkg, context, dgExemptions = []) {
23
28
  if (!context || !pkg.cooldown) {
24
29
  return false;
25
30
  }
26
- if (isCooldownExempt(pkg.name, context.exempt, "pypi")) {
31
+ if (isCooldownExempt(pkg.name, context.exempt, context.ecosystem)
32
+ || isCooldownExemptByDgFile(pkg.name, context.ecosystem, dgExemptions)) {
27
33
  return false;
28
34
  }
29
35
  return pkg.cooldown.status === "quarantine"
@@ -39,7 +45,12 @@ export function resolvePreflightDecisions(ecosystem, cwd, env = process.env) {
39
45
  if (!file.readable) {
40
46
  return null;
41
47
  }
42
- return { root, file, ecosystem, env };
48
+ const honored = honoredOverrides(file, root, env, trustsProjectOverrides(env));
49
+ if (honored.droppedExemptions > 0 || honored.droppedDecisions > 0) {
50
+ process.stderr.write(`dg: ignoring ${honored.droppedExemptions} cooldown exemption(s) and ${honored.droppedDecisions} decision(s) in ${file.path} not authored on this machine — re-add with 'dg cooldown add' / 'dg decisions', or 'dg config set policy.trustProjectAllowlists true' to trust this repo\n`);
51
+ }
52
+ const gatedFile = { ...file, cooldownExemptions: honored.exemptions, decisions: honored.decisions };
53
+ return { root, file: gatedFile, ecosystem, env };
43
54
  }
44
55
  const PROCEED = { proceed: true };
45
56
  const UNRESOLVED = { set: undefined, count: undefined };
@@ -70,6 +81,15 @@ export function cachedPipResolution(binary, args) {
70
81
  function pipResolutionKey(binary, args) {
71
82
  return JSON.stringify([binary, ...args]);
72
83
  }
84
+ // pip's dry-run resolve downloads sdists/wheels it cannot get PEP 658
85
+ // metadata for, so big dependency trees legitimately take minutes on a cold
86
+ // pip cache. A short timeout here silently skips the preflight (and with it
87
+ // the batched analyze + preverified handoff), pushing every package onto the
88
+ // slow per-artifact verdict path.
89
+ function pipResolveTimeoutMs(env) {
90
+ const raw = Number(env.DG_PIP_RESOLVE_TIMEOUT_MS);
91
+ return Number.isFinite(raw) && raw > 0 ? raw : 120_000;
92
+ }
73
93
  function resolvePipInstallSet(binary, args, env) {
74
94
  if (!binary)
75
95
  return Promise.resolve(UNRESOLVED);
@@ -87,9 +107,11 @@ function resolvePipInstallSet(binary, args, env) {
87
107
  };
88
108
  let child;
89
109
  try {
90
- child = spawn(binary, [...args, "--dry-run", "--report", "-", "--quiet"], {
110
+ const invocation = resolveSpawnInvocation(binary, [...args, "--dry-run", "--report", "-", "--quiet"]);
111
+ child = spawn(invocation.command, [...invocation.args], {
91
112
  env,
92
- stdio: ["ignore", "pipe", "ignore"]
113
+ stdio: ["ignore", "pipe", "ignore"],
114
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
93
115
  });
94
116
  }
95
117
  catch {
@@ -102,7 +124,7 @@ function resolvePipInstallSet(binary, args, env) {
102
124
  }
103
125
  catch { /* already exited */ }
104
126
  finish(UNRESOLVED);
105
- }, 30_000);
127
+ }, pipResolveTimeoutMs(env));
106
128
  child.stdout?.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
107
129
  child.on("error", () => finish(UNRESOLVED));
108
130
  child.on("close", (code) => finish(code === 0
@@ -154,7 +176,7 @@ export function renderProvenanceDowngrades(packages, out) {
154
176
  out.write(` ⚠ ${pkg.name}@${pkg.version}: ${line} (display only, verdict unchanged)\n`);
155
177
  }
156
178
  }
157
- export async function runInstallPreflight(manager, binary, childArgs, env, cwd = process.cwd()) {
179
+ export async function runInstallPreflight(manager, binary, childArgs, env, cwd = process.cwd(), beforeInteraction) {
158
180
  if (manager !== "pip" || !binary) {
159
181
  return PROCEED;
160
182
  }
@@ -165,18 +187,42 @@ export async function runInstallPreflight(manager, binary, childArgs, env, cwd =
165
187
  pipResolutions.set(key, resolution);
166
188
  }
167
189
  const set = resolution.set;
168
- if (!set || set.length === 0) {
190
+ if (!set) {
191
+ process.stderr.write(" dg: could not resolve the install set ahead of time — verifying during install instead\n");
192
+ return PROCEED;
193
+ }
194
+ if (set.length === 0) {
169
195
  return PROCEED;
170
196
  }
171
- const cooldownContext = resolvePreflightCooldown(env);
197
+ const cooldownContext = resolvePreflightCooldown(env, "pypi");
172
198
  let verdicts;
173
199
  try {
174
200
  verdicts = await analyzePackages(set.map((pkg) => ({ name: pkg.name, version: pkg.version })), { ecosystem: "pypi", env, ...(cooldownContext ? { cooldown: cooldownContext.param } : {}) });
175
201
  }
176
202
  catch {
203
+ process.stderr.write(" dg: pre-install check unavailable — verifying during install instead\n");
177
204
  return PROCEED;
178
205
  }
179
- return decideFromVerdicts(verdicts.packages, defaultPromptIo(), cooldownContext, resolvePreflightDecisions("pypi", cwd, env));
206
+ beforeInteraction?.();
207
+ const decision = await decideFromVerdicts(verdicts.packages, defaultPromptIo(), cooldownContext, resolvePreflightDecisions("pypi", cwd, env));
208
+ if (!decision.proceed || decision.forceOverride) {
209
+ return decision;
210
+ }
211
+ const preverified = preverifiedEntries(verdicts.packages, cooldownContext !== undefined);
212
+ return preverified.length > 0 ? { ...decision, preverified } : decision;
213
+ }
214
+ export function preverifiedEntries(packages, cooldownRequested) {
215
+ return packages
216
+ .filter((pkg) => pkg.action === "pass" || pkg.action === "warn")
217
+ .map((pkg) => ({
218
+ ecosystem: "pypi",
219
+ name: pkg.name,
220
+ version: pkg.version,
221
+ action: pkg.action,
222
+ ...(pkg.reasons[0] ? { reason: pkg.reasons[0] } : {}),
223
+ ...(typeof pkg.artifactSha256 === "string" && pkg.artifactSha256.length > 0 ? { scannedSha256: pkg.artifactSha256 } : {}),
224
+ cooldownEvaluated: cooldownRequested && pkg.cooldown !== undefined
225
+ }));
180
226
  }
181
227
  export async function decideFromVerdicts(packages, io, cooldownContext, decisions = null) {
182
228
  if (io.isTTY) {
@@ -185,7 +231,7 @@ export async function decideFromVerdicts(packages, io, cooldownContext, decision
185
231
  const covered = [];
186
232
  const entries = packages
187
233
  .map((pkg) => {
188
- const viaCooldown = isQuarantined(pkg, cooldownContext) && pkg.action !== "block";
234
+ const viaCooldown = isQuarantined(pkg, cooldownContext, decisions?.file.cooldownExemptions ?? []) && pkg.action !== "block";
189
235
  const action = viaCooldown ? "block" : pkg.action ?? "pass";
190
236
  return { pkg, action, viaCooldown };
191
237
  })
@@ -1,15 +1,17 @@
1
- import { createLaunchPlan, runWithProductionProxyLive, EXIT_INSTALL_BLOCKED } from "./run.js";
1
+ import { createLaunchPlan, prepareProxyWorker, runWithProductionProxyLive, EXIT_INSTALL_BLOCKED } from "./run.js";
2
2
  import { runInstallPreflight } from "./install-preflight.js";
3
- import { isSupportedPackageManager } from "./classify.js";
3
+ import { isSupportedPackageManager, normalizeManagerName } from "./classify.js";
4
4
  import { isCiEnv, resolvePresentation } from "../presentation/mode.js";
5
+ import { startPrepSpinner } from "../install-ui/prep-spinner.js";
5
6
  const FALL_THROUGH = { handled: false };
6
7
  export async function maybeRunLiveInstall(args, options = {}) {
7
8
  const env = options.env ?? process.env;
8
9
  if (!process.stdout.isTTY || isCiEnv(env) || resolvePresentation().mode !== "rich") {
9
10
  return FALL_THROUGH;
10
11
  }
11
- const [manager, ...rest] = args;
12
- if (!manager || !isSupportedPackageManager(manager)) {
12
+ const [rawManager, ...rest] = args;
13
+ const manager = normalizeManagerName(rawManager ?? "");
14
+ if (!rawManager || !isSupportedPackageManager(manager)) {
13
15
  return FALL_THROUGH;
14
16
  }
15
17
  const { childArgs, forceOverride } = stripControlArgs(rest);
@@ -17,24 +19,43 @@ export async function maybeRunLiveInstall(args, options = {}) {
17
19
  if (plan.classification.kind !== "protected" || !plan.realBinary.path) {
18
20
  return FALL_THROUGH;
19
21
  }
22
+ const appLoading = import("../install-ui/live-install-app.js");
23
+ appLoading.catch(() => undefined);
24
+ let prepared = forceOverride ? undefined : prepareProxyWorker(plan.classification, env);
20
25
  let effectiveOverride = forceOverride;
26
+ let preverified;
21
27
  if (!forceOverride) {
22
- const preflight = await runInstallPreflight(manager, plan.realBinary.path, childArgs, env);
28
+ const spinner = startPrepSpinner("DG preparing…");
29
+ let preflight;
30
+ try {
31
+ preflight = await runInstallPreflight(manager, plan.realBinary.path, childArgs, env, process.cwd(), spinner.stop);
32
+ }
33
+ finally {
34
+ spinner.stop();
35
+ }
23
36
  if (!preflight.proceed) {
37
+ prepared?.discard();
24
38
  return { handled: true, result: { exitCode: EXIT_INSTALL_BLOCKED, stdout: "", stderr: " Install cancelled.\n" } };
25
39
  }
26
40
  effectiveOverride = preflight.forceOverride;
41
+ preverified = preflight.preverified;
42
+ if (effectiveOverride) {
43
+ prepared?.discard();
44
+ prepared = undefined;
45
+ }
27
46
  }
28
47
  const runOptions = {
29
48
  env,
30
- ...(effectiveOverride ? { forceOverride: effectiveOverride } : {})
49
+ ...(effectiveOverride ? { forceOverride: effectiveOverride } : {}),
50
+ ...(preverified && preverified.length > 0 && !effectiveOverride ? { preverified } : {})
31
51
  };
32
- const { renderLiveInstall } = await import("../install-ui/live-install-app.js");
33
52
  try {
34
- const result = await renderLiveInstall((onView) => runWithProductionProxyLive(plan, childArgs, runOptions, onView));
53
+ const { renderLiveInstall } = await appLoading;
54
+ const result = await renderLiveInstall((onView) => runWithProductionProxyLive(plan, childArgs, runOptions, onView, prepared));
35
55
  return { handled: true, result };
36
56
  }
37
57
  catch (error) {
58
+ prepared?.discard();
38
59
  return {
39
60
  handled: true,
40
61
  result: {