@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
@@ -2,7 +2,9 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { basename, resolve } from "node:path";
3
3
  import { writeReportAtomic } from "../util/report-writer.js";
4
4
  import { discoverScanProjects, SBOM_LOCKFILE_ECOSYSTEMS } from "../scan/collect.js";
5
- import { buildCycloneDxSbom, collectSbomComponents, readRootComponent } from "../sbom/cyclonedx.js";
5
+ import { bomRefOf, buildCycloneDxSbom, collectSbomComponents, readRootComponent } from "../sbom/cyclonedx.js";
6
+ import { buildDependencyGraph, SBOM_ROOT_REF } from "../sbom/graph.js";
7
+ import { sanitizeLine } from "../security/sanitize.js";
6
8
  import { resolvePresentation } from "../presentation/mode.js";
7
9
  import { createTheme } from "../presentation/theme.js";
8
10
  import { buildSbomRows } from "../sbom-ui/inventory.js";
@@ -17,12 +19,12 @@ export const sbomCommand = {
17
19
  flags: [
18
20
  { flag: "--output", value: "<path>", summary: "Write the document to a file without opening the interactive view (alias -o)." },
19
21
  { flag: "--json", summary: "Print the raw CycloneDX document to stdout instead of the interactive view." },
20
- { flag: "--reproducible", summary: "Byte-stable output: derive the serial number from the components and drop the timestamp (honors SOURCE_DATE_EPOCH)." }
22
+ { flag: "--reproducible", summary: "Byte-stable output: derive the serial number from the components and pin the timestamp to SOURCE_DATE_EPOCH (or drop it when that is unset)." }
21
23
  ],
22
24
  examples: ["dg sbom", "dg sbom -o sbom.cdx.json", "dg sbom --json | cyclonedx validate", "dg sbom --reproducible"],
23
25
  details: [
24
- "Reads the project's lockfiles and inventories every resolved dependency as a CycloneDX 1.5 component with its purl, license, and integrity hash.",
25
- "In a terminal it opens an interactive view: the inventory appears immediately, then dg's scanner runs over the npm and PyPI components and fills in a BLOCK/WARN/PASS verdict per package — malware, provenance downgrades, and cooldown — while cargo stays inventory only. Browse with the arrow keys, search with /, filter with f, and press w to write the document. Signing in unlocks verdicts; without it the view shows the inventory alone.",
26
+ "Reads the project's lockfiles and inventories each resolved dependency as a CycloneDX 1.5 component with its purl, plus the declared license and integrity hash when the lockfile records them. npm and Cargo lockfiles also yield the dependency graph (which package pulls in which); unpinned dependencies are reported on stderr and left out.",
27
+ "In a terminal it opens an interactive view: the inventory appears immediately, then dg's scanner runs over the npm and PyPI components and fills in a BLOCK/WARN/PASS verdict per package — malware, provenance downgrades, and cooldown — while cargo stays inventory only. Browse with the arrow keys, search with /, filter with f, and press e to export the document. Signing in unlocks verdicts; without it the view shows the inventory alone.",
26
28
  "Piped, with -o, or with --json it stays offline and prints the raw CycloneDX document so you can attach it to a release or feed any CycloneDX-aware tool — no scan, no account needed. Use --reproducible (or set SOURCE_DATE_EPOCH) for identical bytes across runs so a committed SBOM diffs cleanly."
27
29
  ],
28
30
  handler: (context) => runSbomCommand(context)
@@ -37,11 +39,24 @@ export function runSbomCommand(context, cwd = process.cwd(), now = new Date(), e
37
39
  const { components, dropped } = collectSbomComponents(projects);
38
40
  const rootComponent = readRootComponent(root);
39
41
  const reproducible = parsed.reproducible || env.SOURCE_DATE_EPOCH !== undefined;
42
+ const componentRefs = new Set(components.map((component) => bomRefOf(component)));
43
+ const dependencies = buildDependencyGraph(projects, componentRefs);
44
+ const graphable = projects.length > 0 &&
45
+ projects.every((project) => project.depFile === "package-lock.json" ||
46
+ project.depFile === "npm-shrinkwrap.json" ||
47
+ project.depFile === "Cargo.lock");
48
+ const completeness = dropped.length === 0 && graphable && dependencies.length > 0 ? "complete" : "incomplete";
49
+ const rootSubject = rootComponent.component ??
50
+ (dependencies.some((dependency) => dependency.ref === SBOM_ROOT_REF)
51
+ ? { name: sanitizeLine(basename(root)) || "root" }
52
+ : undefined);
40
53
  const bom = buildCycloneDxSbom(components, {
41
54
  ...timestampOption(reproducible, now, env),
42
55
  serialNumber: "urn:uuid:00000000-0000-0000-0000-000000000000",
43
56
  toolVersion: dgVersion(),
44
- ...(rootComponent.component ? { rootComponent: rootComponent.component } : {})
57
+ dependencies,
58
+ completeness,
59
+ ...(rootSubject ? { rootComponent: rootSubject } : {})
45
60
  });
46
61
  const finalBom = {
47
62
  ...bom,
@@ -2,18 +2,19 @@ import { runScanCommand } from "../scan/command.js";
2
2
  export const scanCommand = {
3
3
  name: "scan",
4
4
  summary: "Scan a project and show Dependency Guardian findings.",
5
- usage: "dg scan [path] [--json|--sarif] [--output <path>]",
5
+ usage: "dg scan [path] [--json|--sarif] [--output <path>] [--fail-on <block|warn|never>]",
6
6
  args: [{ name: "[path]", summary: "Project directory or a package.json to scan (default: current directory)." }],
7
7
  flags: [
8
8
  { flag: "--json", summary: "Machine-readable JSON report." },
9
9
  { flag: "--sarif", summary: "SARIF report for code-scanning tools." },
10
10
  { flag: "--output", value: "<path>", summary: "Write the report to a file instead of stdout (alias -o)." },
11
- { flag: "--staged", summary: "Scan only the git-staged lockfile changes (what dg guard-commit runs)." },
11
+ { flag: "--fail-on", value: "<block|warn|never>", summary: "Exit non-zero at or above this level (default: block; never suppresses block/warn exit codes, but analysis-incomplete still exits 4)." },
12
+ { flag: "--staged", summary: "Scan only the git-staged lockfile changes (what dg commit-guard runs)." },
12
13
  { flag: "--no-decisions", summary: "Ignore acceptances remembered in dg.json (see dg decisions)." }
13
14
  ],
14
- examples: ["dg scan", "dg scan ./packages/api", "dg scan --json -o scan.json", "dg scan --staged"],
15
+ examples: ["dg scan", "dg scan ./packages/api", "dg scan --json -o scan.json", "dg scan --sarif -o scan.sarif --fail-on never"],
15
16
  details: [
16
- "Reads lockfiles, scores each dependency through the scanner, and never changes project files. In a terminal it opens the full-screen results browser; piped or with --json/--sarif it prints machine output. Exit codes: 0 clean, 1 warn, 2 block, 4 analysis incomplete, 10 nothing to scan, 64 usage error."
17
+ "Reads lockfiles, scores each dependency through the scanner, and never changes project files. Requires sign-in — run dg login. In a terminal it opens the full-screen results browser; piped or with --json/--sarif it prints machine output. Exit codes: 0 clean (warn counts as clean under the default --fail-on block), 1 warn with --fail-on warn, 2 block, 4 analysis incomplete, 10 nothing to scan, 64 usage error. Use --fail-on never so a block or warn verdict exits 0; analysis-incomplete (4), nothing-to-scan (10), and a rejected key (64) still exit non-zero, so a broken scan fails loudly instead of uploading an empty report. Add if: always() to the SARIF-upload step so it runs even when dg exits non-zero."
17
18
  ],
18
19
  handler: runScanCommand
19
20
  };
@@ -497,7 +497,7 @@ function serviceDoctorChecks(state) {
497
497
  {
498
498
  name: "service-proxy",
499
499
  status: state.running && state.proxy ? "pass" : state.lastError?.startsWith("stale service runtime state") ? "fail" : "warn",
500
- message: state.running && state.proxy ? `Persistent service proxy listening at ${state.proxy.proxyUrl}` : state.lastError ?? "Service proxy is not running"
500
+ message: state.running && state.proxy ? `Service proxy listening at ${state.proxy.proxyUrl} for this login session (not restarted after reboot or logout)` : state.lastError ?? "Service proxy is not running"
501
501
  },
502
502
  {
503
503
  name: "health-endpoint",
@@ -15,19 +15,19 @@ import { markSecurityNotesShown, securityNotesShown } from "../setup-ui/gate.js"
15
15
  export const setupCommand = {
16
16
  name: "setup",
17
17
  summary: "Protect this machine — shell installs, AI-agent installs, and a repo's commits.",
18
- usage: "dg setup [--print] [--yes] [--agents] [--guard-commit] [--shell <auto|zsh|bash|fish>] [--service]",
18
+ usage: "dg setup [--print] [--yes] [--agents] [--commit-guard] [--shell <auto|zsh|bash|fish>] [--service]",
19
19
  flags: [
20
20
  { flag: "--print", summary: "Preview the exact write plan and change nothing." },
21
- { flag: "--yes", summary: "Apply the shell firewall only, without prompts (add --agents / --guard-commit to opt in the rest)." },
21
+ { flag: "--yes", summary: "Apply the shell firewall only, without prompts (add --agents / --commit-guard to opt in the rest)." },
22
22
  { flag: "--agents", summary: "With --yes: also route every detected AI agent's installs through dg." },
23
- { flag: "--guard-commit", summary: "With --yes: also scan this repo's commits (per-repo)." },
23
+ { flag: "--commit-guard", summary: "With --yes: also scan this repo's commits (per-repo)." },
24
24
  { flag: "--shell", value: "<auto|zsh|bash|fish>", summary: "Target shell rc to write (default: auto-detect)." },
25
- { flag: "--service", summary: "Set up service mode (Pro/Team; persistent proxy + managed CA)." }
25
+ { flag: "--service", summary: "Set up service mode (Pro/Team; long-running local proxy for this login session + managed CA)." }
26
26
  ],
27
27
  examples: ["dg setup", "dg setup --print", "dg setup --yes --agents", "dg setup --shell zsh --yes"],
28
28
  details: [
29
29
  "In a terminal it shows one consent screen listing everything it will protect — your shell's installs plus every detected AI agent, with the exact files it writes — and applies them on a single yes. Commits in the current repo are a separate per-repo question.",
30
- "Non-interactively, --yes applies just the shell firewall; add --agents and/or --guard-commit to opt those in. --print previews. Writes only dg-owned reversible files; undo it all with dg uninstall."
30
+ "Non-interactively, --yes applies just the shell firewall; add --agents and/or --commit-guard to opt those in. --print previews. Writes only dg-owned reversible files; undo it all with dg uninstall."
31
31
  ],
32
32
  handler: (context) => setupHandler(context.args)
33
33
  };
@@ -234,7 +234,7 @@ async function applyRequestedSurfaces(parsed) {
234
234
  }
235
235
  }
236
236
  }
237
- if (parsed.guardCommit) {
237
+ if (parsed.commitGuard) {
238
238
  const repo = commitGuardOffer();
239
239
  if (repo) {
240
240
  try {
@@ -254,7 +254,7 @@ function printableSurfaceOffers() {
254
254
  lines.push(`would offer: route ${offer.label} installs through dg (--agents)`);
255
255
  }
256
256
  if (commitGuardOffer()) {
257
- lines.push("would offer: scan this repo's commits (--guard-commit)");
257
+ lines.push("would offer: scan this repo's commits (--commit-guard)");
258
258
  }
259
259
  return lines.length > 0 ? `\n${lines.join("\n")}\n` : "";
260
260
  }
@@ -335,7 +335,7 @@ function parseSetupArgs(args) {
335
335
  let shell = "auto";
336
336
  let service = false;
337
337
  let agents = false;
338
- let guardCommit = false;
338
+ let commitGuard = false;
339
339
  for (let index = 0; index < args.length; index += 1) {
340
340
  const arg = args[index];
341
341
  if (arg === "--print") {
@@ -350,8 +350,8 @@ function parseSetupArgs(args) {
350
350
  else if (arg === "--agents") {
351
351
  agents = true;
352
352
  }
353
- else if (arg === "--guard-commit") {
354
- guardCommit = true;
353
+ else if (arg === "--commit-guard") {
354
+ commitGuard = true;
355
355
  }
356
356
  else if (arg === "--python-hook" || arg?.startsWith("--python-hook=")) {
357
357
  return {
@@ -362,7 +362,7 @@ function parseSetupArgs(args) {
362
362
  else if (arg === "--git-hooks") {
363
363
  return {
364
364
  exitCode: EXIT_USAGE,
365
- error: "dg setup --git-hooks moved — run 'dg guard-commit' inside the repo you want to protect.\n"
365
+ error: "dg setup --git-hooks moved — run 'dg commit-guard' inside the repo you want to protect.\n"
366
366
  };
367
367
  }
368
368
  else if (arg === "--shell") {
@@ -419,7 +419,7 @@ function parseSetupArgs(args) {
419
419
  shell,
420
420
  service,
421
421
  agents,
422
- guardCommit
422
+ commitGuard
423
423
  };
424
424
  }
425
425
  function parseShell(value) {
@@ -64,7 +64,7 @@ async function buildStatusReport(io) {
64
64
  async function fetchStatusUsage(connected, io) {
65
65
  const token = connected ? resolveStatusToken() : undefined;
66
66
  if (!token) {
67
- return { state: "anonymous" };
67
+ return { state: "signed-out" };
68
68
  }
69
69
  const account = await fetchAccountStatus(token, process.env, io.fetchImpl ?? fetch, io.usageTimeoutMs ?? STATUS_USAGE_TIMEOUT_MS);
70
70
  if (!account || account.scansUsed === null) {
@@ -131,7 +131,7 @@ function renderStatus(report, theme) {
131
131
  return `${lines.join("\n")}\n`;
132
132
  }
133
133
  function usageLine(report, theme) {
134
- if (report.usage.state === "anonymous") {
134
+ if (report.usage.state === "signed-out") {
135
135
  return theme.paint("muted", "sign in to see usage");
136
136
  }
137
137
  if (report.usage.state === "unavailable") {
@@ -147,7 +147,7 @@ function commitGuardLine(state, theme) {
147
147
  return `${theme.paint("pass", "✓")} this repo's commits are scanned`;
148
148
  }
149
149
  if (state === "dead") {
150
- return `${theme.paint("warn", "⚠")} installed but git isn't using it — run ${theme.paint("accent", "dg guard-commit --check")}`;
150
+ return `${theme.paint("warn", "⚠")} installed but git isn't using it — run ${theme.paint("accent", "dg commit-guard --check")}`;
151
151
  }
152
- return `${theme.paint("warn", "⚠")} off — run ${theme.paint("accent", "dg guard-commit")}`;
152
+ return `${theme.paint("warn", "⚠")} off — run ${theme.paint("accent", "dg commit-guard")}`;
153
153
  }
@@ -8,7 +8,7 @@ export const uninstallCommand = {
8
8
  usage: "dg uninstall [--yes] [--service] [--all] [--keep-config]",
9
9
  flags: [
10
10
  { flag: "--yes", summary: "Remove without the confirmation prompt." },
11
- { flag: "--keep-config", summary: "Keep ~/.dg config and cache." },
11
+ { flag: "--keep-config", summary: "Keep the saved login too (the default removes it; config.json is kept either way unless --all)." },
12
12
  { flag: "--all", summary: "Also remove the config directory — a full wipe." },
13
13
  { flag: "--service", summary: "Reverse only service-mode writes." }
14
14
  ],
@@ -1,7 +1,24 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { toolInvocation } from "../util/external-tool.js";
5
+ import { resolveDgPaths } from "../state/index.js";
3
6
  import { EXIT_USAGE } from "./types.js";
4
7
  import { dgVersion } from "./version.js";
8
+ // `dg update` only prints the npm command; the user runs the upgrade manually,
9
+ // during which dg is briefly unavailable. Drop a freshness-stamped sentinel the
10
+ // shim's fail-open self-cleanup honors (15-min window) so a slow `npm install -g`
11
+ // is not mistaken for an uninstall and self-cleaned mid-upgrade.
12
+ function markUpgradeWindow() {
13
+ try {
14
+ const dgHome = join(resolveDgPaths().homeDir, ".dg");
15
+ mkdirSync(dgHome, { recursive: true, mode: 0o700 });
16
+ writeFileSync(join(dgHome, ".upgrading"), "", { encoding: "utf8", mode: 0o600 });
17
+ }
18
+ catch {
19
+ // best-effort: a missing sentinel only restores the prior 60s-debounce behavior
20
+ }
21
+ }
5
22
  const PACKAGE_NAME = "@westbayberry/dg";
6
23
  const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/u;
7
24
  export const updateCommand = {
@@ -24,6 +41,9 @@ function runUpdateCommand(args, commandName) {
24
41
  }
25
42
  const latestVersion = readLatestVersion();
26
43
  const report = buildUpdateReport(latestVersion);
44
+ if (report.updateCommand) {
45
+ markUpgradeWindow();
46
+ }
27
47
  if (parsed.format === "json") {
28
48
  return {
29
49
  exitCode: report.status === "unknown" ? 1 : 0,
@@ -1,4 +1,4 @@
1
- import { EXIT_ANALYSIS_INCOMPLETE, EXIT_UNAVAILABLE, EXIT_USAGE_VERDICT } from "./types.js";
1
+ import { EXIT_ANALYSIS_INCOMPLETE, EXIT_TOOL_ERROR, EXIT_UNAVAILABLE, EXIT_USAGE_VERDICT } from "./types.js";
2
2
  import { writeFileSync } from "node:fs";
3
3
  import { existsSync } from "node:fs";
4
4
  import { resolve } from "node:path";
@@ -22,8 +22,8 @@ export const verifyCommand = {
22
22
  ],
23
23
  examples: ["dg verify npm:react", "dg verify pypi:requests@2.31.0", "dg verify ./pkg.tgz --verbose", "dg verify package-lock.json --json"],
24
24
  details: [
25
- "dg verify npm:react (or pypi:requests, with an optional @version — defaults to latest) runs a real scanner check on a published package before you install it. Signed-out runs show the verdict and top reasons; dg login unlocks full findings, license info, --json, and --output.",
26
- "Local paths, workspaces, tgz/zip/wheel artifacts, and lockfiles are verified offline as advisory preflight (free); proxy enforcement remains authoritative for network artifact fetches."
25
+ "dg verify npm:react (or pypi:requests, with an optional @version — defaults to latest) runs a real scanner check on a published package before you install it. Registry checks require sign-in run dg login.",
26
+ "Local paths, workspaces, tgz/zip/wheel artifacts, and lockfiles are verified offline as advisory preflight (no account needed); proxy enforcement remains authoritative for network artifact fetches."
27
27
  ],
28
28
  handler: (context) => {
29
29
  const parsed = parseVerifyArgs(context.args);
@@ -52,7 +52,7 @@ export const verifyCommand = {
52
52
  }
53
53
  catch (error) {
54
54
  return {
55
- exitCode: EXIT_ANALYSIS_INCOMPLETE,
55
+ exitCode: EXIT_TOOL_ERROR,
56
56
  stdout: "",
57
57
  stderr: `dg verify could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n`
58
58
  };
@@ -11,8 +11,10 @@ export const CONFIG_KEYS = Object.freeze([
11
11
  "policy.scriptHardening",
12
12
  "policy.shimFailClosed",
13
13
  "policy.strictEgress",
14
+ "policy.allowNonVendorApiHost",
15
+ "policy.provenanceDowngrade",
14
16
  "scriptGate.mode",
15
- "scriptGate.observe",
17
+ "scriptGate.persistObservations",
16
18
  "gitHook.onWarn",
17
19
  "gitHook.onIncomplete",
18
20
  "cooldown.age",
@@ -37,11 +39,13 @@ export const DEFAULT_CONFIG = Object.freeze({
37
39
  allowForceOverride: true,
38
40
  scriptHardening: false,
39
41
  shimFailClosed: false,
40
- strictEgress: false
42
+ strictEgress: false,
43
+ allowNonVendorApiHost: false,
44
+ provenanceDowngrade: "off"
41
45
  },
42
46
  scriptGate: {
43
47
  mode: "observe",
44
- observe: false
48
+ persistObservations: false
45
49
  },
46
50
  gitHook: {
47
51
  onWarn: "prompt",
@@ -146,11 +150,17 @@ export function getConfigValue(config, key) {
146
150
  if (key === "policy.strictEgress") {
147
151
  return String(config.policy.strictEgress);
148
152
  }
153
+ if (key === "policy.allowNonVendorApiHost") {
154
+ return String(config.policy.allowNonVendorApiHost);
155
+ }
156
+ if (key === "policy.provenanceDowngrade") {
157
+ return config.policy.provenanceDowngrade;
158
+ }
149
159
  if (key === "scriptGate.mode") {
150
160
  return config.scriptGate.mode;
151
161
  }
152
- if (key === "scriptGate.observe") {
153
- return String(config.scriptGate.observe);
162
+ if (key === "scriptGate.persistObservations") {
163
+ return String(config.scriptGate.persistObservations);
154
164
  }
155
165
  if (key === "gitHook.onWarn") {
156
166
  return config.gitHook.onWarn;
@@ -183,7 +193,8 @@ export const ADVANCED_CONFIG_KEYS = new Set([
183
193
  "policy.scriptHardening",
184
194
  "policy.shimFailClosed",
185
195
  "policy.strictEgress",
186
- "scriptGate.observe",
196
+ "policy.provenanceDowngrade",
197
+ "scriptGate.persistObservations",
187
198
  "cooldown.npm.age",
188
199
  "cooldown.pypi.age",
189
200
  "cooldown.cargo.age"
@@ -199,7 +210,7 @@ export function setConfigValue(config, key, rawValue) {
199
210
  return {
200
211
  ...config,
201
212
  api: {
202
- baseUrl: parseUrl(rawValue)
213
+ baseUrl: assertVendorApiBaseUrl(parseUrl(rawValue), config.policy.allowNonVendorApiHost)
203
214
  }
204
215
  };
205
216
  }
@@ -235,6 +246,25 @@ export function setConfigValue(config, key, rawValue) {
235
246
  if (key === "policy.strictEgress") {
236
247
  return withPolicyBoolean(config, "strictEgress", rawValue);
237
248
  }
249
+ if (key === "policy.allowNonVendorApiHost") {
250
+ const next = withPolicyBoolean(config, "allowNonVendorApiHost", rawValue);
251
+ if (!next.policy.allowNonVendorApiHost) {
252
+ const host = new URL(next.api.baseUrl).hostname;
253
+ if (!isVendorApiHost(host)) {
254
+ throw new ConfigError(`cannot disable policy.allowNonVendorApiHost while api.baseUrl points at the self-hosted host ${host}; reset api.baseUrl to a vendor host (or unset it) first`);
255
+ }
256
+ }
257
+ return next;
258
+ }
259
+ if (key === "policy.provenanceDowngrade") {
260
+ return {
261
+ ...config,
262
+ policy: {
263
+ ...config.policy,
264
+ provenanceDowngrade: parseProvenanceDowngradeMode(rawValue)
265
+ }
266
+ };
267
+ }
238
268
  if (key === "scriptGate.mode") {
239
269
  return {
240
270
  ...config,
@@ -244,12 +274,12 @@ export function setConfigValue(config, key, rawValue) {
244
274
  }
245
275
  };
246
276
  }
247
- if (key === "scriptGate.observe") {
277
+ if (key === "scriptGate.persistObservations") {
248
278
  return {
249
279
  ...config,
250
280
  scriptGate: {
251
281
  ...config.scriptGate,
252
- observe: parseBoolean(rawValue, key)
282
+ persistObservations: parseBoolean(rawValue, key)
253
283
  }
254
284
  };
255
285
  }
@@ -317,7 +347,7 @@ function normalizeConfig(raw) {
317
347
  const cooldown = fieldObject(raw, "cooldown");
318
348
  const audit = fieldObject(raw, "audit");
319
349
  const scriptHardening = fieldBoolean(policy, "policy.scriptHardening", "scriptHardening") ?? DEFAULT_CONFIG.policy.scriptHardening;
320
- return {
350
+ const normalized = {
321
351
  version: 1,
322
352
  api: {
323
353
  baseUrl: parseUrl(fieldString(api, "api.baseUrl", "baseUrl") ?? DEFAULT_CONFIG.api.baseUrl)
@@ -331,11 +361,15 @@ function normalizeConfig(raw) {
331
361
  allowForceOverride: fieldBoolean(policy, "policy.allowForceOverride", "allowForceOverride") ?? DEFAULT_CONFIG.policy.allowForceOverride,
332
362
  scriptHardening,
333
363
  shimFailClosed: fieldBoolean(policy, "policy.shimFailClosed", "shimFailClosed") ?? DEFAULT_CONFIG.policy.shimFailClosed,
334
- strictEgress: fieldBoolean(policy, "policy.strictEgress", "strictEgress") ?? DEFAULT_CONFIG.policy.strictEgress
364
+ strictEgress: fieldBoolean(policy, "policy.strictEgress", "strictEgress") ?? DEFAULT_CONFIG.policy.strictEgress,
365
+ allowNonVendorApiHost: fieldBoolean(policy, "policy.allowNonVendorApiHost", "allowNonVendorApiHost") ?? DEFAULT_CONFIG.policy.allowNonVendorApiHost,
366
+ provenanceDowngrade: parseProvenanceDowngradeMode(fieldString(policy, "policy.provenanceDowngrade", "provenanceDowngrade") ?? DEFAULT_CONFIG.policy.provenanceDowngrade)
335
367
  },
336
368
  scriptGate: {
337
369
  mode: parseScriptGateMode(fieldString(scriptGate, "scriptGate.mode", "mode") ?? (scriptHardening ? "enforce" : DEFAULT_CONFIG.scriptGate.mode)),
338
- observe: fieldBoolean(scriptGate, "scriptGate.observe", "observe") ?? DEFAULT_CONFIG.scriptGate.observe
370
+ persistObservations: fieldBoolean(scriptGate, "scriptGate.persistObservations", "persistObservations") ??
371
+ fieldBoolean(scriptGate, "scriptGate.observe", "observe") ??
372
+ DEFAULT_CONFIG.scriptGate.persistObservations
339
373
  },
340
374
  gitHook: {
341
375
  onWarn: parseOnWarn(fieldString(gitHook, "gitHook.onWarn", "onWarn") ?? DEFAULT_CONFIG.gitHook.onWarn),
@@ -353,6 +387,8 @@ function normalizeConfig(raw) {
353
387
  upload: fieldBoolean(audit, "audit.upload", "upload") ?? DEFAULT_CONFIG.audit.upload
354
388
  }
355
389
  };
390
+ assertVendorApiBaseUrl(normalized.api.baseUrl, normalized.policy.allowNonVendorApiHost);
391
+ return normalized;
356
392
  }
357
393
  function fieldObject(root, field) {
358
394
  const value = root[field];
@@ -417,6 +453,12 @@ function parsePolicyMode(value) {
417
453
  }
418
454
  throw new ConfigError("policy.mode must be one of: off, warn, block, strict");
419
455
  }
456
+ function parseProvenanceDowngradeMode(value) {
457
+ if (value === "off" || value === "warn" || value === "block") {
458
+ return value;
459
+ }
460
+ throw new ConfigError("policy.provenanceDowngrade must be one of: off, warn, block");
461
+ }
420
462
  function parseScriptGateMode(value) {
421
463
  if (value === "observe" || value === "enforce" || value === "off") {
422
464
  return value;
@@ -500,3 +542,30 @@ export function parseUrl(value) {
500
542
  throw new ConfigError("api.baseUrl must be an absolute URL");
501
543
  }
502
544
  }
545
+ // The vendor API host the CLI ships to — derived from the default so there is one
546
+ // source of truth, never a duplicated literal. localhost stays usable for dev.
547
+ export const VENDOR_API_HOST = new URL(DEFAULT_CONFIG.api.baseUrl).hostname;
548
+ export function isVendorApiHost(host) {
549
+ return host === VENDOR_API_HOST || host === "localhost" || host === "127.0.0.1";
550
+ }
551
+ // The CLI attaches a Bearer token to and trusts scan verdicts from this base URL,
552
+ // so a non-vendor host (a tampered config / auth file, an env-set login base, an
553
+ // unpinned MITM) would leak the token and could return action:pass for malware.
554
+ // Refuse it unless the operator opted into a self-hosted API in their own global
555
+ // config (policy.allowNonVendorApiHost) — a project/repo cannot set that field.
556
+ export function assertVendorApiBaseUrl(baseUrl, allowNonVendor) {
557
+ if (allowNonVendor) {
558
+ return baseUrl;
559
+ }
560
+ let host;
561
+ try {
562
+ host = new URL(baseUrl).hostname;
563
+ }
564
+ catch {
565
+ throw new ConfigError("api.baseUrl must be an absolute URL");
566
+ }
567
+ if (!isVendorApiHost(host)) {
568
+ throw new ConfigError(`api.baseUrl host ${host} is not a Dependency Guardian vendor host; set policy.allowNonVendorApiHost=true to use a self-hosted API`);
569
+ }
570
+ return baseUrl;
571
+ }
@@ -6,7 +6,7 @@ import { existsSync, readdirSync } from "node:fs";
6
6
  import { basename, join, resolve, sep } from "node:path";
7
7
  import { exportDestinations, resolveExportPath, userHomeDir, writeReportAtomic } from "../util/report-writer.js";
8
8
  export function loginRequiredToast() {
9
- return `${chalk.yellow("Sign in to export:")} ${chalk.cyan.bold("dg login")} ${chalk.dim("(free account)")}`;
9
+ return `${chalk.yellow("Sign in to export:")} ${chalk.cyan.bold("dg login")}`;
10
10
  }
11
11
  const PATH_DISPLAY_MAX = 48;
12
12
  function cursorBeforeExtension(name) {
@@ -0,0 +1,22 @@
1
+ import { cooldownRequestParam, isCooldownExemptByDgFile } from "../policy/cooldown.js";
2
+ import { DEFAULT_CONFIG, loadUserConfig } from "../config/settings.js";
3
+ export function resolveCooldownRequest(options, identity) {
4
+ if (identity.ecosystem === "unknown" || identity.version === "unknown") {
5
+ return undefined;
6
+ }
7
+ if (isCooldownExemptByDgFile(identity.name, identity.ecosystem, options.cooldownExemptions ?? [])) {
8
+ return undefined;
9
+ }
10
+ // A corrupt user config must not silently disable the cooldown gate — fall back
11
+ // to defaults (24h age + onUnknown=block) so the gate still applies, consistent
12
+ // with the rest of the policy stack. undefined stays reserved for the legitimate
13
+ // disabled/exempt cases cooldownRequestParam itself returns.
14
+ let config;
15
+ try {
16
+ config = loadUserConfig(options.env);
17
+ }
18
+ catch {
19
+ config = DEFAULT_CONFIG;
20
+ }
21
+ return cooldownRequestParam(config, options.env, identity.ecosystem, identity.name);
22
+ }