@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
@@ -9,13 +9,24 @@ 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") {
@@ -73,6 +91,11 @@ function classifyUv(args) {
73
91
  if (action === "tool" && ["run", "install", "upgrade"].includes(args[1] ?? "")) {
74
92
  return protectedClassification("uv", args, "uv", "uv tool run/install/upgrade fetches package artifacts");
75
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");
98
+ }
76
99
  if (uvProtected.has(action) || containsFetchSpec(args)) {
77
100
  return protectedClassification("uv", args, "uv", "uv install/fetch command");
78
101
  }
@@ -122,6 +145,9 @@ function initFetchesPackage(action, args, protectedCommands) {
122
145
  const commandIndex = args.findIndex((arg) => !arg.startsWith("-"));
123
146
  return args.slice(commandIndex + 1).some((arg) => arg !== "--" && !arg.startsWith("-"));
124
147
  }
148
+ export function uvRunFetchesPackage(args) {
149
+ return args.some((a) => a === "--with" || a === "--with-editable" || a === "--with-requirements" || a.startsWith("--with="));
150
+ }
125
151
  function containsFetchSpec(args) {
126
152
  return args.some((arg) => /^https?:\/\//.test(arg) || /^git\+https?:\/\//.test(arg) || /\.t(ar\.)?gz(?:$|[#?])/.test(arg));
127
153
  }
@@ -13,18 +13,50 @@ function clientReadTimeoutSeconds(env) {
13
13
  const verdictSeconds = Number.isFinite(verdictMs) && verdictMs > 0 ? verdictMs / 1000 : 240;
14
14
  return Math.ceil(verdictSeconds + 60);
15
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
+ }
16
53
  export function buildProxyChildEnv(options) {
17
54
  const authToken = readProxyAuthToken(dirname(options.caBundlePath));
18
55
  const proxyUrl = authToken ? proxyUrlWithAuth(options.proxyUrl, authToken) : options.proxyUrl;
19
- const env = {
56
+ const env = scrubChildSecrets({
20
57
  ...options.baseEnv,
21
58
  DG_PROXY_ACTIVE: "1"
22
- };
23
- // The wrapped package manager and every lifecycle/postinstall script it runs
24
- // are untrusted. The dg account credential must never reach them; only the
25
- // trusted proxy worker (which makes the authenticated API calls) keeps it.
26
- delete env.DG_API_KEY;
27
- delete env.DG_API_TOKEN;
59
+ });
28
60
  // dg fully controls NO_PROXY (loopback only). An inherited NO_PROXY that names
29
61
  // the registry (or a `*`/`.org` glob) would route the manager straight past the
30
62
  // firewall, so the inherited value is dropped, not merged.
@@ -1,7 +1,8 @@
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";
@@ -11,14 +12,17 @@ import { findProjectRoot, loadDgFile, resolveAcceptedBy, warnUnreadableDgFile }
11
12
  import { parsePipReportInstallCount, parsePipReportInstallSet } from "./pip-report.js";
12
13
  import { resolveSpawnInvocation } from "./spawn-invocation.js";
13
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;
14
18
  try {
15
- const config = loadUserConfig(env);
16
- const param = cooldownRequestParam(config, env, ecosystem, "");
17
- return param ? { param, exempt: config.cooldown.exempt, ecosystem } : undefined;
19
+ config = loadUserConfig(env);
18
20
  }
19
21
  catch {
20
- return undefined;
22
+ config = DEFAULT_CONFIG;
21
23
  }
24
+ const param = cooldownRequestParam(config, env, ecosystem, "");
25
+ return param ? { param, exempt: config.cooldown.exempt, ecosystem } : undefined;
22
26
  }
23
27
  function isQuarantined(pkg, context, dgExemptions = []) {
24
28
  if (!context || !pkg.cooldown) {
@@ -41,7 +45,12 @@ export function resolvePreflightDecisions(ecosystem, cwd, env = process.env) {
41
45
  if (!file.readable) {
42
46
  return null;
43
47
  }
44
- 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 };
45
54
  }
46
55
  const PROCEED = { proceed: true };
47
56
  const UNRESOLVED = { set: undefined, count: undefined };
@@ -1,6 +1,6 @@
1
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
5
  import { startPrepSpinner } from "../install-ui/prep-spinner.js";
6
6
  const FALL_THROUGH = { handled: false };
@@ -9,8 +9,9 @@ export async function maybeRunLiveInstall(args, options = {}) {
9
9
  if (!process.stdout.isTTY || isCiEnv(env) || resolvePresentation().mode !== "rich") {
10
10
  return FALL_THROUGH;
11
11
  }
12
- const [manager, ...rest] = args;
13
- if (!manager || !isSupportedPackageManager(manager)) {
12
+ const [rawManager, ...rest] = args;
13
+ const manager = normalizeManagerName(rawManager ?? "");
14
+ if (!rawManager || !isSupportedPackageManager(manager)) {
14
15
  return FALL_THROUGH;
15
16
  }
16
17
  const { childArgs, forceOverride } = stripControlArgs(rest);
@@ -0,0 +1,171 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ // A bare `npm install` resolves the whole dependency tree, but enumerating every
4
+ // transitive package in the hook would blow its time budget — that breadth is
5
+ // the runtime network gate's job. The static hook screens the DIRECT
6
+ // dependencies declared in the manifest, which is where a cloned hostile repo
7
+ // plants a malicious package, and caps the count so a giant manifest defers
8
+ // instead of silently passing.
9
+ const MAX_MANIFEST_SPECS = 100;
10
+ const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
11
+ function readBoundedFile(path) {
12
+ try {
13
+ if (statSync(path).size > MAX_MANIFEST_BYTES) {
14
+ return null;
15
+ }
16
+ return readFileSync(path, "utf8");
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function asRecord(value) {
23
+ return typeof value === "object" && value !== null ? value : {};
24
+ }
25
+ // package-lock v2/v3 keys direct deps as "node_modules/<name>"; v1 nests them
26
+ // under `dependencies`. Either way, pinning to the lock means the version we
27
+ // screen is the version that installs.
28
+ function lockfileVersions(cwd) {
29
+ const out = new Map();
30
+ for (const file of ["package-lock.json", "npm-shrinkwrap.json"]) {
31
+ const raw = readBoundedFile(join(cwd, file));
32
+ if (!raw) {
33
+ continue;
34
+ }
35
+ try {
36
+ const parsed = JSON.parse(raw);
37
+ const packages = asRecord(parsed.packages);
38
+ for (const [key, meta] of Object.entries(packages)) {
39
+ if (key.startsWith("node_modules/")) {
40
+ const version = asRecord(meta).version;
41
+ if (typeof version === "string") {
42
+ out.set(key.slice("node_modules/".length), version);
43
+ }
44
+ }
45
+ }
46
+ const deps = asRecord(parsed.dependencies);
47
+ for (const [name, meta] of Object.entries(deps)) {
48
+ const version = asRecord(meta).version;
49
+ if (typeof version === "string" && !out.has(name)) {
50
+ out.set(name, version);
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // Unparsable lockfile: fall back to range resolution, don't crash.
56
+ }
57
+ if (out.size > 0) {
58
+ break;
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ export function readNpmManifestSpecs(cwd) {
64
+ const raw = readBoundedFile(join(cwd, "package.json"));
65
+ if (!raw) {
66
+ return null;
67
+ }
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(raw);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ const names = [];
76
+ const seen = new Set();
77
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
78
+ for (const name of Object.keys(asRecord(parsed[field]))) {
79
+ if (!seen.has(name)) {
80
+ seen.add(name);
81
+ names.push(name);
82
+ }
83
+ }
84
+ }
85
+ if (names.length === 0) {
86
+ return null;
87
+ }
88
+ const truncated = names.length > MAX_MANIFEST_SPECS;
89
+ const locks = lockfileVersions(cwd);
90
+ const specs = names.slice(0, MAX_MANIFEST_SPECS).map((name) => ({ name, version: locks.get(name) ?? null }));
91
+ return { specs, truncated };
92
+ }
93
+ function parseRequirementLine(line) {
94
+ const stripped = line.split(/(?<!\\)#/)[0]?.trim() ?? "";
95
+ if (stripped.length === 0 || stripped.startsWith("-")) {
96
+ return null;
97
+ }
98
+ // Drop environment markers (`; python_version < "3.9"`) and inline options.
99
+ const core = (stripped.split(";")[0] ?? "").trim();
100
+ if (core.length === 0 || core.includes("://") || core.startsWith("git+")) {
101
+ return null;
102
+ }
103
+ const exact = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?==([^,;\s]+)$/.exec(core);
104
+ if (exact && exact[1] && exact[2]) {
105
+ return { name: exact[1], version: exact[2] };
106
+ }
107
+ const ranged = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?\s*(?:===|>=|<=|~=|!=|<|>|$)/.exec(core);
108
+ if (ranged && ranged[1]) {
109
+ return { name: ranged[1], version: null };
110
+ }
111
+ return null;
112
+ }
113
+ export function readPipRequirementSpecs(args, cwd) {
114
+ const files = [];
115
+ for (let i = 0; i < args.length; i += 1) {
116
+ const a = args[i];
117
+ if (a === undefined) {
118
+ continue;
119
+ }
120
+ if (a === "-r" || a === "--requirement") {
121
+ const v = args[i + 1];
122
+ i += 1;
123
+ if (v !== undefined) {
124
+ files.push(v);
125
+ }
126
+ }
127
+ else if (a.startsWith("--requirement=")) {
128
+ files.push(a.slice("--requirement=".length));
129
+ }
130
+ else if (a.startsWith("-r=")) {
131
+ files.push(a.slice("-r=".length));
132
+ }
133
+ }
134
+ if (files.length === 0) {
135
+ return null;
136
+ }
137
+ const specs = [];
138
+ const seen = new Set();
139
+ let truncated = false;
140
+ for (const file of files) {
141
+ const path = isAbsolute(file) ? file : join(cwd, file);
142
+ if (!existsSync(path)) {
143
+ // A missing requirements file means the install will fail anyway — nothing
144
+ // to screen, and no reason to nag the user. Leave it alone.
145
+ continue;
146
+ }
147
+ const raw = readBoundedFile(path);
148
+ if (!raw) {
149
+ // The file exists but we could not read it (oversized / permissions) — that
150
+ // is a real unscreened install, so defer rather than pass it clean.
151
+ truncated = true;
152
+ continue;
153
+ }
154
+ for (const line of raw.split(/\r?\n/)) {
155
+ const spec = parseRequirementLine(line);
156
+ if (!spec || seen.has(spec.name)) {
157
+ continue;
158
+ }
159
+ if (specs.length >= MAX_MANIFEST_SPECS) {
160
+ truncated = true;
161
+ break;
162
+ }
163
+ seen.add(spec.name);
164
+ specs.push(spec);
165
+ }
166
+ }
167
+ if (specs.length === 0) {
168
+ return truncated ? { specs, truncated } : null;
169
+ }
170
+ return { specs, truncated };
171
+ }
@@ -7,7 +7,10 @@ const npmrcAuthPattern = /(_authToken|_auth|_password)\s*=\s*("[^"]*"|[^\s;,]+)/
7
7
  const jsonSecretPattern = /("[A-Za-z0-9_.$-]*(?:secret[_-]key|access[_-]key|api[_-]key|token|password|secret)"\s*:\s*)"(?:[^"\\]|\\.)*"/gi;
8
8
  const bearerPattern = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g;
9
9
  // Bare tokens (not in URL userinfo or KEY=value form), by their published shape.
10
- const knownTokenShapePattern = /\b(npm_[A-Za-z0-9]{36}|gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255}|pypi-[A-Za-z0-9_-]{20,}|glpat-[A-Za-z0-9_-]{20,}|hf_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|sk_(?:live|test)_[A-Za-z0-9]{20,}|rk_live_[A-Za-z0-9]{20,}|sk-(?:proj-)?[A-Za-z0-9_-]{20,}|xox[abdeprs]-[A-Za-z0-9-]{10,})\b/g;
10
+ // Alnum-only look-around instead of \b so a fixed-length token immediately
11
+ // adjacent to an underscore-word (e.g. npm_<36>_ci) is still redacted — \b fails
12
+ // there because the boundary would fall between two word characters.
13
+ const knownTokenShapePattern = /(?<![A-Za-z0-9])(npm_[A-Za-z0-9]{36}|gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255}|pypi-[A-Za-z0-9_-]{20,}|glpat-[A-Za-z0-9_-]{20,}|hf_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|sk_(?:live|test)_[A-Za-z0-9]{20,}|rk_live_[A-Za-z0-9]{20,}|sk-(?:proj-)?[A-Za-z0-9_-]{20,}|xox[abdeprs]-[A-Za-z0-9-]{10,})(?![A-Za-z0-9])/g;
11
14
  export function redactSecrets(text) {
12
15
  return text
13
16
  .replace(credentialUrlPattern, "$1<redacted>@")
@@ -7,7 +7,7 @@ import { defaultPromptIo } from "../install-ui/prompt.js";
7
7
  import { normalizePypiName } from "../policy/pypi-name.js";
8
8
  import { resolveAcceptedBy } from "../project/dgfile.js";
9
9
  import { enforceProtectedInstall } from "../proxy/enforcement.js";
10
- import { classifyPackageManagerInvocation, isSupportedPackageManager } from "./classify.js";
10
+ import { classifyPackageManagerInvocation, isSupportedPackageManager, normalizeManagerName } from "./classify.js";
11
11
  import { actionRank, promptPreflightYesNo, recordPreflightApprovals, renderCoveredWarns, renderProvenanceDowngrades, resolvePreflightDecisions } from "./install-preflight.js";
12
12
  import { redactSecrets } from "./output-redaction.js";
13
13
  import { startPrepSpinner } from "../install-ui/prep-spinner.js";
@@ -26,8 +26,9 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
26
26
  if (!io.isTTY || isCiEnv(env)) {
27
27
  return FALL_THROUGH;
28
28
  }
29
- const [manager, ...rest] = args;
30
- if (!manager || !isSupportedPackageManager(manager)) {
29
+ const [rawManager, ...rest] = args;
30
+ const manager = normalizeManagerName(rawManager ?? "");
31
+ if (!rawManager || !isSupportedPackageManager(manager)) {
31
32
  return FALL_THROUGH;
32
33
  }
33
34
  const ecosystem = ECOSYSTEM_BY_MANAGER[manager];
@@ -3,8 +3,9 @@ import { existsSync, writeFileSync } from "node:fs";
3
3
  import { constants as osConstants } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { EXIT_UNAVAILABLE } from "../commands/types.js";
6
- import { loadUserConfig } from "../config/settings.js";
6
+ import { loadUserConfig, trustsProjectOverrides } from "../config/settings.js";
7
7
  import { findProjectRoot, loadDgFile } from "../project/dgfile.js";
8
+ import { honoredOverrides } from "../project/override-trust.js";
8
9
  import { writeCooldownExemptionsFile } from "../proxy/cooldown-exemptions-file.js";
9
10
  import { writePreverifiedFile } from "../proxy/preverified.js";
10
11
  import { describeBlockedInstall, describeFlaggedWarn, renderInstallDecision } from "../install-ui/block-render.js";
@@ -13,21 +14,66 @@ import { isCiEnv, resolvePresentation } from "../presentation/mode.js";
13
14
  import { maybeSetupNudge } from "../runtime/nudges.js";
14
15
  import { createTheme } from "../presentation/theme.js";
15
16
  import { readProxySessionState } from "../proxy/server.js";
17
+ import { readServiceState } from "../service/state.js";
16
18
  import { cleanupSessionSync, createSessionSync, resolveDgPaths } from "../state/index.js";
17
19
  import { classifyPackageManagerInvocation } from "./classify.js";
18
- import { buildProxyChildEnv } from "./env.js";
20
+ import { buildProxyChildEnv, scrubChildSecrets } from "./env.js";
19
21
  import { prepareCargoHome, userCargoHome } from "./cargo-cache.js";
20
22
  import { cachedPipResolution } from "./install-preflight.js";
21
23
  import { createStreamRedactor, redactSecrets } from "./output-redaction.js";
22
24
  import { resolveSpawnInvocation } from "./spawn-invocation.js";
23
25
  import { resolveRealBinary } from "./resolve-real-binary.js";
24
- import { runScriptGateAfterInstall } from "../scripts/gate.js";
26
+ import { runScriptGateAfterInstall, scriptGateChildEnv, scriptGateInstallArgs } from "../scripts/gate.js";
25
27
  export const EXIT_INSTALL_BLOCKED = 2;
26
28
  export { resolveSpawnInvocation } from "./spawn-invocation.js";
27
29
  export function shimDepth(env) {
28
30
  const parsed = Number.parseInt(env.DG_SHIM_DEPTH ?? "", 10);
29
31
  return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
30
32
  }
33
+ // A genuine parent dg proxy sets DG_PROXY_ACTIVE=1 and routes the child through a
34
+ // loopback proxy URL that carries the proxy's auth token (buildProxyChildEnv ->
35
+ // proxyUrlWithAuth). All of these must hold for a nested invocation to safely run
36
+ // the real package manager directly (its traffic still flows through the live
37
+ // parent proxy). DG_PROXY_ACTIVE + a bare loopback URL are both forgeable, so:
38
+ // - the URL must carry a dg auth token (a forged HTTPS_PROXY=http://127.0.0.1:1
39
+ // with no credential is rejected), and
40
+ // - when a persistent dg service is the parent (the agent-routing case), the URL
41
+ // must match that live service's proxy, so a forged token cannot impersonate it.
42
+ // If neither matches, we start our own verifying proxy rather than trust the env.
43
+ export function inheritedDgProxyActive(env) {
44
+ if (env.DG_PROXY_ACTIVE !== "1") {
45
+ return false;
46
+ }
47
+ const proxyUrl = env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
48
+ if (!proxyUrl) {
49
+ return false;
50
+ }
51
+ let url;
52
+ try {
53
+ url = new URL(proxyUrl);
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ const host = url.hostname.replace(/^\[(.*)\]$/, "$1");
59
+ if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") {
60
+ return false;
61
+ }
62
+ if (!url.username && !url.password) {
63
+ return false;
64
+ }
65
+ try {
66
+ const { state } = readServiceState(env);
67
+ if (state.running && state.proxy) {
68
+ const live = new URL(state.proxy.proxyUrl);
69
+ return live.hostname.replace(/^\[(.*)\]$/, "$1") === host && live.port === url.port;
70
+ }
71
+ }
72
+ catch {
73
+ // No readable service state — fall through to the session-proxy path below.
74
+ }
75
+ return true;
76
+ }
31
77
  export function rootUnprotectedNotice(env, uid = process.getuid?.()) {
32
78
  if (uid !== 0) {
33
79
  return "";
@@ -59,11 +105,11 @@ export function createLaunchPlan(manager, args, env = process.env) {
59
105
  classification,
60
106
  realBinary,
61
107
  startsProxy: classification.kind === "protected",
62
- childEnv: {
108
+ childEnv: scrubChildSecrets({
63
109
  ...env,
64
110
  DG_SHIM_ACTIVE: shimNonce(manager, env),
65
111
  DG_SHIM_DEPTH: String(shimDepth(env) + 1)
66
- }
112
+ })
67
113
  };
68
114
  }
69
115
  export async function runPackageManager(manager, args, options = {}) {
@@ -84,15 +130,20 @@ export async function runPackageManager(manager, args, options = {}) {
84
130
  stderr: `dg: ${manager} shim exec loop detected (DG_SHIM_DEPTH=${depth}) — refusing to re-enter\n`
85
131
  };
86
132
  }
87
- if (depth === 1 && plan.startsProxy) {
88
- const child = await spawnPackageManager(plan, args, options);
89
- return {
90
- exitCode: child.exitCode,
91
- stdout: streamedOut(child.stdout, options),
92
- stderr: `dg: re-entered through its own shim — running the real ${plan.classification.realBinaryName} directly\n${streamedErr(child.stderr, options)}`
93
- };
94
- }
95
133
  if (plan.startsProxy) {
134
+ // Reuse a parent dg proxy ONLY when one is genuinely live in this environment
135
+ // (the parent set DG_PROXY_ACTIVE and a loopback proxy URL). An env var such as
136
+ // a forged or stale DG_SHIM_DEPTH must never be what decides to skip
137
+ // verification: if no live dg proxy is detected, start one rather than run the
138
+ // install unproxied. DG_SHIM_DEPTH is only the >=2 infinite-recursion backstop.
139
+ if (inheritedDgProxyActive(options.env ?? process.env)) {
140
+ const child = await spawnPackageManager(plan, args, options);
141
+ return {
142
+ exitCode: child.exitCode,
143
+ stdout: streamedOut(child.stdout, options),
144
+ stderr: `dg: re-entered through its own shim — running the real ${plan.classification.realBinaryName} directly\n${streamedErr(child.stderr, options)}`
145
+ };
146
+ }
96
147
  if (!options.proxyVerdict) {
97
148
  return runWithProductionProxy(plan, args, options);
98
149
  }
@@ -271,6 +322,7 @@ export async function runWithProductionProxyLive(plan, args, options, onView, pr
271
322
  cacheDir: prepareProxyCacheDir(plan.classification.manager, proxy.session.dir, env)
272
323
  });
273
324
  const spawner = options.spawner ?? defaultSpawner;
325
+ const hardened = applyScriptGateHardening(plan, args, childEnv, env);
274
326
  const resolvedTotal = plan.classification.manager === "pip" ? cachedPipResolution(plan.realBinary.path ?? "", args)?.count : undefined;
275
327
  const poll = setInterval(() => {
276
328
  onView(deriveLiveView(readProxySessionState(proxy.session), "scanning", resolvedTotal));
@@ -279,8 +331,8 @@ export async function runWithProductionProxyLive(plan, args, options, onView, pr
279
331
  try {
280
332
  finished = await spawner({
281
333
  binary: plan.realBinary.path ?? "",
282
- args,
283
- env: childEnv
334
+ args: hardened.args,
335
+ env: hardened.env
284
336
  });
285
337
  }
286
338
  finally {
@@ -326,6 +378,22 @@ function installOutcome(stdout) {
326
378
  /^(added|changed|removed|updated) \d/.test(line.trim()));
327
379
  return lines.length > 0 ? `${lines.join("\n")}\n` : "";
328
380
  }
381
+ // scriptGate.mode === "enforce" is applied HERE, at the spawn boundary: it
382
+ // rewrites the install args (--ignore-scripts) and child env
383
+ // (npm_config_ignore_scripts) for npm/yarn so a reputation-clean package can't
384
+ // run a lifecycle script. observe/off leave the install untouched. Without this
385
+ // the mode setting was inert — the verdict screens what is fetched, this gates
386
+ // what the fetched package is allowed to execute.
387
+ function applyScriptGateHardening(plan, args, childEnv, configEnv) {
388
+ const mode = loadUserConfig(configEnv).scriptGate.mode;
389
+ const manager = plan.classification.manager;
390
+ const hardenedArgs = scriptGateInstallArgs({ mode, manager, args, env: childEnv });
391
+ const gateEnv = scriptGateChildEnv({ mode, manager, args, env: childEnv });
392
+ return {
393
+ args: hardenedArgs,
394
+ env: Object.keys(gateEnv).length > 0 ? { ...childEnv, ...gateEnv } : childEnv
395
+ };
396
+ }
329
397
  async function spawnPackageManager(plan, args, options) {
330
398
  if (!plan.realBinary.path) {
331
399
  return {
@@ -335,10 +403,11 @@ async function spawnPackageManager(plan, args, options) {
335
403
  };
336
404
  }
337
405
  const spawner = options.spawner ?? defaultSpawner;
406
+ const hardened = applyScriptGateHardening(plan, args, plan.childEnv, options.env ?? process.env);
338
407
  return spawner({
339
408
  binary: plan.realBinary.path,
340
- args,
341
- env: plan.childEnv,
409
+ args: hardened.args,
410
+ env: hardened.env,
342
411
  onStdout: options.onStdout,
343
412
  onStderr: options.onStderr
344
413
  });
@@ -410,7 +479,10 @@ export function loadProjectCooldownExemptions(env, cwd = process.cwd()) {
410
479
  return [];
411
480
  }
412
481
  const file = loadDgFile(root);
413
- return file.readable ? file.cooldownExemptions : [];
482
+ if (!file.readable) {
483
+ return [];
484
+ }
485
+ return honoredOverrides(file, root, env, trustsProjectOverrides(env)).exemptions;
414
486
  }
415
487
  catch {
416
488
  return [];
@@ -6,6 +6,7 @@ import { gitTrimmed } from "../util/git.js";
6
6
  import { writeJsonAtomic } from "../util/json-file.js";
7
7
  import { canonicalCooldownName } from "../policy/pypi-name.js";
8
8
  import { acquireLockSyncWithRetry, resolveDgPaths } from "../state/index.js";
9
+ import { stampAuthoredEntries } from "./override-trust.js";
9
10
  const DG_FILE_LOCK_STALE_MS = 10_000;
10
11
  const DG_FILE_LOCK_TIMEOUT_MS = 5_000;
11
12
  export function withDgFileLock(root, env, action) {
@@ -29,7 +30,7 @@ export function mutateDgFile(root, env, mutate) {
29
30
  if (!file.readable) {
30
31
  throw new Error(`refusing to rewrite ${file.path}: ${file.failure ?? "unreadable"}`);
31
32
  }
32
- const next = mutate(file);
33
+ const next = stampAuthoredEntries(mutate(file), file, root, env);
33
34
  saveDgFile(next);
34
35
  return next;
35
36
  });
Binary file