@westbayberry/dg 2.3.3 → 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +94 -217
  2. package/dist/agents/copilot-cli.js +7 -2
  3. package/dist/agents/persistence.js +83 -8
  4. package/dist/agents/registry.js +5 -2
  5. package/dist/agents/routing.js +31 -14
  6. package/dist/api/analyze.js +138 -41
  7. package/dist/audit/deep.js +2 -1
  8. package/dist/audit/detectors.js +23 -2
  9. package/dist/audit/rules.js +4 -1
  10. package/dist/audit-ui/export.js +5 -4
  11. package/dist/auth/device-login.js +33 -11
  12. package/dist/auth/login-app.js +17 -12
  13. package/dist/auth/store.js +65 -9
  14. package/dist/bin/dg.js +1 -1
  15. package/dist/commands/audit.js +12 -20
  16. package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
  17. package/dist/commands/cooldown.js +2 -1
  18. package/dist/commands/decisions.js +8 -7
  19. package/dist/commands/help.js +11 -3
  20. package/dist/commands/licenses.js +17 -8
  21. package/dist/commands/login.js +58 -21
  22. package/dist/commands/router.js +12 -2
  23. package/dist/commands/sbom.js +20 -5
  24. package/dist/commands/scan.js +5 -4
  25. package/dist/commands/service.js +1 -1
  26. package/dist/commands/setup.js +12 -12
  27. package/dist/commands/status.js +4 -4
  28. package/dist/commands/uninstall.js +1 -1
  29. package/dist/commands/update.js +20 -0
  30. package/dist/commands/verify.js +4 -4
  31. package/dist/config/settings.js +81 -12
  32. package/dist/export-ui/ExportDialog.js +1 -1
  33. package/dist/gate/cooldown-request.js +22 -0
  34. package/dist/gate/egress-guard.js +185 -0
  35. package/dist/gate/host-match.js +48 -0
  36. package/dist/gate/verdict-core.js +101 -0
  37. package/dist/gate/verdict-fetch.js +214 -0
  38. package/dist/launcher/agent-check.js +1096 -42
  39. package/dist/launcher/agent-hook-exec.js +8 -1
  40. package/dist/launcher/agent-hook-io.js +5 -3
  41. package/dist/launcher/classify.js +110 -26
  42. package/dist/launcher/env.js +84 -18
  43. package/dist/launcher/install-preflight.js +26 -3
  44. package/dist/launcher/live-install.js +4 -2
  45. package/dist/launcher/manifest-screen.js +88 -7
  46. package/dist/launcher/output-redaction.js +153 -15
  47. package/dist/launcher/preflight-prompt.js +1 -1
  48. package/dist/launcher/resolve-real-binary.js +8 -3
  49. package/dist/launcher/run.js +237 -40
  50. package/dist/origin/artifact-cache.js +55 -0
  51. package/dist/origin/cargo-config.js +12 -0
  52. package/dist/origin/gate-mode.js +7 -0
  53. package/dist/origin/platform-paths.js +14 -0
  54. package/dist/origin/precedence.js +345 -0
  55. package/dist/origin/server.js +611 -0
  56. package/dist/origin/spike-worker.js +35 -0
  57. package/dist/origin/worker.js +116 -0
  58. package/dist/presentation/package-page.js +21 -1
  59. package/dist/presentation/safe-version.js +11 -0
  60. package/dist/presentation/sarif.js +109 -0
  61. package/dist/project/dgfile.js +54 -17
  62. package/dist/project/override-trust.js +0 -0
  63. package/dist/proxy/enforcement.js +27 -7
  64. package/dist/proxy/metadata-map.js +33 -7
  65. package/dist/proxy/preverified.js +0 -3
  66. package/dist/proxy/server.js +104 -446
  67. package/dist/proxy/upstream-proxy.js +23 -4
  68. package/dist/proxy/worker.js +3 -2
  69. package/dist/publish-set/collect.js +6 -0
  70. package/dist/runtime/fatal.js +2 -1
  71. package/dist/runtime/first-run.js +5 -1
  72. package/dist/runtime/node-version.js +51 -0
  73. package/dist/runtime/nudges.js +27 -1
  74. package/dist/sbom/cyclonedx.js +120 -33
  75. package/dist/sbom/graph.js +236 -0
  76. package/dist/sbom/spdx.js +53 -0
  77. package/dist/sbom-ui/SbomApp.js +5 -3
  78. package/dist/sbom-ui/inventory.js +8 -10
  79. package/dist/scan/analyze-worker.js +3 -1
  80. package/dist/scan/collect.js +38 -8
  81. package/dist/scan/command.js +77 -27
  82. package/dist/scan/discovery.js +18 -7
  83. package/dist/scan/installed.js +202 -0
  84. package/dist/scan/manifest-coverage.js +128 -0
  85. package/dist/scan/render.js +125 -40
  86. package/dist/scan/scanner-report.js +92 -22
  87. package/dist/scan/staged.js +68 -15
  88. package/dist/scan/types.js +7 -1
  89. package/dist/scan-ui/LegacyApp.js +3 -3
  90. package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
  91. package/dist/scan-ui/hooks/useScan.js +37 -11
  92. package/dist/scan-ui/launch.js +2 -2
  93. package/dist/scan-ui/logo.js +6 -2
  94. package/dist/scan-ui/shims.js +6 -6
  95. package/dist/scripts/gate.js +53 -35
  96. package/dist/security/csv.js +9 -0
  97. package/dist/security/sanitize.js +11 -2
  98. package/dist/service/state.js +120 -37
  99. package/dist/service/trust-refresh.js +12 -1
  100. package/dist/service/trust-store.js +36 -5
  101. package/dist/service/worker.js +5 -7
  102. package/dist/setup/git-hook.js +2 -2
  103. package/dist/setup/install-method.js +36 -0
  104. package/dist/setup/plan.js +119 -57
  105. package/dist/setup/uninstall-standalone.js +7 -2
  106. package/dist/setup-ui/gate.js +1 -8
  107. package/dist/setup-ui/offer.js +14 -7
  108. package/dist/setup-ui/selector.js +1 -1
  109. package/dist/setup-ui/wizard.js +73 -78
  110. package/dist/standalone/uninstall.mjs +1114 -386
  111. package/dist/state/cleanup-registry.js +14 -3
  112. package/dist/state/cooldown-held.js +36 -13
  113. package/dist/state/locks.js +239 -34
  114. package/dist/state/sessions.js +2 -2
  115. package/dist/util/sh-escape.js +6 -0
  116. package/dist/util/tty-prompt.js +13 -4
  117. package/dist/verify/local.js +60 -10
  118. package/dist/verify/package-check.js +35 -49
  119. package/dist/verify/preflight.js +59 -33
  120. package/dist/verify/render.js +11 -12
  121. package/npm-shrinkwrap.json +714 -213
  122. package/package.json +3 -2
@@ -1,11 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { existsSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { applyDecisions, packageKey } from "../decisions/apply.js";
7
7
  import { sanitize } from "../security/sanitize.js";
8
- import { collectScanPackages, discoverScanProjects } from "./collect.js";
8
+ import { collectScanPackages, discoverScanProjects, mergeInstalledPackages } from "./collect.js";
9
+ import { discoverInstalledPackages } from "./installed.js";
10
+ import { applyLocalFindingEscalation } from "./render.js";
11
+ import { ESCALATING_SKIP_REASONS } from "./types.js";
9
12
  const WORKER_TIMEOUT_BASE_MS = 180_000;
10
13
  const SERVER_PER_PACKAGE_WORST_CASE_MS = 660_000;
11
14
  const SERVER_SCAN_CONCURRENCY = 64;
@@ -13,16 +16,39 @@ const WORKER_MAX_BUFFER = 64 * 1024 * 1024;
13
16
  export function scanWorkerTimeoutMs(packageCount) {
14
17
  return WORKER_TIMEOUT_BASE_MS + packageCount * Math.ceil(SERVER_PER_PACKAGE_WORST_CASE_MS / SERVER_SCAN_CONCURRENCY);
15
18
  }
16
- export function runScannerScan(targetPath, localReport, env = process.env, decisionsFile = null) {
17
- const projects = discoverScanProjects(resolve(targetPath));
18
- if (projects.length === 0) {
19
+ // Stable identity of the resolved dependency tree sorted `eco:name@version`
20
+ // keys hashed. `dg scan --install-origin` sends it so the server bills the tree
21
+ // once per user per month rather than on every reinstall.
22
+ function computeTreeHash(groups) {
23
+ const keys = [];
24
+ for (const group of groups) {
25
+ for (const pkg of group.packages) {
26
+ keys.push(`${group.ecosystem}:${pkg.name}@${pkg.version}`);
27
+ }
28
+ }
29
+ keys.sort();
30
+ return createHash("sha256").update(keys.join("\n")).digest("hex");
31
+ }
32
+ export function runScannerScan(targetPath, localReport, env = process.env, decisionsFile = null, installOrigin = false) {
33
+ const root = resolve(targetPath);
34
+ const projects = discoverScanProjects(root);
35
+ const installed = discoverInstalledPackages(root, env);
36
+ const installedTotal = [...installed.byEcosystem.values()].reduce((sum, list) => sum + list.length, 0);
37
+ if (projects.length === 0 && installedTotal === 0) {
19
38
  return { kind: "skipped", reason: "no_lockfiles" };
20
39
  }
21
- const collected = collectScanPackages(projects);
40
+ const collected = mergeInstalledPackages(collectScanPackages(projects), installed);
22
41
  const groups = [...collected.byEcosystem.entries()].map(([ecosystem, packages]) => ({ ecosystem, packages }));
23
42
  const total = groups.reduce((sum, group) => sum + group.packages.length, 0);
24
43
  if (total === 0) {
25
- if (collected.skipped > 0) {
44
+ // A lockfile that resolves to only workspace/local links (or a package from an
45
+ // ecosystem the analyzer does not cover, or a bare index directive) is a clean
46
+ // no-op, not a parse failure. Everything else that dropped a package —
47
+ // git/direct-url specs, unpinned or off-registry entries, real parse errors —
48
+ // still fails closed so an unverifiable artifact can't slip through as a
49
+ // benign skip.
50
+ const benignSkips = collected.skippedPackages.filter((pkg) => isBenignSkip(pkg.reason)).length;
51
+ if (collected.skipped > benignSkips) {
26
52
  return { kind: "failed", error: lockfileParseError(projects, collected) };
27
53
  }
28
54
  return { kind: "skipped", reason: "no_packages" };
@@ -37,11 +63,12 @@ export function runScannerScan(targetPath, localReport, env = process.env, decis
37
63
  error: { kind: "worker", message: "scanner worker is missing — reinstall @westbayberry/dg" }
38
64
  };
39
65
  }
66
+ const installTreeHash = installOrigin ? computeTreeHash(groups) : null;
40
67
  const timeoutMs = scanWorkerTimeoutMs(total);
41
68
  const worker = spawnSync(process.execPath, [workerPath], {
42
69
  encoding: "utf8",
43
70
  env,
44
- input: JSON.stringify({ scanId: randomUUID(), groups }),
71
+ input: JSON.stringify({ scanId: randomUUID(), groups, ...(installTreeHash ? { installTreeHash } : {}) }),
45
72
  maxBuffer: WORKER_MAX_BUFFER,
46
73
  timeout: timeoutMs
47
74
  });
@@ -65,12 +92,51 @@ export function runScannerScan(targetPath, localReport, env = process.env, decis
65
92
  ecosystems.set(packageKey(pkg.name, pkg.version), group.ecosystem);
66
93
  }
67
94
  }
68
- const report = buildScannerReport(localReport, response, total, ecosystems);
95
+ const report = withUnscanned(buildScannerReport(localReport, response, total, ecosystems, collected.origins), collected.skippedPackages);
69
96
  if (!decisionsFile?.readable) {
70
97
  return { kind: "report", report };
71
98
  }
72
99
  return { kind: "report", report: annotateReportWithDecisions(report, decisionsFile, ecosystems) };
73
100
  }
101
+ function isBenignSkip(reason) {
102
+ return reason === "workspace" || reason === "local";
103
+ }
104
+ export function summarizeUnscanned(skippedPackages) {
105
+ if (!skippedPackages || skippedPackages.length === 0) {
106
+ return undefined;
107
+ }
108
+ const surfaced = skippedPackages.filter((pkg) => !isBenignSkip(pkg.reason));
109
+ if (surfaced.length === 0) {
110
+ return undefined;
111
+ }
112
+ const byReason = {};
113
+ let escalating = 0;
114
+ for (const pkg of surfaced) {
115
+ byReason[pkg.reason] = (byReason[pkg.reason] ?? 0) + 1;
116
+ if (ESCALATING_SKIP_REASONS.has(pkg.reason)) {
117
+ escalating += 1;
118
+ }
119
+ }
120
+ return { total: surfaced.length, escalating, byReason, sample: surfaced.slice(0, 20) };
121
+ }
122
+ // An escalating skip means a package that should have been verified was dropped,
123
+ // so a passing scan is downgraded to analysis_incomplete rather than reading clean.
124
+ export function withUnscanned(report, skippedPackages) {
125
+ const unscanned = summarizeUnscanned(skippedPackages);
126
+ if (!unscanned) {
127
+ return report;
128
+ }
129
+ const hasLocalBlock = report.findings.some((finding) => finding.severity === "block" && !finding.acknowledged);
130
+ if (unscanned.escalating > 0 && report.scanner && report.scanner.action !== "block" && !hasLocalBlock) {
131
+ return {
132
+ ...report,
133
+ status: "unknown",
134
+ scanner: { ...report.scanner, action: "analysis_incomplete" },
135
+ unscanned
136
+ };
137
+ }
138
+ return { ...report, unscanned };
139
+ }
74
140
  export function tryScannerScan(targetPath, localReport, env = process.env, decisionsFile = null) {
75
141
  const outcome = runScannerScan(targetPath, localReport, env, decisionsFile);
76
142
  return outcome.kind === "report" ? outcome.report : null;
@@ -143,22 +209,24 @@ function lockfileParseError(projects, collected) {
143
209
  : `found ${projects.length} lockfile${projects.length === 1 ? "" : "s"} (${lockfiles}) but no packages could be parsed`
144
210
  };
145
211
  }
146
- export function buildScannerReport(localReport, response, analyzedCount, ecosystems) {
147
- const packages = ecosystems
148
- ? response.packages.map((pkg) => {
149
- const ecosystem = ecosystems.get(packageKey(pkg.name, pkg.version));
150
- return ecosystem ? { ...pkg, ecosystem } : pkg;
151
- })
152
- : response.packages;
153
- const findings = packages
212
+ export function buildScannerReport(localReport, response, analyzedCount, ecosystems, origins) {
213
+ const packages = response.packages.map((pkg) => {
214
+ const ecosystem = pkg.ecosystem ?? ecosystems?.get(packageKey(pkg.name, pkg.version));
215
+ return ecosystem ? { ...pkg, ecosystem } : pkg;
216
+ });
217
+ const scannerFindings = packages
154
218
  .filter((pkg) => (pkg.action ?? "pass") !== "pass")
155
- .map((pkg) => scannerFinding(pkg));
219
+ .map((pkg) => scannerFinding(pkg, origins));
220
+ const scannerLocations = new Set(scannerFindings.map((finding) => finding.location));
221
+ const localFindings = localReport.findings.filter((finding) => !scannerLocations.has(finding.location));
222
+ const findings = [...localFindings, ...scannerFindings];
156
223
  const warnCount = findings.filter((finding) => finding.severity === "warn").length;
157
224
  const blockCount = findings.filter((finding) => finding.severity === "block").length;
158
225
  return {
159
226
  target: localReport.target,
160
- status: statusFromAction(response.action),
161
- projects: localReport.projects.map((project) => ({ ...project, findings: [] })),
227
+ ...(localReport.targetDir !== undefined ? { targetDir: localReport.targetDir } : {}),
228
+ status: statusFromAction(applyLocalFindingEscalation(response.action, findings)),
229
+ projects: localReport.projects,
162
230
  findings,
163
231
  errors: localReport.errors,
164
232
  summary: {
@@ -172,16 +240,18 @@ export function buildScannerReport(localReport, response, analyzedCount, ecosyst
172
240
  scanner: { ...response, packages }
173
241
  };
174
242
  }
175
- function scannerFinding(pkg) {
243
+ function scannerFinding(pkg, origins) {
176
244
  const action = pkg.action ?? "pass";
177
245
  const top = pkg.findings[0];
246
+ const manifestPath = pkg.ecosystem ? origins?.get(`${pkg.ecosystem}:${pkg.name}@${pkg.version}`) : undefined;
178
247
  return {
179
248
  id: top?.category ?? top?.id ?? "scanner-finding",
180
249
  severity: action === "block" ? "block" : "warn",
181
250
  title: top?.title ?? pkg.reasons[0] ?? `scanner ${action} verdict`,
182
251
  message: pkg.reasons.join("; ") || top?.title || `scanner returned ${action} (score ${pkg.score})`,
183
252
  project: "",
184
- location: `${pkg.name}@${pkg.version}`
253
+ location: `${pkg.name}@${pkg.version}`,
254
+ ...(manifestPath ? { manifestPath } : {})
185
255
  };
186
256
  }
187
257
  function statusFromAction(action) {
@@ -7,6 +7,7 @@ import { DEFAULT_CONFIG, loadUserConfig } from "../config/settings.js";
7
7
  import { offerRememberSync } from "../decisions/remember-prompt.js";
8
8
  import { packageKey } from "../decisions/apply.js";
9
9
  import { loadDgFile, resolveAcceptedBy, warnUnreadableDgFile } from "../project/dgfile.js";
10
+ import { gateProjectDecisions } from "../project/override-trust.js";
10
11
  import { gitSync, gitTrimmed } from "../util/git.js";
11
12
  import { promptYesNo } from "../util/tty-prompt.js";
12
13
  import { GUARD_SELFTEST_ENV } from "../setup/git-hook.js";
@@ -30,6 +31,15 @@ function emptyLocalReport(target) {
30
31
  }
31
32
  };
32
33
  }
34
+ const DEPENDENCY_MANIFEST_NAMES = new Set([
35
+ "package.json",
36
+ "pyproject.toml",
37
+ "Pipfile",
38
+ "setup.cfg",
39
+ "setup.py",
40
+ "environment.yml",
41
+ "environment.yaml"
42
+ ]);
33
43
  export function stagedLockfilePaths(cwd, env) {
34
44
  const diff = gitSync(["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, env });
35
45
  if (!diff.ok) {
@@ -37,6 +47,13 @@ export function stagedLockfilePaths(cwd, env) {
37
47
  }
38
48
  return diff.stdout.split("\0").filter(Boolean).filter((path) => isLockfileName(basename(path)));
39
49
  }
50
+ export function stagedDependencyManifestPaths(cwd, env) {
51
+ const diff = gitSync(["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, env });
52
+ if (!diff.ok) {
53
+ return [];
54
+ }
55
+ return diff.stdout.split("\0").filter(Boolean).filter((path) => DEPENDENCY_MANIFEST_NAMES.has(basename(path)));
56
+ }
40
57
  export function scopeStagedPaths(paths, root, cwd, targetPath) {
41
58
  if (!targetPath) {
42
59
  return [...paths];
@@ -53,18 +70,42 @@ export function scopeStagedPaths(paths, root, cwd, targetPath) {
53
70
  }
54
71
  export function materializeStaged(relPaths, cwd, env) {
55
72
  const dir = mkdtempSync(join(tmpdir(), "dg-staged-"));
56
- let count = 0;
57
- for (const relative of relPaths) {
58
- const blob = gitSync(["show", `:${relative}`], { cwd, env });
59
- if (!blob.ok) {
60
- continue;
73
+ try {
74
+ let count = 0;
75
+ for (const relative of relPaths) {
76
+ const blob = gitSync(["show", `:${relative}`], { cwd, env });
77
+ if (!blob.ok) {
78
+ continue;
79
+ }
80
+ const destination = join(dir, relative);
81
+ mkdirSync(dirname(destination), { recursive: true });
82
+ writeFileSync(destination, blob.stdout, "utf8");
83
+ count += 1;
61
84
  }
62
- const destination = join(dir, relative);
63
- mkdirSync(dirname(destination), { recursive: true });
64
- writeFileSync(destination, blob.stdout, "utf8");
65
- count += 1;
85
+ return { dir, count };
86
+ }
87
+ catch (error) {
88
+ rmSync(dir, { recursive: true, force: true });
89
+ throw error;
66
90
  }
67
- return { dir, count };
91
+ }
92
+ // Ctrl-C during the scan (realistic on a slow pre-commit guard) would otherwise
93
+ // skip the try/finally and leak the materialized temp dir. Mirror the proxy
94
+ // path's signal handling: remove the dir then exit with the signal's code.
95
+ function installTempDirSignalCleanup(dir) {
96
+ const registrations = ["SIGINT", "SIGTERM"].map((signal) => {
97
+ const handler = () => {
98
+ rmSync(dir, { recursive: true, force: true });
99
+ process.exit(signal === "SIGINT" ? 130 : 143);
100
+ };
101
+ process.once(signal, handler);
102
+ return { handler, signal };
103
+ });
104
+ return () => {
105
+ for (const registration of registrations) {
106
+ process.off(registration.signal, registration.handler);
107
+ }
108
+ };
68
109
  }
69
110
  export function runStagedScan(options) {
70
111
  const env = options.env ?? process.env;
@@ -72,7 +113,7 @@ export function runStagedScan(options) {
72
113
  const theme = createTheme(resolvePresentation().color);
73
114
  // git exports GIT_INDEX_FILE to real pre-commit runs; its absence separates dg's hook self-test from a user commit that inherited the env var.
74
115
  if (options.hook && env[GUARD_SELFTEST_ENV] === "1" && env.GIT_INDEX_FILE === undefined) {
75
- return { exitCode: 2, stdout: "", stderr: "dg guard-commit self-test: synthetic block (exit 2)\n" };
116
+ return { exitCode: 2, stdout: "", stderr: "dg commit-guard self-test: synthetic block (exit 2)\n" };
76
117
  }
77
118
  const root = gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
78
119
  if (!root) {
@@ -88,14 +129,15 @@ export function runStagedScan(options) {
88
129
  return outsideRepoResult(options.targetPath ?? "");
89
130
  }
90
131
  if (scoped.length === 0) {
91
- return { exitCode: 0, stdout: "", stderr: "" };
132
+ return { exitCode: 0, stdout: "", stderr: stagedManifestNotice(cwd, env, theme) };
92
133
  }
93
134
  const dgFile = options.useDecisions === false ? null : loadDgFile(root);
94
135
  if (dgFile) {
95
136
  warnUnreadableDgFile(dgFile);
96
137
  }
97
- const usableDgFile = dgFile?.readable ? dgFile : null;
138
+ const usableDgFile = dgFile?.readable ? gateProjectDecisions(dgFile, root, env) : null;
98
139
  const { dir, count } = materializeStaged(scoped, cwd, env);
140
+ const removeSignalCleanup = installTempDirSignalCleanup(dir);
99
141
  try {
100
142
  if (count === 0) {
101
143
  return failOpen(theme, "could not read the staged lockfile contents", onIncomplete);
@@ -107,6 +149,7 @@ export function runStagedScan(options) {
107
149
  return decideStagedVerdict(report, env, options.hook, usableDgFile ? { root, file: usableDgFile } : undefined);
108
150
  }
109
151
  finally {
152
+ removeSignalCleanup();
110
153
  rmSync(dir, { recursive: true, force: true });
111
154
  }
112
155
  }
@@ -134,16 +177,26 @@ export function stagedScanReport(options) {
134
177
  warnUnreadableDgFile(dgFile);
135
178
  }
136
179
  const { dir, count } = materializeStaged(scoped, cwd, env);
180
+ const removeSignalCleanup = installTempDirSignalCleanup(dir);
137
181
  try {
138
182
  if (count === 0) {
139
183
  return { report: base, outcome: { kind: "failed", error: { kind: "worker", message: "could not read the staged lockfile contents" } } };
140
184
  }
141
- return { report: base, outcome: runScannerScan(dir, base, env, dgFile?.readable ? dgFile : null) };
185
+ return { report: base, outcome: runScannerScan(dir, base, env, dgFile?.readable ? gateProjectDecisions(dgFile, root, env) : null) };
142
186
  }
143
187
  finally {
188
+ removeSignalCleanup();
144
189
  rmSync(dir, { recursive: true, force: true });
145
190
  }
146
191
  }
192
+ function stagedManifestNotice(cwd, env, theme) {
193
+ const manifests = stagedDependencyManifestPaths(cwd, env);
194
+ if (manifests.length === 0) {
195
+ return "";
196
+ }
197
+ const sample = manifests.slice(0, 3).join(", ");
198
+ return ` ${theme.paint("unknown", "?")} ${theme.paint("muted", `DG could not verify staged dependency manifest${manifests.length === 1 ? "" : "s"} (${sample}) — no lockfile staged. Commit a lockfile so dg can resolve and scan the declared versions.`)}\n`;
199
+ }
147
200
  function safeRealpath(path) {
148
201
  try {
149
202
  return realpathSync(path);
@@ -220,7 +273,7 @@ export function stagedRememberOffer(report, decisionContext, env) {
220
273
  file: decisionContext.file,
221
274
  packages,
222
275
  acceptedBy: resolveAcceptedBy(decisionContext.root, env),
223
- surface: "guard-commit",
276
+ surface: "commit-guard",
224
277
  env,
225
278
  ...(decisionContext.prompts ? { prompts: decisionContext.prompts } : {})
226
279
  });
@@ -1 +1,7 @@
1
- export {};
1
+ // An unpinned entry has no resolvable version to verify, so it escalates a scan
2
+ // to analysis_incomplete. Other reasons are surfaced without failing the verdict:
3
+ // git/direct-url are screened by the proxy at install time, and workspace/local
4
+ // are not published artifacts.
5
+ export const ESCALATING_SKIP_REASONS = new Set([
6
+ "unpinned"
7
+ ]);
@@ -10,7 +10,7 @@ import { ErrorView } from "./components/ErrorView.js";
10
10
  import { ProjectSelector } from "./components/ProjectSelector.js";
11
11
  import { SetupBanner } from "./components/SetupBanner.js";
12
12
  import { useResizeRepaint } from "./hooks/useResizeRepaint.js";
13
- import { effectiveScanAction, scanExitCode } from "./shims.js";
13
+ import { effectiveScanAction, verdictExitCode } from "./shims.js";
14
14
  import { leaveTui, showCursor, tuiIsActive } from "./alt-screen.js";
15
15
  import { formatResetDate } from "../install-ui/block-render.js";
16
16
  import { accountHeaderLine } from "./shims.js";
@@ -29,7 +29,7 @@ export const App = ({ config, setupIssues = [], initialView, updateAvailable })
29
29
  useEffect(() => () => leaveAltScreen(), [leaveAltScreen]);
30
30
  const handleResultsExit = useCallback(() => {
31
31
  if (state.phase === "results") {
32
- process.exitCode = scanExitCode(effectiveScanAction(state.result.action, state.decisions?.effectiveAction, config.mode), config.mode);
32
+ process.exitCode = verdictExitCode(effectiveScanAction(state.result.action, state.decisions?.effectiveAction, config.mode), config.failOn ?? "block");
33
33
  }
34
34
  leaveAltScreen();
35
35
  exit();
@@ -61,7 +61,7 @@ export const App = ({ config, setupIssues = [], initialView, updateAvailable })
61
61
  // not only when the user dismisses the view — a piped/killed/non-TTY exit
62
62
  // must still carry the right code (an unverified scan is never a silent 0).
63
63
  if (state.phase === "results") {
64
- process.exitCode = scanExitCode(effectiveScanAction(state.result.action, state.decisions?.effectiveAction, config.mode), config.mode);
64
+ process.exitCode = verdictExitCode(effectiveScanAction(state.result.action, state.decisions?.effectiveAction, config.mode), config.failOn ?? "block");
65
65
  }
66
66
  }, [state, config, exitWithMessage]);
67
67
  useInput((input, key) => {
@@ -6,6 +6,7 @@ import { ExportDialog, loginRequiredToast } from "../../export-ui/ExportDialog.j
6
6
  import { resolvePresentation } from "../../presentation/mode.js";
7
7
  import { createTheme } from "../../presentation/theme.js";
8
8
  import { packagePageUrl } from "../../presentation/package-page.js";
9
+ import { csvCell as csvEscape } from "../../security/csv.js";
9
10
  import { accountHeaderLine, isLoggedIn } from "../shims.js";
10
11
  import { packageKey } from "../../decisions/apply.js";
11
12
  import { ScoreHeader, COMPACT_ROWS, LOGO_MIN_COLS } from "./ScoreHeader.js";
@@ -242,7 +243,10 @@ function buildDetailLines(group, safeVersion, maxWidth) {
242
243
  lines.push(_jsxs(Text, { children: [chalk.dim("Recommendation:"), " ", chalk.cyan(truncate(rep.recommendation, maxWidth - 18))] }, "recommendation"));
243
244
  }
244
245
  if (safeVersion) {
245
- lines.push(_jsx(Text, { children: chalk.green(`Safe version: ${rep.name}@${safeVersion}`) }, "safe"));
246
+ const sep = rep.ecosystem === "pypi" ? "==" : "@";
247
+ const note = safeVersion.crossesMajor ? "crosses major, may break" : "same major";
248
+ const text = `dg doesn't flag ${rep.name}${sep}${safeVersion.version} (${safeVersion.direction} · ${note})`;
249
+ lines.push(_jsx(Text, { children: safeVersion.crossesMajor ? chalk.yellow(text) : chalk.green(text) }, "safe"));
246
250
  }
247
251
  return lines;
248
252
  }
@@ -430,7 +434,6 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
430
434
  return {
431
435
  ...summary,
432
436
  packages: result.packages,
433
- safeVersions: result.safeVersions,
434
437
  licenses: licenseGroups.map((g) => ({
435
438
  spdx: g.spdx,
436
439
  riskCategory: g.risk,
@@ -439,10 +442,10 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
439
442
  })),
440
443
  };
441
444
  };
442
- const csvCell = (v) => {
443
- const s = v === null || v === undefined ? "" : String(v);
444
- return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
445
- };
445
+ const csvCell = (v) => csvEscape(v === null || v === undefined ? "" : String(v));
446
+ // Escape Markdown table metacharacters so an attacker-named package can't forge
447
+ // a row/column or break the heading structure of an exported report.
448
+ const mdCell = (v) => String(v ?? "").replace(/\|/gu, "\\|").replace(/[\r\n]+/gu, " ");
446
449
  const formatExport = (payload, scope, format) => {
447
450
  if (format === "json") {
448
451
  return { body: JSON.stringify(payload, null, 2) + "\n", ext: "json" };
@@ -510,13 +513,13 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
510
513
  const renderPkgRows = (rows) => {
511
514
  lines.push("## Packages", "", "| Name | Version | Score | License |", "|---|---|---|---|");
512
515
  for (const r of rows)
513
- lines.push(`| ${r.name} | ${r.version} | ${r.score} | ${r.license ?? "—"} |`);
516
+ lines.push(`| ${mdCell(r.name)} | ${mdCell(r.version)} | ${r.score} | ${mdCell(r.license ?? "—")} |`);
514
517
  lines.push("");
515
518
  };
516
519
  const renderLicRows = (rows) => {
517
520
  lines.push("## Licenses", "", "| License | Risk | Count |", "|---|---|---|");
518
521
  for (const r of rows)
519
- lines.push(`| ${r.spdx} | ${r.riskCategory} | ${r.count} |`);
522
+ lines.push(`| ${mdCell(r.spdx)} | ${mdCell(r.riskCategory)} | ${r.count} |`);
520
523
  lines.push("");
521
524
  };
522
525
  if (scope === "summary") {
@@ -541,10 +544,10 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
541
544
  }
542
545
  else if (scope === "current-license") {
543
546
  const r = payload;
544
- lines.push(`## ${r.spdx} · ${r.riskCategory} · ${r.count} packages`, "");
547
+ lines.push(`## ${mdCell(r.spdx)} · ${mdCell(r.riskCategory)} · ${r.count} packages`, "");
545
548
  lines.push("| Name | Version | Score |", "|---|---|---|");
546
549
  for (const p of r.packages)
547
- lines.push(`| ${p.name} | ${p.version} | ${p.score} |`);
550
+ lines.push(`| ${mdCell(p.name)} | ${mdCell(p.version)} | ${p.score} |`);
548
551
  }
549
552
  else if (scope === "findings") {
550
553
  const rows = payload;
@@ -553,9 +556,9 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
553
556
  }
554
557
  else {
555
558
  for (const r of rows) {
556
- lines.push(`### ${r.name}@${r.version} · ${r.action.toUpperCase()} · score ${r.score}`);
559
+ lines.push(`### ${mdCell(r.name)}@${mdCell(r.version)} · ${mdCell(r.action.toUpperCase())} · score ${r.score}`);
557
560
  for (const f of r.findings)
558
- lines.push(`- sev ${f.severity}: ${f.title ?? "(hidden)"}`);
561
+ lines.push(`- sev ${f.severity}: ${mdCell(f.title ?? "(hidden)")}`);
559
562
  lines.push("");
560
563
  }
561
564
  }
@@ -693,8 +696,8 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
693
696
  const detailLines = useMemo(() => {
694
697
  if (!detailGroup)
695
698
  return [];
696
- return buildDetailLines(detailGroup, result.safeVersions[firstPackage(detailGroup).name], innerWidth);
697
- }, [detailGroup, result.safeVersions, innerWidth]);
699
+ return buildDetailLines(detailGroup, firstPackage(detailGroup).safeVersion, innerWidth);
700
+ }, [detailGroup, innerWidth]);
698
701
  const detailContentRows = Math.max(3, termRows - DETAIL_PANE_CHROME);
699
702
  const getLevel = (idx) => {
700
703
  return view.expandedKey !== null && groups[idx]?.key === view.expandedKey ? view.expandLevel : null;
@@ -3,7 +3,11 @@ import { useReducer, useEffect, useRef, useCallback, useState } from "react";
3
3
  import { analyzePackages, AnalyzeError, mergeAnalyzeResponses } from "../../api/analyze.js";
4
4
  import { applyDecisions, packageKey } from "../../decisions/apply.js";
5
5
  import { findProjectRoot, loadDgFile } from "../../project/dgfile.js";
6
- import { collectScanPackages, discoverScanProjectsAsync } from "../../scan/collect.js";
6
+ import { gateProjectDecisions } from "../../project/override-trust.js";
7
+ import { collectScanPackages, discoverScanProjectsAsync, mergeInstalledPackages } from "../../scan/collect.js";
8
+ import { summarizeUnscanned } from "../../scan/scanner-report.js";
9
+ import { detectUnlockedManifests, unlockedManifestNotice } from "../../scan/manifest-coverage.js";
10
+ import { discoverInstalledPackages } from "../../scan/installed.js";
7
11
  function reducer(_state, action) {
8
12
  switch (action.type) {
9
13
  case "PROJECTS_FOUND":
@@ -46,6 +50,7 @@ export function useScan(config) {
46
50
  abortRef.current = new AbortController();
47
51
  }
48
52
  const [multiProjects, setMultiProjects] = useState(null);
53
+ const installedRef = useRef(null);
49
54
  useEffect(() => () => abortRef.current?.abort(), []);
50
55
  useEffect(() => {
51
56
  if (started.current) {
@@ -53,12 +58,20 @@ export function useScan(config) {
53
58
  }
54
59
  started.current = true;
55
60
  void (async () => {
56
- const discovered = await discoverScanProjectsAsync(process.cwd(), (progress) => {
61
+ const root = process.cwd();
62
+ const discovered = await discoverScanProjectsAsync(root, (progress) => {
57
63
  dispatch({ type: "DISCOVERY_PROGRESS", path: progress.path, found: progress.found });
58
64
  });
59
65
  const projects = discovered.filter((project) => project.ecosystem === "npm" || project.ecosystem === "pypi");
60
- if (projects.length === 0) {
61
- dispatch({ type: "DISCOVERY_EMPTY", message: "No dependency lockfiles found." });
66
+ const installed = discoverInstalledPackages(root, process.env);
67
+ installedRef.current = installed;
68
+ const installedTotal = [...installed.byEcosystem.values()].reduce((sum, list) => sum + list.length, 0);
69
+ if (projects.length === 0 && installedTotal === 0) {
70
+ const manifestNotice = unlockedManifestNotice(detectUnlockedManifests(root));
71
+ dispatch({
72
+ type: "DISCOVERY_EMPTY",
73
+ message: manifestNotice ?? "No dependencies found — no lockfiles, manifests, or installed packages here."
74
+ });
62
75
  return;
63
76
  }
64
77
  if (projects.length > 1) {
@@ -66,11 +79,11 @@ export function useScan(config) {
66
79
  dispatch({ type: "PROJECTS_FOUND", projects });
67
80
  return;
68
81
  }
69
- await scanProjects(projects, dispatch, abortRef.current?.signal);
82
+ await scanProjects(projects, installed, dispatch, abortRef.current?.signal);
70
83
  })();
71
84
  }, [config]);
72
85
  const scanSelectedProjects = useCallback((projects) => {
73
- void scanProjects(projects, dispatch, abortRef.current?.signal);
86
+ void scanProjects(projects, installedRef.current, dispatch, abortRef.current?.signal);
74
87
  }, []);
75
88
  const restartSelection = useCallback(() => {
76
89
  if (multiProjects) {
@@ -89,10 +102,13 @@ function computeProjectDecisions(result, entries) {
89
102
  if (!root) {
90
103
  return undefined;
91
104
  }
92
- const file = loadDgFile(root);
93
- if (!file.readable) {
105
+ const loaded = loadDgFile(root);
106
+ if (!loaded.readable) {
94
107
  return undefined;
95
108
  }
109
+ // Gate decisions through the trust check so a hostile repo's dg.json can't
110
+ // suppress a warn in the scan TUI that it did not author on this machine.
111
+ const file = gateProjectDecisions(loaded, root, process.env);
96
112
  const ecosystems = new Map();
97
113
  for (const [ecosystem, packages] of entries) {
98
114
  for (const pkg of packages) {
@@ -105,17 +121,24 @@ function computeProjectDecisions(result, entries) {
105
121
  return undefined;
106
122
  }
107
123
  }
108
- async function scanProjects(projects, dispatch, signal) {
124
+ function emptyScanMessage(skipped) {
125
+ if (skipped > 0) {
126
+ return `No packages to scan — found ${skipped} declared ${skipped === 1 ? "entry" : "entries"} with no pinned version. Pin them (e.g. requests==2.31.0) or run \`pip freeze > requirements.txt\`.`;
127
+ }
128
+ return "No packages to scan.";
129
+ }
130
+ async function scanProjects(projects, installed, dispatch, signal) {
109
131
  const startMs = Date.now();
110
132
  let skipped = 0;
111
133
  let total = 0;
112
134
  try {
113
- const collected = collectScanPackages(projects);
135
+ const lockfilePackages = collectScanPackages(projects);
136
+ const collected = installed ? mergeInstalledPackages(lockfilePackages, installed) : lockfilePackages;
114
137
  skipped = collected.skipped;
115
138
  const entries = [...collected.byEcosystem.entries()];
116
139
  total = entries.reduce((sum, [, list]) => sum + list.length, 0);
117
140
  if (total === 0) {
118
- dispatch({ type: "DISCOVERY_EMPTY", message: "No packages to scan." });
141
+ dispatch({ type: "DISCOVERY_EMPTY", message: emptyScanMessage(skipped) });
119
142
  return;
120
143
  }
121
144
  dispatch({ type: "DISCOVERY_COMPLETE", total });
@@ -170,8 +193,11 @@ async function scanProjects(projects, dispatch, signal) {
170
193
  ecoByKey.set(packageKey(pkg.name, pkg.version), ecosystem);
171
194
  }
172
195
  }
196
+ const unscanned = summarizeUnscanned(collected.skippedPackages);
197
+ const escalated = unscanned !== undefined && unscanned.escalating > 0 && base.action !== "block";
173
198
  const result = {
174
199
  ...base,
200
+ ...(escalated ? { action: "analysis_incomplete" } : {}),
175
201
  packages: base.packages.map((pkg) => {
176
202
  const ecosystem = ecoByKey.get(packageKey(pkg.name, pkg.version));
177
203
  return ecosystem ? { ...pkg, ecosystem } : pkg;
@@ -17,7 +17,7 @@ export function shouldLaunchScanTui(options) {
17
17
  }
18
18
  return resolvePresentation().mode === "rich";
19
19
  }
20
- export async function launchScanTui(initialView = "results") {
20
+ export async function launchScanTui(initialView = "results", failOn = "block") {
21
21
  const ci = process.env.CI;
22
22
  const ciCleared = ci === "" || ci === "0" || ci === "false";
23
23
  if (ciCleared) {
@@ -29,7 +29,7 @@ export async function launchScanTui(initialView = "results") {
29
29
  import("./LegacyApp.js")
30
30
  ]);
31
31
  const mode = loadUserConfig().policy.mode;
32
- const config = { mode };
32
+ const config = { mode, failOn: mode === "strict" ? "warn" : failOn };
33
33
  const update = pendingUpdate();
34
34
  const updateAvailable = update
35
35
  ? `Update available: ${update.current} → ${update.latest} · run dg update`
@@ -33,7 +33,9 @@ function fillCircle(grid, cx, cy, r, type) {
33
33
  const LOGO_DATA = (() => {
34
34
  const W = 22, H = 28;
35
35
  const grid = Array.from({ length: H }, () => Array(W).fill(0));
36
- const cx = 11, cy = 14, R = 10;
36
+ // R<10: the even-width grid has no integer center, so R=10 lands the 0° node
37
+ // on the last column and clips its right half, skewing the logo left.
38
+ const cx = 11, cy = 14, R = 9;
37
39
  const angles = [270, 315, 0, 45, 90, 135, 180, 225];
38
40
  const nodes = angles.map(a => {
39
41
  const rad = a * Math.PI / 180;
@@ -70,7 +72,9 @@ export function renderLogo(action) {
70
72
  const colors = {
71
73
  1: chalk.dim,
72
74
  2: chalk.white,
73
- 3: (s) => chalk.bold.white(s),
75
+ // Brightness, not bold: terminals faux-bold braille by double-striking the
76
+ // glyph shifted right, which smears full cells (⣿) into the next column.
77
+ 3: chalk.whiteBright,
74
78
  4: action === "block" ? chalk.red
75
79
  : action === "warn" ? chalk.yellow
76
80
  : action === "analysis_incomplete" ? chalk.cyan