@westbayberry/dg 2.0.8 → 2.0.10

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 (50) hide show
  1. package/README.md +17 -12
  2. package/dist/api/analyze.js +134 -34
  3. package/dist/audit-ui/export.js +3 -4
  4. package/dist/auth/device-login.js +13 -9
  5. package/dist/auth/store.js +43 -26
  6. package/dist/bin/dg.js +5 -0
  7. package/dist/commands/audit.js +14 -4
  8. package/dist/commands/config.js +3 -5
  9. package/dist/commands/doctor.js +3 -3
  10. package/dist/commands/explain.js +138 -6
  11. package/dist/commands/licenses.js +37 -24
  12. package/dist/commands/login.js +12 -3
  13. package/dist/commands/logout.js +15 -4
  14. package/dist/commands/scan.js +1 -1
  15. package/dist/commands/service.js +76 -24
  16. package/dist/commands/status.js +38 -4
  17. package/dist/commands/types.js +1 -0
  18. package/dist/config/settings.js +102 -22
  19. package/dist/launcher/install-preflight.js +81 -12
  20. package/dist/launcher/output-redaction.js +5 -3
  21. package/dist/launcher/preflight-prompt.js +31 -12
  22. package/dist/launcher/run.js +87 -8
  23. package/dist/proxy/ca.js +69 -29
  24. package/dist/proxy/enforcement.js +41 -3
  25. package/dist/proxy/worker.js +21 -9
  26. package/dist/runtime/first-run.js +33 -2
  27. package/dist/runtime/nudges.js +9 -2
  28. package/dist/scan/analyze-worker.js +18 -8
  29. package/dist/scan/collect.js +35 -28
  30. package/dist/scan/command.js +80 -40
  31. package/dist/scan/discovery.js +9 -3
  32. package/dist/scan/render.js +22 -6
  33. package/dist/scan/scanner-report.js +89 -12
  34. package/dist/scan/staged.js +69 -7
  35. package/dist/scan-ui/LegacyApp.js +10 -48
  36. package/dist/scan-ui/components/InteractiveResultsView.js +171 -111
  37. package/dist/scan-ui/components/ProjectSelector.js +3 -3
  38. package/dist/scan-ui/components/ScoreHeader.js +8 -4
  39. package/dist/scan-ui/hooks/useScan.js +74 -27
  40. package/dist/scan-ui/launch.js +18 -4
  41. package/dist/service/state.js +15 -4
  42. package/dist/service/trust-store.js +23 -2
  43. package/dist/setup/git-hook.js +28 -17
  44. package/dist/setup/plan.js +302 -18
  45. package/dist/state/cleanup-registry.js +65 -8
  46. package/dist/state/locks.js +95 -9
  47. package/dist/state/sessions.js +66 -2
  48. package/dist/verify/package-check.js +22 -3
  49. package/dist/verify/preflight.js +328 -170
  50. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { dirname, join } from "node:path";
4
- import { resolveDgPaths } from "../state/index.js";
4
+ import { acquireLockSyncWithRetry, resolveDgPaths } from "../state/index.js";
5
5
  export const CONFIG_KEYS = Object.freeze([
6
6
  "api.baseUrl",
7
7
  "org.id",
@@ -59,10 +59,12 @@ export function loadUserConfig(env = process.env) {
59
59
  return DEFAULT_CONFIG;
60
60
  }
61
61
  try {
62
- const parsed = JSON.parse(readFileSync(path, "utf8"));
63
- return normalizeConfig(parsed);
62
+ return normalizeConfig(JSON.parse(readFileSync(path, "utf8")));
64
63
  }
65
64
  catch (error) {
65
+ if (error instanceof ConfigError) {
66
+ throw new ConfigError(`Invalid dg config at ${path}: ${error.message}`);
67
+ }
66
68
  throw new ConfigError(`Malformed dg config at ${path}: ${error instanceof Error ? error.message : "unknown error"}`);
67
69
  }
68
70
  }
@@ -70,6 +72,28 @@ export function saveUserConfig(config, env = process.env) {
70
72
  const paths = resolveDgPaths(env);
71
73
  writeJsonAtomic(userConfigPath(paths), config);
72
74
  }
75
+ export const USER_CONFIG_LOCK = "user-config";
76
+ const USER_CONFIG_LOCK_TIMEOUT_MS = 5_000;
77
+ const USER_CONFIG_LOCK_STALE_MS = 60_000;
78
+ export function withUserConfigLock(env, action) {
79
+ const lock = acquireLockSyncWithRetry(resolveDgPaths(env), USER_CONFIG_LOCK, {
80
+ staleMs: USER_CONFIG_LOCK_STALE_MS,
81
+ timeoutMs: USER_CONFIG_LOCK_TIMEOUT_MS
82
+ });
83
+ try {
84
+ return action();
85
+ }
86
+ finally {
87
+ lock.release();
88
+ }
89
+ }
90
+ export function updateUserConfig(apply, env = process.env) {
91
+ return withUserConfigLock(env, () => {
92
+ const next = apply(loadUserConfig(env));
93
+ saveUserConfig(next, env);
94
+ return next;
95
+ });
96
+ }
73
97
  export function getConfigValue(config, key) {
74
98
  if (key === "api.baseUrl") {
75
99
  return config.api.baseUrl;
@@ -166,7 +190,7 @@ export function setConfigValue(config, key, rawValue) {
166
190
  return {
167
191
  ...config,
168
192
  audit: {
169
- upload: parseBoolean(rawValue)
193
+ upload: parseBoolean(rawValue, key)
170
194
  }
171
195
  };
172
196
  }
@@ -174,14 +198,14 @@ export function setConfigValue(config, key, rawValue) {
174
198
  return {
175
199
  ...config,
176
200
  telemetry: {
177
- enabled: parseBoolean(rawValue)
201
+ enabled: parseBoolean(rawValue, key)
178
202
  }
179
203
  };
180
204
  }
181
205
  return {
182
206
  ...config,
183
207
  webhooks: {
184
- enabled: parseBoolean(rawValue)
208
+ enabled: parseBoolean(rawValue, key)
185
209
  }
186
210
  };
187
211
  }
@@ -192,38 +216,93 @@ export function isConfigKey(value) {
192
216
  return CONFIG_KEYS.includes(value);
193
217
  }
194
218
  function normalizeConfig(raw) {
219
+ if (!isPlainObject(raw)) {
220
+ throw new ConfigError(`config must be a JSON object, got ${describeJsonType(raw)}`);
221
+ }
195
222
  if (raw.version !== undefined && raw.version !== 1) {
196
223
  throw new ConfigError("unsupported config version");
197
224
  }
225
+ const api = fieldObject(raw, "api");
226
+ const org = fieldObject(raw, "org");
227
+ const policy = fieldObject(raw, "policy");
228
+ const gitHook = fieldObject(raw, "gitHook");
229
+ const audit = fieldObject(raw, "audit");
230
+ const telemetry = fieldObject(raw, "telemetry");
231
+ const webhooks = fieldObject(raw, "webhooks");
198
232
  return {
199
233
  version: 1,
200
234
  api: {
201
- baseUrl: parseUrl(raw.api?.baseUrl ?? DEFAULT_CONFIG.api.baseUrl)
235
+ baseUrl: parseUrl(fieldString(api, "api.baseUrl", "baseUrl") ?? DEFAULT_CONFIG.api.baseUrl)
202
236
  },
203
237
  org: {
204
- id: raw.org?.id ?? DEFAULT_CONFIG.org.id
238
+ id: fieldString(org, "org.id", "id") ?? DEFAULT_CONFIG.org.id
205
239
  },
206
240
  policy: {
207
- mode: parsePolicyMode(raw.policy?.mode ?? DEFAULT_CONFIG.policy.mode),
208
- trustProjectAllowlists: raw.policy?.trustProjectAllowlists ?? DEFAULT_CONFIG.policy.trustProjectAllowlists,
209
- allowForceOverride: raw.policy?.allowForceOverride ?? DEFAULT_CONFIG.policy.allowForceOverride,
210
- scriptHardening: raw.policy?.scriptHardening ?? DEFAULT_CONFIG.policy.scriptHardening
241
+ mode: parsePolicyMode(fieldString(policy, "policy.mode", "mode") ?? DEFAULT_CONFIG.policy.mode),
242
+ trustProjectAllowlists: fieldBoolean(policy, "policy.trustProjectAllowlists", "trustProjectAllowlists") ?? DEFAULT_CONFIG.policy.trustProjectAllowlists,
243
+ allowForceOverride: fieldBoolean(policy, "policy.allowForceOverride", "allowForceOverride") ?? DEFAULT_CONFIG.policy.allowForceOverride,
244
+ scriptHardening: fieldBoolean(policy, "policy.scriptHardening", "scriptHardening") ?? DEFAULT_CONFIG.policy.scriptHardening
211
245
  },
212
246
  gitHook: {
213
- onWarn: parseOnWarn(raw.gitHook?.onWarn ?? DEFAULT_CONFIG.gitHook.onWarn),
214
- onIncomplete: parseOnIncomplete(raw.gitHook?.onIncomplete ?? DEFAULT_CONFIG.gitHook.onIncomplete)
247
+ onWarn: parseOnWarn(fieldString(gitHook, "gitHook.onWarn", "onWarn") ?? DEFAULT_CONFIG.gitHook.onWarn),
248
+ onIncomplete: parseOnIncomplete(fieldString(gitHook, "gitHook.onIncomplete", "onIncomplete") ?? DEFAULT_CONFIG.gitHook.onIncomplete)
215
249
  },
216
250
  audit: {
217
- upload: raw.audit?.upload ?? DEFAULT_CONFIG.audit.upload
251
+ upload: fieldBoolean(audit, "audit.upload", "upload") ?? DEFAULT_CONFIG.audit.upload
218
252
  },
219
253
  telemetry: {
220
- enabled: raw.telemetry?.enabled ?? DEFAULT_CONFIG.telemetry.enabled
254
+ enabled: fieldBoolean(telemetry, "telemetry.enabled", "enabled") ?? DEFAULT_CONFIG.telemetry.enabled
221
255
  },
222
256
  webhooks: {
223
- enabled: raw.webhooks?.enabled ?? DEFAULT_CONFIG.webhooks.enabled
257
+ enabled: fieldBoolean(webhooks, "webhooks.enabled", "enabled") ?? DEFAULT_CONFIG.webhooks.enabled
224
258
  }
225
259
  };
226
260
  }
261
+ function fieldObject(root, field) {
262
+ const value = root[field];
263
+ if (value === undefined) {
264
+ return {};
265
+ }
266
+ if (!isPlainObject(value)) {
267
+ throw new ConfigError(`${field} must be a JSON object, got ${describeJsonType(value)}`);
268
+ }
269
+ return value;
270
+ }
271
+ function fieldBoolean(section, field, key) {
272
+ const value = section[key];
273
+ if (value === undefined) {
274
+ return undefined;
275
+ }
276
+ if (typeof value === "boolean") {
277
+ return value;
278
+ }
279
+ throw new ConfigError(`${field} must be a JSON boolean (true or false), got ${describeJsonType(value)}`);
280
+ }
281
+ function fieldString(section, field, key) {
282
+ const value = section[key];
283
+ if (value === undefined) {
284
+ return undefined;
285
+ }
286
+ if (typeof value === "string") {
287
+ return value;
288
+ }
289
+ throw new ConfigError(`${field} must be a string, got ${describeJsonType(value)}`);
290
+ }
291
+ function isPlainObject(value) {
292
+ return typeof value === "object" && value !== null && !Array.isArray(value);
293
+ }
294
+ function describeJsonType(value) {
295
+ if (value === null) {
296
+ return "null";
297
+ }
298
+ if (Array.isArray(value)) {
299
+ return "an array";
300
+ }
301
+ if (typeof value === "string") {
302
+ return `the string "${value.length > 32 ? `${value.slice(0, 32)}…` : value}"`;
303
+ }
304
+ return `a ${typeof value}`;
305
+ }
227
306
  function withPolicyBoolean(config, key, rawValue) {
228
307
  if (key === "mode") {
229
308
  return config;
@@ -232,7 +311,7 @@ function withPolicyBoolean(config, key, rawValue) {
232
311
  ...config,
233
312
  policy: {
234
313
  ...config.policy,
235
- [key]: parseBoolean(rawValue)
314
+ [key]: parseBoolean(rawValue, `policy.${key}`)
236
315
  }
237
316
  };
238
317
  }
@@ -254,21 +333,22 @@ function parseOnIncomplete(value) {
254
333
  }
255
334
  throw new ConfigError("gitHook.onIncomplete must be one of: allow, block");
256
335
  }
257
- function parseBoolean(value) {
336
+ function parseBoolean(value, field) {
258
337
  if (value === "true") {
259
338
  return true;
260
339
  }
261
340
  if (value === "false") {
262
341
  return false;
263
342
  }
264
- throw new ConfigError("boolean config values must be true or false");
343
+ throw new ConfigError(`${field} must be true or false`);
265
344
  }
266
345
  function parseUrl(value) {
267
346
  const trimmed = value.trim();
268
347
  try {
269
348
  const url = new URL(trimmed);
270
- if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
271
- throw new ConfigError("api.baseUrl must use https unless it targets localhost");
349
+ const localhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
350
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && localhost)) {
351
+ throw new ConfigError("api.baseUrl must use https, or http for localhost");
272
352
  }
273
353
  return url.toString().replace(/\/$/, "");
274
354
  }
@@ -1,11 +1,40 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
2
3
  import { analyzePackages } from "../api/analyze.js";
3
- import { defaultPromptIo, promptYesNo } from "../install-ui/prompt.js";
4
- import { parsePipReportInstallSet } from "./pip-report.js";
4
+ import { defaultPromptIo } from "../install-ui/prompt.js";
5
+ import { parsePipReportInstallCount, parsePipReportInstallSet } from "./pip-report.js";
5
6
  const PROCEED = { proceed: true };
7
+ const UNRESOLVED = { set: undefined, count: undefined };
8
+ const approvedFlagRanks = new Map();
9
+ const pipResolutions = new Map();
10
+ export function resetInstallPreflightSession() {
11
+ approvedFlagRanks.clear();
12
+ pipResolutions.clear();
13
+ }
14
+ export function actionRank(action) {
15
+ return { pass: 0, analysis_incomplete: 1, warn: 2, block: 3 }[action];
16
+ }
17
+ export function recordPreflightApprovals(packages) {
18
+ for (const pkg of packages) {
19
+ const key = `${pkg.name}@${pkg.version}`;
20
+ const rank = actionRank(pkg.action);
21
+ if ((approvedFlagRanks.get(key) ?? -1) < rank) {
22
+ approvedFlagRanks.set(key, rank);
23
+ }
24
+ }
25
+ }
26
+ function isPreflightApproved(pkg) {
27
+ return (approvedFlagRanks.get(`${pkg.name}@${pkg.version}`) ?? -1) >= actionRank(pkg.action);
28
+ }
29
+ export function cachedPipResolution(binary, args) {
30
+ return pipResolutions.get(pipResolutionKey(binary, args));
31
+ }
32
+ function pipResolutionKey(binary, args) {
33
+ return JSON.stringify([binary, ...args]);
34
+ }
6
35
  function resolvePipInstallSet(binary, args, env) {
7
36
  if (!binary)
8
- return Promise.resolve(undefined);
37
+ return Promise.resolve(UNRESOLVED);
9
38
  return new Promise((resolve) => {
10
39
  let stdout = "";
11
40
  let settled = false;
@@ -26,7 +55,7 @@ function resolvePipInstallSet(binary, args, env) {
26
55
  });
27
56
  }
28
57
  catch {
29
- finish(undefined);
58
+ finish(UNRESOLVED);
30
59
  return;
31
60
  }
32
61
  timer = setTimeout(() => {
@@ -34,11 +63,13 @@ function resolvePipInstallSet(binary, args, env) {
34
63
  child.kill();
35
64
  }
36
65
  catch { /* already exited */ }
37
- finish(undefined);
66
+ finish(UNRESOLVED);
38
67
  }, 30_000);
39
68
  child.stdout?.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
40
- child.on("error", () => finish(undefined));
41
- child.on("close", (code) => finish(code === 0 ? parsePipReportInstallSet(stdout) : undefined));
69
+ child.on("error", () => finish(UNRESOLVED));
70
+ child.on("close", (code) => finish(code === 0
71
+ ? { set: parsePipReportInstallSet(stdout), count: parsePipReportInstallCount(stdout) }
72
+ : UNRESOLVED));
42
73
  });
43
74
  }
44
75
  function findingSummary(pkg) {
@@ -59,7 +90,13 @@ export async function runInstallPreflight(manager, binary, childArgs, env) {
59
90
  if (manager !== "pip" || !binary) {
60
91
  return PROCEED;
61
92
  }
62
- const set = await resolvePipInstallSet(binary, childArgs, env);
93
+ const key = pipResolutionKey(binary, childArgs);
94
+ let resolution = pipResolutions.get(key);
95
+ if (!resolution) {
96
+ resolution = await resolvePipInstallSet(binary, childArgs, env);
97
+ pipResolutions.set(key, resolution);
98
+ }
99
+ const set = resolution.set;
63
100
  if (!set || set.length === 0) {
64
101
  return PROCEED;
65
102
  }
@@ -73,17 +110,49 @@ export async function runInstallPreflight(manager, binary, childArgs, env) {
73
110
  return decideFromVerdicts(verdicts.packages, defaultPromptIo());
74
111
  }
75
112
  export async function decideFromVerdicts(packages, io) {
76
- const flagged = packages.filter((pkg) => pkg.action === "warn" || pkg.action === "block");
113
+ const flagged = packages.filter((pkg) => (pkg.action === "warn" || pkg.action === "block") && !isPreflightApproved({ name: pkg.name, version: pkg.version, action: pkg.action }));
77
114
  if (flagged.length === 0 || !io.isTTY) {
78
115
  return PROCEED;
79
116
  }
80
117
  renderPreflight(flagged, io.output);
81
118
  const hasBlock = flagged.some((pkg) => pkg.action === "block");
82
- const accepted = hasBlock
83
- ? await promptYesNo(" Override and install anyway?", io, false)
84
- : await promptYesNo(" Proceed?", io, true);
119
+ const accepted = await promptPreflightYesNo(hasBlock ? " Override and install anyway?" : " Proceed?", io, false);
85
120
  if (!accepted) {
86
121
  return { proceed: false };
87
122
  }
123
+ recordPreflightApprovals(flagged.map((pkg) => ({ name: pkg.name, version: pkg.version, action: pkg.action ?? "warn" })));
88
124
  return hasBlock ? { proceed: true, forceOverride: { force: true } } : PROCEED;
89
125
  }
126
+ export async function promptPreflightYesNo(question, io, defaultYes) {
127
+ if (!io.isTTY) {
128
+ return false;
129
+ }
130
+ const rl = createInterface({ input: io.input, output: io.output });
131
+ try {
132
+ const answer = await new Promise((resolve) => {
133
+ rl.once("SIGINT", () => resolve(undefined));
134
+ rl.question(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, resolve);
135
+ });
136
+ if (answer === undefined) {
137
+ rl.close();
138
+ restoreRawInput(io.input);
139
+ io.output.write("\n");
140
+ process.exit(130);
141
+ }
142
+ const normalized = answer.trim().toLowerCase();
143
+ if (normalized === "") {
144
+ return defaultYes;
145
+ }
146
+ return normalized === "y" || normalized === "yes";
147
+ }
148
+ finally {
149
+ rl.close();
150
+ restoreRawInput(io.input);
151
+ }
152
+ }
153
+ function restoreRawInput(input) {
154
+ const stream = input;
155
+ if (stream.isTTY && typeof stream.setRawMode === "function") {
156
+ stream.setRawMode(false);
157
+ }
158
+ }
@@ -1,21 +1,23 @@
1
1
  const credentialUrlPattern = /\b([a-z][a-z0-9+.-]{0,31}:\/\/)([^/\s:@]+):([^/\s@]+)@/gi;
2
2
  const authHeaderPattern = /\b(proxy-authorization|authorization):\s*[^\r\n]+/gi;
3
- const tokenAssignmentPattern = /\b((?:npm_|pypi_|dg_)?(?:token|authToken|password|secret))=([^\s]+)/gi;
3
+ const tokenAssignmentPattern = /(?<![A-Za-z0-9])([A-Za-z0-9_-]*(?:secret[_-]key|access[_-]key|api[_-]key|token|password|secret))=([^\s]+)/gi;
4
4
  const npmrcAuthPattern = /(_authToken|_auth|_password)\s*=\s*("[^"]*"|[^\s;,]+)/gi;
5
+ const jsonSecretPattern = /("[A-Za-z0-9_.$-]*(?:secret[_-]key|access[_-]key|api[_-]key|token|password|secret)"\s*:\s*)"(?:[^"\\]|\\.)*"/gi;
5
6
  const bearerPattern = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g;
6
- const knownTokenShapePattern = /\b(npm_[A-Za-z0-9]{36}|gh[pousr]_[A-Za-z0-9]{36,255}|pypi-[A-Za-z0-9_-]{20,})\b/g;
7
+ const knownTokenShapePattern = /\b(npm_[A-Za-z0-9]{36}|gh[pousr]_[A-Za-z0-9]{36,255}|pypi-[A-Za-z0-9_-]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[bp]-[A-Za-z0-9-]{10,})\b/g;
7
8
  export function redactSecrets(text) {
8
9
  return text
9
10
  .replace(credentialUrlPattern, "$1<redacted>@")
10
11
  .replace(authHeaderPattern, (_match, header) => `${header}: <redacted>`)
11
12
  .replace(npmrcAuthPattern, "$1=<redacted>")
13
+ .replace(jsonSecretPattern, '$1"<redacted>"')
12
14
  .replace(tokenAssignmentPattern, "$1=<redacted>")
13
15
  .replace(bearerPattern, "Bearer <redacted>")
14
16
  .replace(knownTokenShapePattern, "<redacted>");
15
17
  }
16
18
  const STREAM_FLUSH_QUIET_MS = 80;
17
19
  const SECRET_TAIL_SCAN_CHARS = 80;
18
- const secretTailPattern = /(Bearer\s+[\w.~+/=-]*|npm_[A-Za-z0-9]*|gh[pousr]_[A-Za-z0-9]*|pypi-[\w-]*|(?:_authToken|_auth|_password|token|authToken|password|secret)=\S*)$/i;
20
+ const secretTailPattern = /(Bearer\s+[\w.~+/=-]*|npm_[A-Za-z0-9]*|gh[pousr]_[A-Za-z0-9]*|pypi-[\w-]*|glpat-[\w-]*|xox[bp]-[\w-]*|[\w-]*(?:_authToken|_auth|_password|secret[_-]key|access[_-]key|api[_-]key|token|password|secret)=\S*|"[\w.$-]*(?:secret[_-]key|access[_-]key|api[_-]key|token|password|secret)"\s*:\s*"?[^"\n]*)$/i;
19
21
  export function createStreamRedactor(emit) {
20
22
  let pending = "";
21
23
  let timer;
@@ -1,9 +1,10 @@
1
1
  import { analyzePackages } from "../api/analyze.js";
2
2
  import { isCiEnv } from "../presentation/mode.js";
3
3
  import { renderInstallDecision } from "../install-ui/block-render.js";
4
- import { promptYesNo, defaultPromptIo } from "../install-ui/prompt.js";
4
+ import { defaultPromptIo } from "../install-ui/prompt.js";
5
5
  import { enforceProtectedInstall } from "../proxy/enforcement.js";
6
6
  import { classifyPackageManagerInvocation, isSupportedPackageManager } from "./classify.js";
7
+ import { actionRank, promptPreflightYesNo, recordPreflightApprovals } from "./install-preflight.js";
7
8
  import { redactSecrets } from "./output-redaction.js";
8
9
  const ECOSYSTEM_BY_MANAGER = {
9
10
  npm: "npm",
@@ -37,25 +38,27 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
37
38
  if (specs.length === 0) {
38
39
  return FALL_THROUGH;
39
40
  }
40
- let worst;
41
+ const flagged = [];
41
42
  try {
42
43
  const response = await (options.analyze ?? analyzePackages)(specs.map((spec) => ({ name: spec.name, version: spec.version })), { ecosystem, env });
43
44
  for (const spec of specs) {
44
45
  const pkg = response.packages.find((entry) => entry.name === spec.name && entry.version === spec.version);
45
46
  const action = pkg?.action ?? "pass";
46
- if (!worst || rank(action) > rank(worst.action)) {
47
- worst = {
48
- action,
49
- spec,
50
- reason: pkg?.reasons[0] ?? pkg?.findings[0]?.title ?? "flagged by the scanner"
51
- };
47
+ if (action === "pass") {
48
+ continue;
52
49
  }
50
+ flagged.push({
51
+ action,
52
+ spec,
53
+ reason: pkg?.reasons[0] ?? pkg?.findings[0]?.title ?? "flagged by the scanner"
54
+ });
53
55
  }
54
56
  }
55
57
  catch {
56
58
  return FALL_THROUGH;
57
59
  }
58
- if (!worst || worst.action === "pass") {
60
+ const worst = flagged.reduce((top, entry) => (!top || actionRank(entry.action) > actionRank(top.action) ? entry : top), undefined);
61
+ if (!worst) {
59
62
  return FALL_THROUGH;
60
63
  }
61
64
  if (worst.action === "block") {
@@ -80,8 +83,20 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
80
83
  return FALL_THROUGH;
81
84
  }
82
85
  const label = `${worst.spec.name}@${worst.spec.version}`;
83
- const proceed = await promptYesNo(`⚠ DG flagged ${label} (warn) — ${worst.reason}. Proceed?`, io);
86
+ if (worst.action === "analysis_incomplete") {
87
+ const proceed = await promptPreflightYesNo(`? DG could not fully analyze ${label} (analysis incomplete) — ${worst.reason}. Proceed?`, io, true);
88
+ if (proceed) {
89
+ recordPreflightApprovals(flagged.map(asFlaggedPackage));
90
+ return FALL_THROUGH;
91
+ }
92
+ return {
93
+ handled: true,
94
+ result: { exitCode: 4, stdout: "", stderr: "Declined. Nothing was installed.\n" }
95
+ };
96
+ }
97
+ const proceed = await promptPreflightYesNo(`⚠ DG flagged ${label} (warn) — ${worst.reason}. Proceed?`, io, false);
84
98
  if (proceed) {
99
+ recordPreflightApprovals(flagged.map(asFlaggedPackage));
85
100
  return FALL_THROUGH;
86
101
  }
87
102
  return {
@@ -89,8 +104,12 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
89
104
  result: { exitCode: 1, stdout: "", stderr: "Declined. Nothing was installed.\n" }
90
105
  };
91
106
  }
92
- function rank(action) {
93
- return { pass: 0, analysis_incomplete: 1, warn: 2, block: 3 }[action];
107
+ function asFlaggedPackage(entry) {
108
+ return {
109
+ name: entry.spec.name,
110
+ version: entry.spec.version,
111
+ action: entry.action
112
+ };
94
113
  }
95
114
  function stripControlArgs(args) {
96
115
  const childArgs = [];
@@ -12,10 +12,57 @@ import { readProxySessionState } from "../proxy/server.js";
12
12
  import { cleanupSessionSync, createSessionSync, resolveDgPaths } from "../state/index.js";
13
13
  import { classifyPackageManagerInvocation } from "./classify.js";
14
14
  import { buildProxyChildEnv } from "./env.js";
15
+ import { cachedPipResolution } from "./install-preflight.js";
15
16
  import { parsePipReportInstallCount } from "./pip-report.js";
16
17
  import { createStreamRedactor, redactSecrets } from "./output-redaction.js";
17
18
  import { resolveRealBinary } from "./resolve-real-binary.js";
18
19
  export const EXIT_INSTALL_BLOCKED = 2;
20
+ const CMD_SCRIPT_PATTERN = /\.(cmd|bat)$/i;
21
+ const CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
22
+ export function resolveSpawnInvocation(binary, args, platform = process.platform) {
23
+ if (platform !== "win32" || !CMD_SCRIPT_PATTERN.test(binary)) {
24
+ return { command: binary, args, windowsVerbatimArguments: false };
25
+ }
26
+ const commandLine = [escapeCmdCommand(binary), ...args.map(escapeCmdArgument)].join(" ");
27
+ return {
28
+ command: process.env.comspec ?? "cmd.exe",
29
+ args: ["/d", "/s", "/c", `"${commandLine}"`],
30
+ windowsVerbatimArguments: true
31
+ };
32
+ }
33
+ function escapeCmdCommand(command) {
34
+ return command.replace(CMD_META_CHARS, "^$1");
35
+ }
36
+ // cmd shims parse their command line twice, hence the doubled meta-char escape (cross-spawn's algorithm)
37
+ function escapeCmdArgument(argument) {
38
+ const quoted = `"${argument.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`;
39
+ return quoted.replace(CMD_META_CHARS, "^$1").replace(CMD_META_CHARS, "^$1");
40
+ }
41
+ export function shimDepth(env) {
42
+ const parsed = Number.parseInt(env.DG_SHIM_DEPTH ?? "", 10);
43
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
44
+ }
45
+ export function rootUnprotectedNotice(env, uid = process.getuid?.()) {
46
+ if (uid !== 0) {
47
+ return "";
48
+ }
49
+ if (existsSync(resolveDgPaths(env).stateDir)) {
50
+ return "";
51
+ }
52
+ return "dg: running as root without dg state — bare package-manager installs by root are not protected\n";
53
+ }
54
+ let rootNoticeWritten = false;
55
+ function maybeWarnRootWithoutState(env) {
56
+ if (rootNoticeWritten) {
57
+ return;
58
+ }
59
+ const notice = rootUnprotectedNotice(env);
60
+ if (!notice) {
61
+ return;
62
+ }
63
+ rootNoticeWritten = true;
64
+ process.stderr.write(notice);
65
+ }
19
66
  export function createLaunchPlan(manager, args, env = process.env) {
20
67
  const classification = classifyPackageManagerInvocation(manager, args);
21
68
  const realBinary = resolveRealBinary({
@@ -28,7 +75,8 @@ export function createLaunchPlan(manager, args, env = process.env) {
28
75
  startsProxy: classification.kind === "protected",
29
76
  childEnv: {
30
77
  ...env,
31
- DG_SHIM_ACTIVE: shimNonce(manager, env)
78
+ DG_SHIM_ACTIVE: shimNonce(manager, env),
79
+ DG_SHIM_DEPTH: String(shimDepth(env) + 1)
32
80
  }
33
81
  };
34
82
  }
@@ -41,6 +89,23 @@ export async function runPackageManager(manager, args, options = {}) {
41
89
  if (!plan.realBinary.path) {
42
90
  return unavailable(invoked, `real ${plan.classification.realBinaryName} binary was not found outside dg shims`);
43
91
  }
92
+ maybeWarnRootWithoutState(options.env ?? process.env);
93
+ const depth = shimDepth(options.env ?? process.env);
94
+ if (depth >= 2) {
95
+ return {
96
+ exitCode: EXIT_UNAVAILABLE,
97
+ stdout: "",
98
+ stderr: `dg: ${manager} shim exec loop detected (DG_SHIM_DEPTH=${depth}) — refusing to re-enter\n`
99
+ };
100
+ }
101
+ if (depth === 1 && plan.startsProxy) {
102
+ const child = await spawnPackageManager(plan, args, options);
103
+ return {
104
+ exitCode: child.exitCode,
105
+ stdout: streamedOut(child.stdout, options),
106
+ stderr: `dg: re-entered through its own shim — running the real ${plan.classification.realBinaryName} directly\n${streamedErr(child.stderr, options)}`
107
+ };
108
+ }
44
109
  if (plan.startsProxy) {
45
110
  if (!options.proxyVerdict) {
46
111
  return runWithProductionProxy(plan, args, options);
@@ -173,9 +238,11 @@ function resolvePipInstallTotal(binary, args, env, register) {
173
238
  };
174
239
  let child;
175
240
  try {
176
- child = spawn(binary, [...args, "--dry-run", "--report", "-", "--quiet"], {
241
+ const invocation = resolveSpawnInvocation(binary, [...args, "--dry-run", "--report", "-", "--quiet"]);
242
+ child = spawn(invocation.command, [...invocation.args], {
177
243
  env,
178
- stdio: ["ignore", "pipe", "ignore"]
244
+ stdio: ["ignore", "pipe", "ignore"],
245
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
179
246
  });
180
247
  }
181
248
  catch {
@@ -200,6 +267,7 @@ export async function runWithProductionProxyLive(plan, args, options, onView) {
200
267
  throw new Error("live install mode renders its own UI and owns the terminal; streaming output callbacks are not supported");
201
268
  }
202
269
  const env = options.env ?? process.env;
270
+ maybeWarnRootWithoutState(env);
203
271
  const proxy = await startProxyWorker(plan.classification, env, options.forceOverride);
204
272
  if ("decision" in proxy) {
205
273
  return { exitCode: EXIT_INSTALL_BLOCKED, stdout: "", stderr: redactSecrets(renderInstallDecision(proxy.decision)) };
@@ -217,9 +285,15 @@ export async function runWithProductionProxyLive(plan, args, options, onView) {
217
285
  let resolvedTotal;
218
286
  let dryRunChild;
219
287
  if (plan.classification.manager === "pip") {
220
- void resolvePipInstallTotal(plan.realBinary.path ?? "", args, childEnv, (child) => { dryRunChild = child; })
221
- .then((count) => { resolvedTotal = count; })
222
- .catch(() => undefined);
288
+ const cached = cachedPipResolution(plan.realBinary.path ?? "", args);
289
+ if (cached) {
290
+ resolvedTotal = cached.count;
291
+ }
292
+ else {
293
+ void resolvePipInstallTotal(plan.realBinary.path ?? "", args, childEnv, (child) => { dryRunChild = child; })
294
+ .then((count) => { resolvedTotal = count; })
295
+ .catch(() => undefined);
296
+ }
223
297
  }
224
298
  const poll = setInterval(() => {
225
299
  onView(deriveLiveView(readProxySessionState(proxy.session), "scanning", resolvedTotal));
@@ -292,9 +366,11 @@ async function spawnPackageManager(plan, args, options) {
292
366
  });
293
367
  }
294
368
  const defaultSpawner = (request) => new Promise((resolve) => {
295
- const child = spawn(request.binary, [...request.args], {
369
+ const invocation = resolveSpawnInvocation(request.binary, request.args);
370
+ const child = spawn(invocation.command, [...invocation.args], {
296
371
  env: request.env,
297
- stdio: ["inherit", "pipe", "pipe"]
372
+ stdio: ["inherit", "pipe", "pipe"],
373
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
298
374
  });
299
375
  const stdout = [];
300
376
  const stderr = [];
@@ -421,6 +497,9 @@ function stopProxyWorker(proxy) {
421
497
  function installProxySignalHandlers(proxy) {
422
498
  const registrations = ["SIGINT", "SIGTERM"].map((signal) => {
423
499
  const handler = () => {
500
+ if (process.stdin.isTTY) {
501
+ process.stdin.setRawMode(false);
502
+ }
424
503
  stopProxyWorker(proxy);
425
504
  process.exit(signal === "SIGINT" ? 130 : 143);
426
505
  };