@westbayberry/dg 2.0.11 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +113 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +113 -0
  6. package/dist/agents/gemini.js +107 -0
  7. package/dist/agents/persistence.js +285 -0
  8. package/dist/agents/registry.js +127 -0
  9. package/dist/agents/types.js +1 -0
  10. package/dist/agents/windsurf.js +93 -0
  11. package/dist/api/analyze.js +6 -4
  12. package/dist/audit/detectors.js +0 -11
  13. package/dist/audit/events.js +5 -21
  14. package/dist/audit-ui/AuditApp.js +2 -0
  15. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  16. package/dist/audit-ui/export.js +0 -4
  17. package/dist/audit-ui/format.js +0 -3
  18. package/dist/audit-ui/launch.js +10 -1
  19. package/dist/auth/device-login.js +4 -5
  20. package/dist/auth/login-app.js +7 -7
  21. package/dist/auth/store.js +8 -3
  22. package/dist/bin/dg.js +59 -52
  23. package/dist/commands/agents.js +231 -0
  24. package/dist/commands/audit.js +22 -3
  25. package/dist/commands/config.js +26 -10
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +169 -0
  28. package/dist/commands/doctor.js +1 -1
  29. package/dist/commands/help.js +1 -1
  30. package/dist/commands/licenses.js +10 -22
  31. package/dist/commands/logout.js +4 -11
  32. package/dist/commands/router.js +8 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +3 -2
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/status.js +5 -2
  38. package/dist/commands/types.js +1 -0
  39. package/dist/commands/update.js +17 -8
  40. package/dist/commands/verify.js +8 -5
  41. package/dist/config/settings.js +154 -65
  42. package/dist/decisions/apply.js +128 -0
  43. package/dist/decisions/remember-prompt.js +92 -0
  44. package/dist/export-ui/ExportDialog.js +198 -0
  45. package/dist/install-ui/LiveInstall.js +2 -2
  46. package/dist/install-ui/block-render.js +21 -4
  47. package/dist/install-ui/prep-spinner.js +32 -0
  48. package/dist/install-ui/prompt.js +14 -0
  49. package/dist/launcher/agent-check.js +466 -0
  50. package/dist/launcher/agent-hook-exec.js +70 -0
  51. package/dist/launcher/agent-hook-io.js +31 -0
  52. package/dist/launcher/cargo-cache.js +40 -0
  53. package/dist/launcher/classify.js +17 -6
  54. package/dist/launcher/env.js +71 -24
  55. package/dist/launcher/install-preflight.js +167 -17
  56. package/dist/launcher/live-install.js +25 -5
  57. package/dist/launcher/output-redaction.js +7 -4
  58. package/dist/launcher/preflight-prompt.js +43 -3
  59. package/dist/launcher/run.js +100 -86
  60. package/dist/launcher/spawn-invocation.js +21 -0
  61. package/dist/policy/cooldown.js +117 -0
  62. package/dist/policy/evaluate.js +5 -21
  63. package/dist/policy/pypi-name.js +17 -0
  64. package/dist/presentation/mode.js +3 -2
  65. package/dist/presentation/package-page.js +9 -0
  66. package/dist/presentation/provenance.js +23 -0
  67. package/dist/presentation/theme.js +7 -7
  68. package/dist/project/dgfile.js +446 -0
  69. package/dist/proxy/auth.js +42 -0
  70. package/dist/proxy/ca.js +29 -9
  71. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  72. package/dist/proxy/enforcement.js +57 -17
  73. package/dist/proxy/metadata-map.js +66 -4
  74. package/dist/proxy/preverified.js +55 -0
  75. package/dist/proxy/server.js +473 -45
  76. package/dist/proxy/worker.js +16 -1
  77. package/dist/publish-set/collect.js +1 -4
  78. package/dist/publish-set/npm.js +8 -5
  79. package/dist/publish-set/pack.js +9 -3
  80. package/dist/runtime/cli.js +0 -4
  81. package/dist/runtime/fatal.js +31 -0
  82. package/dist/runtime/first-run.js +12 -11
  83. package/dist/runtime/node-version.js +43 -6
  84. package/dist/runtime/nudges.js +35 -2
  85. package/dist/sbom/cyclonedx.js +211 -0
  86. package/dist/sbom-ui/SbomApp.js +158 -0
  87. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  88. package/dist/sbom-ui/components/SbomList.js +52 -0
  89. package/dist/sbom-ui/inventory.js +128 -0
  90. package/dist/sbom-ui/launch.js +51 -0
  91. package/dist/sbom-ui/run.js +55 -0
  92. package/dist/sbom-ui/store.js +26 -0
  93. package/dist/scan/collect.js +10 -6
  94. package/dist/scan/command.js +51 -17
  95. package/dist/scan/discovery.js +11 -2
  96. package/dist/scan/render.js +63 -8
  97. package/dist/scan/scanner-report.js +42 -9
  98. package/dist/scan/staged.js +71 -11
  99. package/dist/scan-ui/LegacyApp.js +12 -16
  100. package/dist/scan-ui/alt-screen.js +5 -8
  101. package/dist/scan-ui/components/InteractiveResultsView.js +193 -123
  102. package/dist/scan-ui/components/ProgressBar.js +3 -14
  103. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  104. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  105. package/dist/scan-ui/components/SetupBanner.js +0 -6
  106. package/dist/scan-ui/format-helpers.js +61 -5
  107. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  108. package/dist/scan-ui/hooks/useScan.js +46 -4
  109. package/dist/scan-ui/launch.js +7 -4
  110. package/dist/scan-ui/shims.js +14 -4
  111. package/dist/scripts/detect.js +158 -0
  112. package/dist/scripts/gate.js +170 -0
  113. package/dist/service/state.js +27 -8
  114. package/dist/service/trust-refresh.js +92 -0
  115. package/dist/service/worker.js +23 -1
  116. package/dist/setup/activate-shell.js +28 -0
  117. package/dist/setup/git-hook.js +49 -4
  118. package/dist/setup/plan.js +98 -29
  119. package/dist/setup-ui/gate.js +39 -0
  120. package/dist/setup-ui/offer.js +42 -0
  121. package/dist/setup-ui/selector.js +27 -0
  122. package/dist/setup-ui/tasks.js +56 -0
  123. package/dist/setup-ui/wizard.js +225 -0
  124. package/dist/state/cooldown-held.js +66 -0
  125. package/dist/state/index.js +1 -0
  126. package/dist/state/locks.js +4 -2
  127. package/dist/state/store.js +2 -1
  128. package/dist/util/external-tool.js +25 -0
  129. package/dist/util/git.js +10 -3
  130. package/dist/util/json-file.js +24 -0
  131. package/dist/util/report-writer.js +57 -0
  132. package/dist/util/tty-prompt.js +13 -6
  133. package/dist/verify/local.js +240 -42
  134. package/dist/verify/package-check.js +86 -18
  135. package/dist/verify/preflight.js +242 -49
  136. package/dist/verify/render.js +15 -1
  137. package/npm-shrinkwrap.json +2383 -0
  138. package/package.json +14 -8
  139. package/NOTICE +0 -5
  140. package/dist/commands/completion.js +0 -116
  141. package/dist/commands/explain.js +0 -232
  142. package/dist/commands/unavailable.js +0 -11
  143. package/dist/telemetry/events.js +0 -40
@@ -1,5 +1,7 @@
1
- import { writeFileSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { statSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { writeReportAtomic } from "../util/report-writer.js";
4
+ import { findProjectRoot, loadDgFile, warnUnreadableDgFile } from "../project/dgfile.js";
3
5
  import { scanProject } from "./discovery.js";
4
6
  import { renderJsonReport, renderSarifReport, renderTextReport } from "./render.js";
5
7
  import { resolvePresentation } from "../presentation/mode.js";
@@ -9,7 +11,7 @@ import { runScannerScan } from "./scanner-report.js";
9
11
  import { runStagedScan, stagedScanReport } from "./staged.js";
10
12
  import { scanExitCode } from "../scan-ui/shims.js";
11
13
  import { loadUserConfig } from "../config/settings.js";
12
- import { EXIT_USAGE_VERDICT } from "../commands/types.js";
14
+ import { EXIT_ANALYSIS_INCOMPLETE, EXIT_NOTHING_TO_SCAN, EXIT_USAGE_VERDICT } from "../commands/types.js";
13
15
  export function runScanCommand(context) {
14
16
  const parsed = parseScanArgs(context.args);
15
17
  if ("error" in parsed) {
@@ -18,12 +20,12 @@ export function runScanCommand(context) {
18
20
  const stagedTarget = parsed.sawTarget ? parsed.targetPath : null;
19
21
  const machineOutput = parsed.format !== "text" || parsed.outputPath !== null;
20
22
  if (parsed.staged && !machineOutput) {
21
- return runStagedScan({ hook: parsed.hook, targetPath: stagedTarget });
23
+ return runStagedScan({ hook: parsed.hook, targetPath: stagedTarget, useDecisions: !parsed.noDecisions });
22
24
  }
23
25
  let report;
24
26
  let outcome;
25
27
  if (parsed.staged) {
26
- const staged = stagedScanReport({ targetPath: stagedTarget });
28
+ const staged = stagedScanReport({ targetPath: stagedTarget, useDecisions: !parsed.noDecisions });
27
29
  if ("result" in staged) {
28
30
  return staged.result;
29
31
  }
@@ -53,12 +55,12 @@ export function runScanCommand(context) {
53
55
  }
54
56
  catch (error) {
55
57
  return {
56
- exitCode: 1,
58
+ exitCode: EXIT_ANALYSIS_INCOMPLETE,
57
59
  stdout: "",
58
60
  stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
59
61
  };
60
62
  }
61
- outcome = runScannerScan(parsed.targetPath, report);
63
+ outcome = runScannerScan(parsed.targetPath, report, process.env, parsed.noDecisions ? null : loadScanDecisions(parsed.targetPath));
62
64
  }
63
65
  if (outcome.kind === "report") {
64
66
  report = outcome.report;
@@ -68,26 +70,32 @@ export function runScanCommand(context) {
68
70
  }
69
71
  const skipNotice = skipNoticeFor(outcome, report);
70
72
  const scannerUnavailable = !report.scanner && report.summary.projectCount > 0;
71
- const rendered = renderReport(report, parsed.format, scannerUnavailable, skipNotice);
73
+ const nothingToScan = !parsed.staged &&
74
+ !report.scanner &&
75
+ !report.scannerError &&
76
+ report.summary.projectCount === 0 &&
77
+ report.summary.errorCount === 0;
78
+ const exitCode = nothingToScan ? EXIT_NOTHING_TO_SCAN : exitCodeForReport(report);
79
+ const rendered = renderReport(report, parsed.format, scannerUnavailable, skipNotice, nothingToScan);
72
80
  if (parsed.outputPath) {
73
81
  try {
74
- writeFileSync(resolve(parsed.outputPath), rendered, "utf8");
82
+ writeReportAtomic(resolve(parsed.outputPath), rendered);
75
83
  }
76
84
  catch (error) {
77
85
  return {
78
- exitCode: 1,
86
+ exitCode: EXIT_ANALYSIS_INCOMPLETE,
79
87
  stdout: "",
80
88
  stderr: `dg scan could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n`
81
89
  };
82
90
  }
83
91
  return {
84
- exitCode: exitCodeForReport(report),
92
+ exitCode,
85
93
  stdout: `Wrote ${parsed.format} scan report to ${parsed.outputPath}\n`,
86
94
  stderr: ""
87
95
  };
88
96
  }
89
97
  return {
90
- exitCode: exitCodeForReport(report),
98
+ exitCode,
91
99
  stdout: rendered,
92
100
  stderr: ""
93
101
  };
@@ -99,6 +107,7 @@ function parseScanArgs(args) {
99
107
  let sawTarget = false;
100
108
  let staged = false;
101
109
  let hook = false;
110
+ let noDecisions = false;
102
111
  for (let index = 0; index < args.length; index += 1) {
103
112
  const arg = args[index];
104
113
  if (!arg) {
@@ -112,6 +121,10 @@ function parseScanArgs(args) {
112
121
  hook = true;
113
122
  continue;
114
123
  }
124
+ if (arg === "--no-decisions") {
125
+ noDecisions = true;
126
+ continue;
127
+ }
115
128
  if (arg === "--json") {
116
129
  if (format !== "text") {
117
130
  return { error: "choose only one output format" };
@@ -150,16 +163,35 @@ function parseScanArgs(args) {
150
163
  targetPath,
151
164
  sawTarget,
152
165
  staged,
153
- hook
166
+ hook,
167
+ noDecisions
154
168
  };
155
169
  }
156
170
  function usageError(message) {
157
171
  return {
158
172
  exitCode: EXIT_USAGE_VERDICT,
159
173
  stdout: "",
160
- stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>]\n`
174
+ stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>] [--no-decisions]\n`
161
175
  };
162
176
  }
177
+ function loadScanDecisions(targetPath, env = process.env) {
178
+ let dir = resolve(targetPath);
179
+ try {
180
+ if (!statSync(dir).isDirectory()) {
181
+ dir = dirname(dir);
182
+ }
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ const root = findProjectRoot(dir, env);
188
+ if (!root) {
189
+ return null;
190
+ }
191
+ const file = loadDgFile(root);
192
+ warnUnreadableDgFile(file);
193
+ return file.readable ? file : null;
194
+ }
163
195
  function degradeReport(report, error) {
164
196
  const status = report.status === "block" || report.status === "warn" ? report.status : "unknown";
165
197
  return { ...report, status, scannerError: error };
@@ -173,9 +205,9 @@ function skipNoticeFor(outcome, report) {
173
205
  }
174
206
  return "empty_lockfile";
175
207
  }
176
- function renderReport(report, format, scannerUnavailable, skipNotice) {
208
+ function renderReport(report, format, scannerUnavailable, skipNotice, nothingToScan) {
177
209
  if (format === "json") {
178
- return renderJsonReport(report, scannerUnavailable);
210
+ return renderJsonReport(report, scannerUnavailable, nothingToScan);
179
211
  }
180
212
  if (format === "sarif") {
181
213
  return renderSarifReport(report);
@@ -184,7 +216,9 @@ function renderReport(report, format, scannerUnavailable, skipNotice) {
184
216
  }
185
217
  function exitCodeForReport(report) {
186
218
  if (report.scanner) {
187
- return scanExitCode(report.scanner.action, loadUserConfig().policy.mode);
219
+ const mode = loadUserConfig().policy.mode;
220
+ const action = mode === "strict" ? report.scanner.action : report.decisions?.effectiveAction ?? report.scanner.action;
221
+ return scanExitCode(action, mode);
188
222
  }
189
223
  if (report.status === "block") {
190
224
  return 2;
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { basename, dirname, relative, resolve, sep } from "node:path";
4
+ import { resolveToolPath } from "../util/external-tool.js";
4
5
  const IGNORED_DIRECTORIES = new Set([
5
6
  ".git",
6
7
  ".hg",
@@ -113,8 +114,12 @@ export function gitIgnoredDirectories(root) {
113
114
  return ignored;
114
115
  }
115
116
  function insideGitWorkTree(root) {
117
+ const git = resolveToolPath("git");
118
+ if (!git) {
119
+ return false;
120
+ }
116
121
  try {
117
- const out = execFileSync("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], {
122
+ const out = execFileSync(git, ["-C", root, "rev-parse", "--is-inside-work-tree"], {
118
123
  encoding: "utf8",
119
124
  timeout: 3000,
120
125
  stdio: ["ignore", "pipe", "ignore"]
@@ -126,8 +131,12 @@ function insideGitWorkTree(root) {
126
131
  }
127
132
  }
128
133
  function checkIgnoreBatch(root, directories) {
134
+ const git = resolveToolPath("git");
135
+ if (!git) {
136
+ return new Set();
137
+ }
129
138
  try {
130
- const out = execFileSync("git", ["-C", root, "check-ignore", "--stdin", "-z"], {
139
+ const out = execFileSync(git, ["-C", root, "check-ignore", "--stdin", "-z"], {
131
140
  input: directories.join("\0"),
132
141
  encoding: "utf8",
133
142
  timeout: 5000,
@@ -1,4 +1,6 @@
1
1
  import { createTheme } from "../presentation/theme.js";
2
+ import { packagePageUrl } from "../presentation/package-page.js";
3
+ import { sanitizeDeep } from "../security/sanitize.js";
2
4
  const DETAIL_PROJECT_LIMIT = 8;
3
5
  const CLEAN_PROJECT_PREVIEW_LIMIT = 5;
4
6
  const FINDING_GROUP_PREVIEW_LIMIT = 3;
@@ -16,22 +18,29 @@ const SCANNER_SKIP_MESSAGES = {
16
18
  no_lockfile: "no lockfile found — server verification skipped (local heuristics only)",
17
19
  empty_lockfile: "lockfile contains no scannable packages — server verification skipped (local heuristics only)"
18
20
  };
19
- export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(), theme = createTheme(false), scannerNotice) {
21
+ export function renderTextReport(rawReport, terminalWidth = terminalWidthFromEnv(), theme = createTheme(false), scannerNotice) {
22
+ const report = sanitizeDeep(rawReport);
20
23
  const width = Math.max(48, Math.min(terminalWidth, 140));
21
24
  const cleanProjects = report.projects.filter((project) => project.findings.length === 0);
22
25
  const findingProjects = report.projects.filter((project) => project.findings.length > 0);
23
26
  const shouldCollapseProjects = report.projects.length > DETAIL_PROJECT_LIMIT;
27
+ const acknowledgedCount = report.decisions?.acknowledgedCount ?? 0;
28
+ const activeFindings = report.findings.filter((finding) => !finding.acknowledged);
24
29
  const lines = [
25
30
  "Dependency Guardian scan",
26
31
  `Target: ${report.target}`,
27
32
  `Scanning: checked ${report.summary.projectCount} project manifest${report.summary.projectCount === 1 ? "" : "s"}.`,
28
- `Status: ${theme.paint(SCAN_STATUS_ROLE[report.status], displayScanStatus(report))}`,
33
+ `Status: ${paintEffectiveStatus(report, theme)}`,
29
34
  `Projects: ${report.summary.projectCount}`,
30
35
  `Dependencies: ${report.summary.dependencyCount}`,
31
- `Findings: ${report.summary.findingCount} (${report.summary.warnCount} warn, ${report.summary.blockCount} block)`,
36
+ `Findings: ${report.summary.findingCount} (${report.summary.warnCount} warn, ${report.summary.blockCount} block)${acknowledgedCount > 0 ? ` · ${acknowledgedCount} acknowledged` : ""}`,
37
+ ...(acknowledgedCount > 0
38
+ ? [`Acknowledged: ${acknowledgedCount} warn verdict${acknowledgedCount === 1 ? "" : "s"} accepted in dg.json — run 'dg decisions' to review`]
39
+ : []),
32
40
  ...(report.scanner
33
41
  ? [`Scanner: score ${report.scanner.score}, ${report.scanner.packages.length} packages verified`]
34
42
  : []),
43
+ ...(report.scanner ? provenanceDowngradeLines(report.scanner.packages, theme) : []),
35
44
  ""
36
45
  ];
37
46
  if (report.scannerError) {
@@ -52,7 +61,7 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
52
61
  }
53
62
  if (findingProjects.length > 0) {
54
63
  lines.push("Finding groups:");
55
- for (const group of groupFindings(report.findings)) {
64
+ for (const group of groupFindings(activeFindings)) {
56
65
  const severityLabel = theme.paint(group.severity === "block" ? "block" : "warn", group.severity.toUpperCase());
57
66
  lines.push(` ${severityLabel} ${group.id}: ${group.count} finding${group.count === 1 ? "" : "s"} across ${group.projectCount} project${group.projectCount === 1 ? "" : "s"}`);
58
67
  for (const finding of group.examples) {
@@ -89,11 +98,46 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
89
98
  lines.push("");
90
99
  }
91
100
  }
101
+ lines.push(...packagePageLines(report, theme));
92
102
  for (const error of report.errors) {
93
103
  lines.push(...wrapLine(`ERROR ${error.location}: `, error.message, width));
94
104
  }
95
105
  return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
96
106
  }
107
+ function packagePageLines(report, theme) {
108
+ const urls = [];
109
+ const seen = new Set();
110
+ for (const pkg of report.scanner?.packages ?? []) {
111
+ const action = pkg.action ?? "pass";
112
+ if (action !== "block" && action !== "warn") {
113
+ continue;
114
+ }
115
+ const url = packagePageUrl(pkg.ecosystem ?? "", pkg.name);
116
+ if (url && !seen.has(url)) {
117
+ seen.add(url);
118
+ urls.push(url);
119
+ }
120
+ }
121
+ if (urls.length === 0) {
122
+ return [];
123
+ }
124
+ return ["Full report for each flagged package:", ...urls.map((url) => ` ${theme.paint("muted", url)}`), ""];
125
+ }
126
+ function provenanceDowngradeLines(packages, theme) {
127
+ return packages
128
+ .filter((pkg) => pkg.provenance?.downgrade)
129
+ .map((pkg) => theme.paint("warn", `Provenance downgrades: ${pkg.name}@${pkg.version} (was attested at ${pkg.provenance.downgrade.fromVersion})`));
130
+ }
131
+ function paintEffectiveStatus(report, theme) {
132
+ if (report.scanner && report.decisions && !report.scannerError) {
133
+ const action = report.decisions.effectiveAction;
134
+ if (action === "analysis_incomplete") {
135
+ return theme.paint("unknown", "analysis_incomplete");
136
+ }
137
+ return theme.paint(SCAN_STATUS_ROLE[action], action);
138
+ }
139
+ return theme.paint(SCAN_STATUS_ROLE[report.status], displayScanStatus(report));
140
+ }
97
141
  function formatProject(project, width) {
98
142
  const lines = [];
99
143
  const version = project.version ? `@${project.version}` : "";
@@ -111,8 +155,8 @@ function formatProject(project, width) {
111
155
  }
112
156
  return lines;
113
157
  }
114
- export function renderJsonReport(report, scannerUnavailable = false) {
115
- return `${JSON.stringify({ ...report, status: displayScanStatus(report), scannerUnavailable }, null, 2)}\n`;
158
+ export function renderJsonReport(report, scannerUnavailable = false, nothingToScan = false) {
159
+ return `${JSON.stringify({ schemaVersion: 1, ...report, status: nothingToScan ? "nothing_to_scan" : displayScanStatus(report), scannerUnavailable }, null, 2)}\n`;
116
160
  }
117
161
  export function renderSarifReport(report) {
118
162
  const rules = uniqueFindings(report.findings).map((finding) => ({
@@ -154,7 +198,17 @@ export function renderSarifReport(report) {
154
198
  }
155
199
  }
156
200
  }
157
- ]
201
+ ],
202
+ ...(finding.acknowledged
203
+ ? {
204
+ suppressions: [
205
+ {
206
+ kind: "external",
207
+ justification: finding.acknowledged.reason || `accepted by ${finding.acknowledged.by}`
208
+ }
209
+ ]
210
+ }
211
+ : {})
158
212
  }))
159
213
  }
160
214
  ]
@@ -250,7 +304,8 @@ function wrapLine(prefix, text, width) {
250
304
  return lines.map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
251
305
  }
252
306
  function terminalWidthFromEnv() {
253
- const value = process.env.DG_TEST_TERMINAL_WIDTH ?? String(process.stdout.columns ?? 100);
307
+ const override = process.env.NODE_ENV === "test" ? process.env.DG_TEST_TERMINAL_WIDTH : undefined;
308
+ const value = override ?? String(process.stdout.columns ?? 100);
254
309
  const parsed = Number.parseInt(value, 10);
255
310
  return Number.isFinite(parsed) ? parsed : 100;
256
311
  }
@@ -3,6 +3,7 @@ import { 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
+ import { applyDecisions, packageKey } from "../decisions/apply.js";
6
7
  import { sanitize } from "../security/sanitize.js";
7
8
  import { collectScanPackages, discoverScanProjects } from "./collect.js";
8
9
  const WORKER_TIMEOUT_BASE_MS = 180_000;
@@ -12,7 +13,7 @@ const WORKER_MAX_BUFFER = 64 * 1024 * 1024;
12
13
  export function scanWorkerTimeoutMs(packageCount) {
13
14
  return WORKER_TIMEOUT_BASE_MS + packageCount * Math.ceil(SERVER_PER_PACKAGE_WORST_CASE_MS / SERVER_SCAN_CONCURRENCY);
14
15
  }
15
- export function runScannerScan(targetPath, localReport, env = process.env) {
16
+ export function runScannerScan(targetPath, localReport, env = process.env, decisionsFile = null) {
16
17
  const projects = discoverScanProjects(resolve(targetPath));
17
18
  if (projects.length === 0) {
18
19
  return { kind: "skipped", reason: "no_lockfiles" };
@@ -58,12 +59,38 @@ export function runScannerScan(targetPath, localReport, env = process.env) {
58
59
  error: { kind: "invalid_response", message: "scanner worker returned unreadable output" }
59
60
  };
60
61
  }
61
- return { kind: "report", report: buildScannerReport(localReport, response, total) };
62
+ const ecosystems = new Map();
63
+ for (const group of groups) {
64
+ for (const pkg of group.packages) {
65
+ ecosystems.set(packageKey(pkg.name, pkg.version), group.ecosystem);
66
+ }
67
+ }
68
+ const report = buildScannerReport(localReport, response, total, ecosystems);
69
+ if (!decisionsFile?.readable) {
70
+ return { kind: "report", report };
71
+ }
72
+ return { kind: "report", report: annotateReportWithDecisions(report, decisionsFile, ecosystems) };
62
73
  }
63
- export function tryScannerScan(targetPath, localReport, env = process.env) {
64
- const outcome = runScannerScan(targetPath, localReport, env);
74
+ export function tryScannerScan(targetPath, localReport, env = process.env, decisionsFile = null) {
75
+ const outcome = runScannerScan(targetPath, localReport, env, decisionsFile);
65
76
  return outcome.kind === "report" ? outcome.report : null;
66
77
  }
78
+ export function annotateReportWithDecisions(report, decisionsFile, ecosystems) {
79
+ if (!report.scanner || !decisionsFile.readable) {
80
+ return report;
81
+ }
82
+ const applied = applyDecisions(report.scanner.packages, (pkg) => ecosystems.get(packageKey(pkg.name, pkg.version)), decisionsFile, report.scanner.action);
83
+ const findings = report.findings.map((finding) => {
84
+ const acknowledged = applied.packages[finding.location]?.acknowledged;
85
+ return acknowledged && finding.severity === "warn" ? { ...finding, acknowledged } : finding;
86
+ });
87
+ return {
88
+ ...report,
89
+ findings,
90
+ summary: { ...report.summary, acknowledgedCount: applied.acknowledgedCount },
91
+ decisions: applied
92
+ };
93
+ }
67
94
  export function workerFailure(worker, timeoutMs, packageCount) {
68
95
  if (worker.error?.code === "ETIMEDOUT") {
69
96
  return {
@@ -103,9 +130,9 @@ function parseWorkerError(stdout) {
103
130
  return null;
104
131
  }
105
132
  function lockfileParseError(projects, collected) {
106
- const parseErrors = collected.parseErrors ?? [];
133
+ const parseErrors = collected.parseErrors;
107
134
  const detail = parseErrors
108
- .map((entry) => [entry.path, entry.message].filter(Boolean).join(": "))
135
+ .map((entry) => [entry.file, entry.reason].filter(Boolean).join(": "))
109
136
  .filter((line) => line.length > 0)
110
137
  .join("; ");
111
138
  const lockfiles = [...new Set(projects.map((project) => project.depFile))].join(", ");
@@ -116,8 +143,14 @@ function lockfileParseError(projects, collected) {
116
143
  : `found ${projects.length} lockfile${projects.length === 1 ? "" : "s"} (${lockfiles}) but no packages could be parsed`
117
144
  };
118
145
  }
119
- export function buildScannerReport(localReport, response, analyzedCount) {
120
- const findings = response.packages
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
121
154
  .filter((pkg) => (pkg.action ?? "pass") !== "pass")
122
155
  .map((pkg) => scannerFinding(pkg));
123
156
  const warnCount = findings.filter((finding) => finding.severity === "warn").length;
@@ -136,7 +169,7 @@ export function buildScannerReport(localReport, response, analyzedCount) {
136
169
  blockCount,
137
170
  errorCount: localReport.summary.errorCount
138
171
  },
139
- scanner: response
172
+ scanner: { ...response, packages }
140
173
  };
141
174
  }
142
175
  function scannerFinding(pkg) {
@@ -4,6 +4,9 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
4
4
  import { createTheme } from "../presentation/theme.js";
5
5
  import { resolvePresentation } from "../presentation/mode.js";
6
6
  import { loadUserConfig } from "../config/settings.js";
7
+ import { offerRememberSync } from "../decisions/remember-prompt.js";
8
+ import { packageKey } from "../decisions/apply.js";
9
+ import { loadDgFile, resolveAcceptedBy, warnUnreadableDgFile } from "../project/dgfile.js";
7
10
  import { gitSync, gitTrimmed } from "../util/git.js";
8
11
  import { promptYesNo } from "../util/tty-prompt.js";
9
12
  import { GUARD_SELFTEST_ENV } from "../setup/git-hook.js";
@@ -67,7 +70,8 @@ export function runStagedScan(options) {
67
70
  const env = options.env ?? process.env;
68
71
  const cwd = options.cwd ?? process.cwd();
69
72
  const theme = createTheme(resolvePresentation().color);
70
- if (env[GUARD_SELFTEST_ENV] === "1") {
73
+ // 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
+ if (options.hook && env[GUARD_SELFTEST_ENV] === "1" && env.GIT_INDEX_FILE === undefined) {
71
75
  return { exitCode: 2, stdout: "", stderr: "dg guard-commit self-test: synthetic block (exit 2)\n" };
72
76
  }
73
77
  const root = gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
@@ -85,16 +89,21 @@ export function runStagedScan(options) {
85
89
  if (scoped.length === 0) {
86
90
  return { exitCode: 0, stdout: "", stderr: "" };
87
91
  }
92
+ const dgFile = options.useDecisions === false ? null : loadDgFile(root);
93
+ if (dgFile) {
94
+ warnUnreadableDgFile(dgFile);
95
+ }
96
+ const usableDgFile = dgFile?.readable ? dgFile : null;
88
97
  const { dir, count } = materializeStaged(scoped, cwd, env);
89
98
  try {
90
99
  if (count === 0) {
91
100
  return failOpen(theme, "could not read the staged lockfile contents");
92
101
  }
93
- const report = tryScannerScan(dir, emptyLocalReport(dir), env);
102
+ const report = tryScannerScan(dir, emptyLocalReport(dir), env, usableDgFile);
94
103
  if (!report || !report.scanner) {
95
104
  return failOpen(theme, "could not reach the scanner");
96
105
  }
97
- return decideStagedVerdict(report, env, options.hook);
106
+ return decideStagedVerdict(report, env, options.hook, usableDgFile ? { root, file: usableDgFile } : undefined);
98
107
  }
99
108
  finally {
100
109
  rmSync(dir, { recursive: true, force: true });
@@ -119,12 +128,16 @@ export function stagedScanReport(options) {
119
128
  if (scoped.length === 0) {
120
129
  return { report: base, outcome: { kind: "skipped", reason: "no_lockfiles" } };
121
130
  }
131
+ const dgFile = options.useDecisions === false ? null : loadDgFile(root);
132
+ if (dgFile) {
133
+ warnUnreadableDgFile(dgFile);
134
+ }
122
135
  const { dir, count } = materializeStaged(scoped, cwd, env);
123
136
  try {
124
137
  if (count === 0) {
125
138
  return { report: base, outcome: { kind: "failed", error: { kind: "worker", message: "could not read the staged lockfile contents" } } };
126
139
  }
127
- return { report: base, outcome: runScannerScan(dir, base, env) };
140
+ return { report: base, outcome: runScannerScan(dir, base, env, dgFile?.readable ? dgFile : null) };
128
141
  }
129
142
  finally {
130
143
  rmSync(dir, { recursive: true, force: true });
@@ -144,11 +157,13 @@ function notARepoResult() {
144
157
  function outsideRepoResult(targetPath) {
145
158
  return { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg scan --staged: ${targetPath} is outside this repository.\n` };
146
159
  }
147
- export function decideStagedVerdict(report, env = process.env, hook = false) {
160
+ export function decideStagedVerdict(report, env = process.env, hook = false, decisionContext) {
148
161
  const theme = createTheme(resolvePresentation().color);
149
162
  const action = report.scanner?.action ?? report.status;
150
163
  const config = loadUserConfig(env);
151
164
  const count = report.summary.dependencyCount;
165
+ const effective = config.policy.mode === "strict" ? action : report.decisions?.effectiveAction ?? action;
166
+ const acknowledgedCount = report.decisions?.acknowledgedCount ?? 0;
152
167
  if (action === "block") {
153
168
  return { exitCode: 2, stdout: "", stderr: renderBlock(report.findings, theme) };
154
169
  }
@@ -156,17 +171,61 @@ export function decideStagedVerdict(report, env = process.env, hook = false) {
156
171
  return scannerUnavailable(theme);
157
172
  }
158
173
  if (action === "warn") {
159
- return decideWarn(report.findings, theme, config.gitHook.onWarn, config.policy.mode, hook);
174
+ if (acknowledgedCount > 0 && effective === "pass") {
175
+ return {
176
+ exitCode: 0,
177
+ stdout: "",
178
+ stderr: ` ${theme.paint("pass", "✓")} ${theme.paint("muted", `DG verified ${count} staged package${count === 1 ? "" : "s"} — ${acknowledgedCount} warning${acknowledgedCount === 1 ? "" : "s"} previously accepted (dg.json)`)}\n`
179
+ };
180
+ }
181
+ if (acknowledgedCount > 0 && effective === "analysis_incomplete") {
182
+ return decideIncomplete(config.gitHook.onIncomplete, theme);
183
+ }
184
+ const activeFindings = report.findings.filter((finding) => !finding.acknowledged);
185
+ return decideWarn(activeFindings, theme, config.gitHook.onWarn, config.policy.mode, hook, stagedRememberOffer(report, decisionContext, env));
160
186
  }
161
187
  if (action === "analysis_incomplete") {
162
- if (config.gitHook.onIncomplete === "block") {
163
- return { exitCode: 1, stdout: "", stderr: incompleteNotice(theme, true) };
164
- }
165
- return { exitCode: 0, stdout: "", stderr: incompleteNotice(theme, false) };
188
+ return decideIncomplete(config.gitHook.onIncomplete, theme);
166
189
  }
167
190
  return { exitCode: 0, stdout: "", stderr: ` ${theme.paint("pass", "✓")} ${theme.paint("muted", `DG verified ${count} staged package${count === 1 ? "" : "s"} — clean`)}\n` };
168
191
  }
169
- function decideWarn(findings, theme, onWarn, policyMode, hook) {
192
+ function decideIncomplete(onIncomplete, theme) {
193
+ if (onIncomplete === "block") {
194
+ return { exitCode: 1, stdout: "", stderr: incompleteNotice(theme, true) };
195
+ }
196
+ return { exitCode: 0, stdout: "", stderr: incompleteNotice(theme, false) };
197
+ }
198
+ export function stagedRememberOffer(report, decisionContext, env) {
199
+ if (!decisionContext || !report.scanner || !report.decisions) {
200
+ return undefined;
201
+ }
202
+ const annotations = report.decisions.packages;
203
+ const packages = [];
204
+ for (const pkg of report.scanner.packages) {
205
+ if ((pkg.action ?? "pass") !== "warn") {
206
+ continue;
207
+ }
208
+ const annotation = annotations[packageKey(pkg.name, pkg.version)];
209
+ if (!annotation || annotation.acknowledged) {
210
+ continue;
211
+ }
212
+ packages.push({ ecosystem: annotation.ecosystem, name: pkg.name, version: pkg.version, findings: pkg.findings });
213
+ }
214
+ if (packages.length === 0) {
215
+ return undefined;
216
+ }
217
+ return () => {
218
+ offerRememberSync({
219
+ file: decisionContext.file,
220
+ packages,
221
+ acceptedBy: resolveAcceptedBy(decisionContext.root, env),
222
+ surface: "guard-commit",
223
+ env,
224
+ ...(decisionContext.prompts ? { prompts: decisionContext.prompts } : {})
225
+ });
226
+ };
227
+ }
228
+ function decideWarn(findings, theme, onWarn, policyMode, hook, onAccepted) {
170
229
  const summary = warnSummary(findings, theme);
171
230
  if (onWarn === "allow") {
172
231
  return { exitCode: 0, stdout: "", stderr: `${summary} ${theme.paint("muted", "proceeding (gitHook.onWarn=allow)")}\n` };
@@ -183,6 +242,7 @@ function decideWarn(findings, theme, onWarn, policyMode, hook) {
183
242
  return { exitCode: 1, stdout: "", stderr: `${summary} ${theme.paint("muted", `commit blocked (no terminal; policy ${policyMode}). Use`)} ${theme.paint("accent", "git commit --no-verify")} ${theme.paint("muted", "to override.")}\n` };
184
243
  }
185
244
  if (answer) {
245
+ onAccepted?.();
186
246
  return { exitCode: 0, stdout: "", stderr: "" };
187
247
  }
188
248
  return { exitCode: 1, stdout: "", stderr: ` ${theme.paint("muted", "Nothing was committed.")}\n` };