@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
@@ -26,15 +26,15 @@ export function accountHeaderLine(usageTier, env = process.env) {
26
26
  export function effectiveScanAction(raw, effective, mode) {
27
27
  return mode === "strict" || effective === undefined ? raw : effective;
28
28
  }
29
- export function scanExitCode(action, mode) {
29
+ export function verdictExitCode(action, failOn) {
30
+ if (action === "analysis_incomplete") {
31
+ return 4;
32
+ }
30
33
  if (action === "block") {
31
- return 2;
34
+ return failOn === "never" ? 0 : 2;
32
35
  }
33
36
  if (action === "warn") {
34
- return mode === "strict" ? 2 : 1;
35
- }
36
- if (action === "analysis_incomplete") {
37
- return 4;
37
+ return failOn === "warn" ? 1 : 0;
38
38
  }
39
39
  return 0;
40
40
  }
@@ -1,7 +1,7 @@
1
1
  import { loadUserConfig } from "../config/settings.js";
2
2
  import { resolvePresentation } from "../presentation/mode.js";
3
3
  import { createTheme } from "../presentation/theme.js";
4
- import { loadDgFile, saveDgFile } from "../project/dgfile.js";
4
+ import { dgFilePath, loadDgFile, saveDgFile, withDgFileLock } from "../project/dgfile.js";
5
5
  import { detectPnpmIgnoredBuilds, detectScriptWanters } from "./detect.js";
6
6
  export function evaluateScriptGate(wanters, approvals) {
7
7
  const approved = [];
@@ -49,33 +49,38 @@ export function applyScriptDecisions(file, decisions, now) {
49
49
  };
50
50
  }
51
51
  export function recordScriptObservations(options) {
52
- const file = loadDgFile(options.projectDir);
53
- if (!file.readable || (!file.exists && !options.createIfMissing) || options.wanters.length === 0) {
54
- return { written: false, path: file.path };
52
+ if (options.wanters.length === 0) {
53
+ return { written: false, path: dgFilePath(options.projectDir) };
55
54
  }
56
- const observed = { ...file.scriptApprovals.observed };
57
- let changed = false;
58
- for (const wanter of options.wanters) {
59
- const existing = observed[wanter.name];
60
- if (existing &&
61
- existing.version === wanter.version &&
62
- existing.scriptsHash === wanter.scriptsHash &&
63
- sameHooks(existing.hooks, wanter.hooks)) {
64
- continue;
55
+ return withDgFileLock(options.projectDir, options.env, () => {
56
+ const file = loadDgFile(options.projectDir);
57
+ if (!file.readable || (!file.exists && !options.createIfMissing)) {
58
+ return { written: false, path: file.path };
65
59
  }
66
- observed[wanter.name] = {
67
- version: wanter.version,
68
- hooks: wanter.hooks,
69
- scriptsHash: wanter.scriptsHash,
70
- firstSeen: existing ? existing.firstSeen : options.now.toISOString()
71
- };
72
- changed = true;
73
- }
74
- if (!changed) {
75
- return { written: false, path: file.path };
76
- }
77
- saveDgFile({ ...file, scriptApprovals: { ...file.scriptApprovals, observed } });
78
- return { written: true, path: file.path };
60
+ const observed = { ...file.scriptApprovals.observed };
61
+ let changed = false;
62
+ for (const wanter of options.wanters) {
63
+ const existing = observed[wanter.name];
64
+ if (existing &&
65
+ existing.version === wanter.version &&
66
+ existing.scriptsHash === wanter.scriptsHash &&
67
+ sameHooks(existing.hooks, wanter.hooks)) {
68
+ continue;
69
+ }
70
+ observed[wanter.name] = {
71
+ version: wanter.version,
72
+ hooks: wanter.hooks,
73
+ scriptsHash: wanter.scriptsHash,
74
+ firstSeen: existing ? existing.firstSeen : options.now.toISOString()
75
+ };
76
+ changed = true;
77
+ }
78
+ if (!changed) {
79
+ return { written: false, path: file.path };
80
+ }
81
+ saveDgFile({ ...file, scriptApprovals: { ...file.scriptApprovals, observed } });
82
+ return { written: true, path: file.path };
83
+ });
79
84
  }
80
85
  function sameHooks(a, b) {
81
86
  return a.length === b.length && a.every((hook, index) => hook === b[index]);
@@ -86,11 +91,16 @@ export function hasExplicitScriptPreference(args, env) {
86
91
  }
87
92
  return env.npm_config_ignore_scripts !== undefined && env.npm_config_ignore_scripts !== "";
88
93
  }
94
+ // Managers that honor `--ignore-scripts` (and the matching npm_config env key), so
95
+ // enforce mode can actually suppress lifecycle scripts. pip/uv build sdists via
96
+ // setup.py / PEP 517 backends and cargo runs build.rs with no equivalent flag —
97
+ // they cannot be enforced this way and are surfaced by runScriptGateAfterInstall.
98
+ const IGNORE_SCRIPTS_MANAGERS = new Set(["npm", "yarn", "pnpm"]);
89
99
  export function scriptGateInstallArgs(options) {
90
100
  if (options.mode !== "enforce") {
91
101
  return options.args;
92
102
  }
93
- if (options.manager !== "npm" && options.manager !== "yarn") {
103
+ if (!IGNORE_SCRIPTS_MANAGERS.has(options.manager)) {
94
104
  return options.args;
95
105
  }
96
106
  if (hasExplicitScriptPreference(options.args, options.env)) {
@@ -99,7 +109,7 @@ export function scriptGateInstallArgs(options) {
99
109
  return [...options.args, "--ignore-scripts"];
100
110
  }
101
111
  export function scriptGateChildEnv(options) {
102
- if (options.mode !== "enforce" || (options.manager !== "npm" && options.manager !== "yarn")) {
112
+ if (options.mode !== "enforce" || !IGNORE_SCRIPTS_MANAGERS.has(options.manager)) {
103
113
  return {};
104
114
  }
105
115
  if (hasExplicitScriptPreference(options.args, options.env)) {
@@ -125,6 +135,10 @@ export function scriptGateReportLine(options) {
125
135
  const noun = wanters.length === 1 ? "package ran" : "packages ran";
126
136
  return `\n ${theme.paint("muted", `dg scripts: ${wanters.length} ${noun} install scripts (${formatNames(names)}) — observed, not blocked · silence: dg config set scriptGate.mode off`)}\n`;
127
137
  }
138
+ function unsuppressableScriptNotice(manager) {
139
+ const theme = createTheme(resolvePresentation().color);
140
+ return `\n ${theme.paint("muted", `dg scripts: enforce mode cannot suppress build/install scripts for ${manager} (no --ignore-scripts equivalent); they ran — review with dg scan`)}\n`;
141
+ }
128
142
  function formatNames(names) {
129
143
  if (names.length <= REPORTED_NAME_LIMIT) {
130
144
  return names.join(", ");
@@ -139,17 +153,20 @@ const MUTATING_ACTIONS = {
139
153
  export function runScriptGateAfterInstall(options) {
140
154
  try {
141
155
  const classification = options.classification;
142
- if (classification.kind !== "protected" || classification.ecosystem !== "javascript") {
143
- return "";
144
- }
145
- const mutatingActions = MUTATING_ACTIONS[classification.manager];
146
- if (!mutatingActions || !mutatingActions.has(classification.action)) {
156
+ if (classification.kind !== "protected") {
147
157
  return "";
148
158
  }
149
159
  const config = loadUserConfig(options.env ?? process.env);
150
160
  if (config.scriptGate.mode === "off") {
151
161
  return "";
152
162
  }
163
+ if (classification.ecosystem !== "javascript") {
164
+ return config.scriptGate.mode === "enforce" ? unsuppressableScriptNotice(classification.manager) : "";
165
+ }
166
+ const mutatingActions = MUTATING_ACTIONS[classification.manager];
167
+ if (!mutatingActions || !mutatingActions.has(classification.action)) {
168
+ return "";
169
+ }
153
170
  const projectDir = options.projectDir ?? process.cwd();
154
171
  if (classification.manager === "pnpm") {
155
172
  return scriptGateReportLine({ manager: "pnpm", pnpmIgnoredBuilds: detectPnpmIgnoredBuilds(projectDir) });
@@ -158,8 +175,9 @@ export function runScriptGateAfterInstall(options) {
158
175
  recordScriptObservations({
159
176
  projectDir,
160
177
  wanters,
161
- createIfMissing: config.scriptGate.observe,
162
- now: options.now ?? new Date()
178
+ createIfMissing: config.scriptGate.persistObservations,
179
+ now: options.now ?? new Date(),
180
+ env: options.env ?? process.env
163
181
  });
164
182
  return scriptGateReportLine({ manager: classification.manager, wanters });
165
183
  }
@@ -0,0 +1,9 @@
1
+ // Single CSV-cell escaper for every exporter (SBOM, licenses, scan TUI) so the
2
+ // formula-injection guard and the delimiter quoting can never drift apart again.
3
+ // A cell whose first character is = + - @ (or TAB/CR) is run as a formula by
4
+ // Excel / Google Sheets / LibreOffice on open, so prefix a sentinel apostrophe;
5
+ // then quote for the CSV delimiter / quote / newline.
6
+ export function csvCell(value) {
7
+ const guarded = /^[=+\-@\t\r]/u.test(value) ? `'${value}` : value;
8
+ return /[",\n\r]/u.test(guarded) ? `"${guarded.replace(/"/gu, "\"\"")}"` : guarded;
9
+ }
@@ -1,11 +1,20 @@
1
1
  import { stripVTControlCharacters } from "node:util";
2
2
  const CTRL_KEEP_NEWLINE = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
3
3
  const CTRL_ALL = /[\x00-\x1F\x7F-\x9F]/g;
4
+ // Invisible / zero-width / bidi-format Unicode (Trojan-Source + display spoofing):
5
+ // these reorder or hide text on screen without changing its bytes, letting an
6
+ // attacker-named package, license, or finding spoof what the terminal/report shows.
7
+ // Covers zero-width (200B-200D), bidi marks (200E-200F), word-joiner + invisibles
8
+ // (2060-2064), bidi-format overrides (202A-202E, 2066-2069), the Arabic letter mark
9
+ // (061C), and the BOM (FEFF). Single source of truth — the audit detector re-exports
10
+ // this so the strip set and the detect set cannot drift.
11
+ export const INVISIBLE_UNICODE_RE = /[\u200B-\u200F\u2060-\u2064\u202A-\u202E\u2066-\u2069\u061C\uFEFF]/u;
12
+ const INVISIBLE_GLOBAL = new RegExp(INVISIBLE_UNICODE_RE.source, "gu");
4
13
  export function sanitize(s) {
5
- return stripVTControlCharacters(s).replace(CTRL_KEEP_NEWLINE, "");
14
+ return stripVTControlCharacters(s).replace(CTRL_KEEP_NEWLINE, "").replace(INVISIBLE_GLOBAL, "");
6
15
  }
7
16
  export function sanitizeLine(s) {
8
- return stripVTControlCharacters(s).replace(/[\r\n]+/g, " ").replace(CTRL_ALL, "");
17
+ return stripVTControlCharacters(s).replace(/[\r\n]+/g, " ").replace(CTRL_ALL, "").replace(INVISIBLE_GLOBAL, "");
9
18
  }
10
19
  export function sanitizeDeep(value) {
11
20
  if (typeof value === "string") {
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { loadUserConfig } from "../config/settings.js";
7
7
  import { applyTrustInstall, applyTrustUninstall, readCertificateFingerprints, readServiceTrustRecord, renderTrustStorePlanLines, resolveTrustInstallPlan, TrustStoreError, TrustToolMissingError, writeServiceTrustRecord } from "./trust-store.js";
8
8
  import { clearTrustRefreshError, readTrustRefreshError } from "./trust-refresh.js";
9
- import { acquireLockSync, cleanupSessionSync, createSessionSync, preserveCorruptCleanupRegistrySync, resolveDgPaths, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
9
+ import { acquireLockSync, acquireLockSyncWithRetry, cleanupSessionSync, createSessionSync, preserveCorruptCleanupRegistrySync, resolveDgPaths, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
10
10
  export const SERVICE_SENTINEL = "dg-service-mode-v1";
11
11
  export const TRUST_SENTINEL = "dg-service-trust-v1";
12
12
  export const SERVICE_LOCK = "service-control";
@@ -196,7 +196,7 @@ export function startService(env, now = new Date()) {
196
196
  };
197
197
  }
198
198
  cleanupRuntime(paths, existingRuntime ?? current.proxy);
199
- const runtime = startServiceProxyRuntime(paths, env ?? process.env, now);
199
+ const runtime = startServiceProxyRuntime(paths, env ?? process.env);
200
200
  const trustRemoval = removeTrustRecordIfDrifted(paths, current.trust, runtime, now);
201
201
  const state = {
202
202
  ...current,
@@ -255,7 +255,7 @@ export function restartService(env, now = new Date()) {
255
255
  throw new ServiceNotConfiguredError();
256
256
  }
257
257
  cleanupRuntime(paths, readRuntime(paths) ?? current.proxy);
258
- const runtime = startServiceProxyRuntime(paths, env ?? process.env, now);
258
+ const runtime = startServiceProxyRuntime(paths, env ?? process.env);
259
259
  const trustRemoval = removeTrustRecordIfDrifted(paths, current.trust, runtime, now);
260
260
  const state = {
261
261
  ...current,
@@ -313,6 +313,20 @@ export function installServiceTrust(env, now = new Date()) {
313
313
  trustStoreOperation(() => applyTrustUninstall(existing));
314
314
  }
315
315
  const record = trustStoreOperation(() => applyTrustInstall(plan, now, TRUST_SENTINEL));
316
+ try {
317
+ writeServiceTrustRecord(paths.trustRecordPath, record);
318
+ }
319
+ catch (error) {
320
+ // The persisted record is what every uninstall path keys on to remove the CA;
321
+ // a failed write must not leave the OS trusting a CA dg can no longer find.
322
+ try {
323
+ applyTrustUninstall(record);
324
+ }
325
+ catch {
326
+ // rollback is best-effort; the original write failure is what propagates
327
+ }
328
+ throw error;
329
+ }
316
330
  clearTrustRefreshError(paths.serviceDir);
317
331
  const state = {
318
332
  ...current,
@@ -321,7 +335,6 @@ export function installServiceTrust(env, now = new Date()) {
321
335
  trustDrift: undefined,
322
336
  trustInstalledAt: current.trustInstalledAt ?? now.toISOString()
323
337
  };
324
- writeServiceTrustRecord(paths.trustRecordPath, record);
325
338
  writeState(paths, state);
326
339
  writeLog(paths, "service.trust.installed", now);
327
340
  withRegistryLock(paths.paths, () => {
@@ -477,6 +490,21 @@ export function withServiceLock(paths, run) {
477
490
  lock.release();
478
491
  }
479
492
  }
493
+ // Retry variant for callbacks that must NOT be dropped on transient contention —
494
+ // e.g. the CA-rotation trust refresh, which silently breaks TLS interception if
495
+ // it loses the lock and gives up.
496
+ export function withServiceLockRetry(paths, run) {
497
+ const lock = acquireLockSyncWithRetry(paths.paths, SERVICE_LOCK, {
498
+ staleMs: SERVICE_LOCK_STALE_MS,
499
+ timeoutMs: 5_000
500
+ });
501
+ try {
502
+ return run();
503
+ }
504
+ finally {
505
+ lock.release();
506
+ }
507
+ }
480
508
  function withRegistryLock(paths, run) {
481
509
  const lock = acquireLockSync(paths, CLEANUP_REGISTRY_LOCK, {
482
510
  staleMs: SERVICE_LOCK_STALE_MS
@@ -554,10 +582,13 @@ function withTrustRefreshError(paths, state) {
554
582
  if (!refreshError) {
555
583
  return state;
556
584
  }
585
+ // Surface the trust-refresh failure even when another lastError is already set
586
+ // (a `??` here would silently drop it — and a broken CA trust is exactly the
587
+ // failure the user must see to repair TLS interception).
588
+ const refreshMessage = `service CA trust refresh failed at ${refreshError.at}: ${refreshError.message}. Run 'dg service trust install --yes' to repair OS trust for the rotated CA.`;
557
589
  return {
558
590
  ...state,
559
- lastError: state.lastError
560
- ?? `service CA trust refresh failed at ${refreshError.at}: ${refreshError.message}. Run 'dg service trust install --yes' to repair OS trust for the rotated CA.`
591
+ lastError: state.lastError ? `${state.lastError}; ${refreshMessage}` : refreshMessage
561
592
  };
562
593
  }
563
594
  function runtimeHealth(runtime) {
@@ -567,35 +598,72 @@ function runtimeHealth(runtime) {
567
598
  reason: `recorded service worker pid ${runtime.pid} is not running`
568
599
  };
569
600
  }
570
- if (!healthEndpointReachable(runtime.healthUrl)) {
571
- return {
572
- healthy: false,
573
- reason: `health endpoint is unreachable at ${runtime.healthUrl}`
574
- };
575
- }
576
- return {
577
- healthy: true,
578
- reason: undefined
579
- };
601
+ return healthEndpointReachable(runtime);
580
602
  }
581
- function healthEndpointReachable(healthUrl) {
603
+ function healthEndpointReachable(runtime) {
604
+ const expectedProxyPort = servicePortFromUrl(runtime.proxyUrl);
582
605
  const script = `
583
606
  const http = require("node:http");
584
607
  const https = require("node:https");
585
608
  const target = process.argv[1];
609
+ const expectedPid = Number(process.argv[2]);
610
+ const expectedProxyPort = Number(process.argv[3]);
586
611
  const client = target.startsWith("https:") ? https : http;
587
612
  const request = client.get(target, { timeout: 500 }, (response) => {
588
- response.resume();
589
- response.on("end", () => process.exit(response.statusCode === 200 ? 0 : 1));
613
+ if (response.statusCode !== 200) {
614
+ response.resume();
615
+ response.on("end", () => process.exit(1));
616
+ return;
617
+ }
618
+ let body = "";
619
+ response.setEncoding("utf8");
620
+ response.on("data", (chunk) => {
621
+ if (body.length < 65536) {
622
+ body += chunk;
623
+ }
624
+ });
625
+ response.on("end", () => {
626
+ try {
627
+ const parsed = JSON.parse(body);
628
+ const identified = parsed && parsed.ok === true && parsed.pid === expectedPid && parsed.proxyPort === expectedProxyPort;
629
+ process.exit(identified ? 0 : 2);
630
+ } catch {
631
+ process.exit(2);
632
+ }
633
+ });
590
634
  });
591
635
  request.on("timeout", () => request.destroy(new Error("timeout")));
592
636
  request.on("error", () => process.exit(1));
593
637
  `;
594
- const result = spawnSync(process.execPath, ["-e", script, healthUrl], {
638
+ const result = spawnSync(process.execPath, ["-e", script, runtime.healthUrl, String(runtime.pid), String(expectedProxyPort)], {
595
639
  stdio: "ignore",
596
640
  timeout: 1_000
597
641
  });
598
- return result.status === 0;
642
+ if (result.status === 0) {
643
+ return {
644
+ healthy: true,
645
+ reason: undefined
646
+ };
647
+ }
648
+ if (result.status === 2) {
649
+ return {
650
+ healthy: false,
651
+ reason: `health endpoint at ${runtime.healthUrl} did not identify the dg service worker (expected pid ${runtime.pid})`
652
+ };
653
+ }
654
+ return {
655
+ healthy: false,
656
+ reason: `health endpoint is unreachable at ${runtime.healthUrl}`
657
+ };
658
+ }
659
+ function servicePortFromUrl(url) {
660
+ try {
661
+ const { port } = new URL(url);
662
+ return port ? Number(port) : Number.NaN;
663
+ }
664
+ catch {
665
+ return Number.NaN;
666
+ }
599
667
  }
600
668
  function detectTrustDrift(trust, runtime) {
601
669
  if (!trust || !runtime?.caPath || !existsSync(runtime.caPath)) {
@@ -628,6 +696,15 @@ function removeTrustRecordIfDrifted(paths, trust, runtime, now) {
628
696
  message: undefined
629
697
  };
630
698
  }
699
+ if (drift.activeFingerprintSha256 === undefined) {
700
+ // The active CA is merely unreadable (a transient IO / restart race), not a
701
+ // confirmed mismatch — surface the message but do NOT tear down the user's OS
702
+ // trust, which would force a manual reinstall over a recoverable condition.
703
+ return {
704
+ removed: false,
705
+ message: drift.message
706
+ };
707
+ }
631
708
  trustStoreOperation(() => applyTrustUninstall(trust));
632
709
  removeFile(paths.trustRecordPath);
633
710
  withRegistryLock(paths.paths, () => {
@@ -639,8 +716,13 @@ function removeTrustRecordIfDrifted(paths, trust, runtime, now) {
639
716
  message: `${drift.message} The stale dg-owned trust record was removed during service restart.`
640
717
  };
641
718
  }
642
- function startServiceProxyRuntime(paths, env, now) {
643
- const workerPath = env.DG_SERVICE_WORKER_PATH ?? fileURLToPath(new URL("./worker.js", import.meta.url));
719
+ function startServiceProxyRuntime(paths, env) {
720
+ // DG_SERVICE_WORKER_PATH is a test seam (point at a stub worker). Honouring an
721
+ // arbitrary path from the ambient environment in production is an arbitrary-code
722
+ // -execution vector, so the override is accepted only under NODE_ENV=test.
723
+ const workerPath = env.NODE_ENV === "test" && env.DG_SERVICE_WORKER_PATH
724
+ ? env.DG_SERVICE_WORKER_PATH
725
+ : fileURLToPath(new URL("./worker.js", import.meta.url));
644
726
  if (!existsSync(workerPath)) {
645
727
  throw new ServiceProxyError("service proxy worker is unavailable until the CLI package is built");
646
728
  }
@@ -651,18 +733,23 @@ function startServiceProxyRuntime(paths, env, now) {
651
733
  mode: 0o600
652
734
  });
653
735
  const config = loadUserConfig(env);
736
+ const childEnv = {
737
+ ...env,
738
+ DG_SERVICE_CLASSIFICATION: JSON.stringify({
739
+ kind: "protected",
740
+ manager: "npm",
741
+ realBinaryName: "npm",
742
+ action: "service-proxy",
743
+ args: []
744
+ })
745
+ };
746
+ // The service worker gates every install for the daemon's lifetime; it must
747
+ // never inherit a --dg-force-install request from whatever environment happened
748
+ // to start the service, which would silently downgrade every block.
749
+ delete childEnv.DG_FORCE_OVERRIDE_REQUEST;
654
750
  const child = spawn(process.execPath, [workerPath, sessionBootstrapPath, config.api.baseUrl, paths.runtimePath], {
655
751
  detached: true,
656
- env: {
657
- ...env,
658
- DG_SERVICE_CLASSIFICATION: JSON.stringify({
659
- kind: "protected",
660
- manager: "npm",
661
- realBinaryName: "npm",
662
- action: "service-proxy",
663
- args: []
664
- })
665
- },
752
+ env: childEnv,
666
753
  stdio: "ignore"
667
754
  });
668
755
  child.unref();
@@ -674,7 +761,6 @@ function startServiceProxyRuntime(paths, env, now) {
674
761
  cleanupSessionSync(session);
675
762
  throw new ServiceProxyError("service proxy worker did not become healthy");
676
763
  }
677
- void now;
678
764
  return runtime;
679
765
  }
680
766
  function waitForRuntime(paths, pid) {
@@ -823,9 +909,6 @@ function writeRegistry(paths, registry) {
823
909
  }
824
910
  function writeRegistryIfChanged(paths, registry, targets) {
825
911
  const next = removeRegistryTargets(registry, targets);
826
- if (!existsSync(paths.cleanupRegistryPath) && registry.entries.length === next.entries.length) {
827
- return;
828
- }
829
912
  if (registry.entries.length === next.entries.length) {
830
913
  return;
831
914
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { applyTrustInstall, applyTrustUninstall, readServiceTrustRecord, resolveTrustInstallPlan, writeServiceTrustRecord } from "./trust-store.js";
3
+ import { applyTrustInstall, applyTrustUninstall, reconcileLinuxTrustStore, readServiceTrustRecord, resolveTrustInstallPlan, writeServiceTrustRecord } from "./trust-store.js";
4
4
  export const TRUST_REFRESH_ERROR_FILENAME = "trust-refresh-error.json";
5
5
  export function trustRefreshErrorPath(serviceDir) {
6
6
  return join(serviceDir, TRUST_REFRESH_ERROR_FILENAME);
@@ -58,6 +58,7 @@ function sweepStaleDgTrustFiles(provider, targetPath) {
58
58
  catch {
59
59
  return;
60
60
  }
61
+ let removedAny = false;
61
62
  for (const entry of entries) {
62
63
  if (!/^dependency-guardian-[0-9a-f]{16}\.(pem|crt)$/.test(entry)) {
63
64
  continue;
@@ -69,6 +70,16 @@ function sweepStaleDgTrustFiles(provider, targetPath) {
69
70
  rmSync(path, {
70
71
  force: true
71
72
  });
73
+ removedAny = true;
74
+ }
75
+ if (removedAny && provider === "linux-system-ca") {
76
+ try {
77
+ reconcileLinuxTrustStore();
78
+ }
79
+ catch {
80
+ // best-effort: the new cert is already installed+trusted; a failed reconcile
81
+ // only delays pruning the swept-out roots until the next rotation.
82
+ }
72
83
  }
73
84
  }
74
85
  function writeTrustRefreshError(serviceDir, error, now) {
@@ -1,6 +1,6 @@
1
1
  import { X509Certificate } from "node:crypto";
2
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
- import { homedir } from "node:os";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { writeJsonAtomic } from "../util/json-file.js";
@@ -92,6 +92,20 @@ export function renderTrustStorePlanLines(plan) {
92
92
  ];
93
93
  return plan.reason ? [...lines, `support note: ${plan.reason}`] : lines;
94
94
  }
95
+ // Install ONLY the active (first) cert from the on-disk rotation bundle (which
96
+ // holds new+previous certs). Copying the whole bundle would keep every rotated-out
97
+ // CA trusted, defeating the ephemeral-CA model. Remove any existing target first
98
+ // (rm does not follow a symlink) then create with O_EXCL so a pre-planted symlink
99
+ // can't redirect this (root-privileged) write to an arbitrary path.
100
+ function extractActiveCert(srcBundlePath) {
101
+ const bundle = readFileSync(srcBundlePath, "utf8");
102
+ const match = bundle.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----\s*/);
103
+ return match ? match[0] : bundle;
104
+ }
105
+ function installActiveCert(srcBundlePath, target) {
106
+ rmSync(target, { force: true });
107
+ writeFileSync(target, extractActiveCert(srcBundlePath), { mode: 0o644, flag: "wx" });
108
+ }
95
109
  export function applyTrustInstall(plan, installedAt, sentinel) {
96
110
  if (!plan.supported || plan.provider === "unsupported") {
97
111
  throw new TrustStoreError(plan.reason ?? "Service trust-store installation is unsupported on this platform.");
@@ -101,17 +115,28 @@ export function applyTrustInstall(plan, installedAt, sentinel) {
101
115
  recursive: true,
102
116
  mode: 0o700
103
117
  });
104
- copyFileSync(plan.caPath, plan.target);
118
+ installActiveCert(plan.caPath, plan.target);
105
119
  }
106
120
  else if (plan.provider === "darwin-user-keychain") {
107
- runNativeCommand(plan.installCommand, "macOS user keychain trust installation failed");
121
+ // `security add-trusted-cert` on the multi-cert rotation bundle would trust
122
+ // EVERY rotated-out CA too, accumulating perpetual trusted roots. Materialize
123
+ // and trust ONLY the active cert (the command's last arg is the bundle path).
124
+ const dir = mkdtempSync(join(tmpdir(), "dg-trust-"));
125
+ const activeCertPath = join(dir, "active.pem");
126
+ try {
127
+ writeFileSync(activeCertPath, extractActiveCert(plan.caPath), { mode: 0o600 });
128
+ runNativeCommand([...plan.installCommand.slice(0, -1), activeCertPath], "macOS user keychain trust installation failed");
129
+ }
130
+ finally {
131
+ rmSync(dir, { recursive: true, force: true });
132
+ }
108
133
  }
109
134
  else {
110
135
  mkdirSync(dirname(plan.target), {
111
136
  recursive: true,
112
137
  mode: 0o755
113
138
  });
114
- copyFileSync(plan.caPath, plan.target);
139
+ installActiveCert(plan.caPath, plan.target);
115
140
  try {
116
141
  runNativeCommand(["update-ca-certificates"], "Linux trust-store refresh failed");
117
142
  }
@@ -230,6 +255,12 @@ function normalizeFingerprint(value) {
230
255
  function isTrustStoreProvider(value) {
231
256
  return value === "darwin-user-keychain" || value === "linux-system-ca" || value === "file";
232
257
  }
258
+ export function reconcileLinuxTrustStore() {
259
+ // Re-run update-ca-certificates so hashed symlinks / ca-certificates.crt entries
260
+ // for source files removed during a sweep are actually pruned (removing the .crt
261
+ // alone does not untrust the root until this runs).
262
+ runNativeCommand(["update-ca-certificates"], "Linux trust-store reconcile failed");
263
+ }
233
264
  function runNativeCommand(command, failureMessage) {
234
265
  const [program, ...args] = command;
235
266
  if (!program) {
@@ -1,8 +1,9 @@
1
1
  import { createServer } from "node:http";
2
2
  import { readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { startProductionHttpProxy } from "../proxy/server.js";
4
- import { resolveServicePaths, TRUST_SENTINEL, withServiceLock } from "./state.js";
4
+ import { resolveServicePaths, TRUST_SENTINEL, withServiceLockRetry } from "./state.js";
5
5
  import { refreshServiceTrustAfterCaRotation } from "./trust-refresh.js";
6
+ import { redactSecrets } from "../launcher/output-redaction.js";
6
7
  const sessionPath = process.argv[2];
7
8
  const apiBaseUrl = process.argv[3];
8
9
  const runtimePath = process.argv[4];
@@ -30,9 +31,6 @@ async function close() {
30
31
  });
31
32
  await Promise.all([proxy?.close(), closeHealthServer(healthServer)]);
32
33
  }
33
- process.stdin.on("end", () => {
34
- close().finally(() => process.exit(0));
35
- });
36
34
  process.on("SIGTERM", () => {
37
35
  close().finally(() => process.exit(0));
38
36
  });
@@ -42,7 +40,7 @@ process.on("SIGINT", () => {
42
40
  function exitOnFatal(error) {
43
41
  const message = error instanceof Error ? error.message : String(error);
44
42
  try {
45
- process.stderr.write(`dg service worker: unexpected error — ${message}\n`);
43
+ process.stderr.write(`dg service worker: unexpected error — ${redactSecrets(message)}\n`);
46
44
  }
47
45
  catch {
48
46
  // stderr is gone; nothing left to report to.
@@ -50,14 +48,14 @@ function exitOnFatal(error) {
50
48
  close().finally(() => process.exit(1));
51
49
  }
52
50
  process.on("uncaughtException", exitOnFatal);
53
- process.on("unhandledRejection", exitOnFatal);
51
+ process.on("unhandledRejection", (reason) => exitOnFatal(reason));
54
52
  const servicePaths = resolveServicePaths(process.env);
55
53
  proxy = await startProductionHttpProxy({
56
54
  session,
57
55
  apiBaseUrl: requiredApiBaseUrl,
58
56
  classification,
59
57
  env: process.env,
60
- onCaRotate: () => withServiceLock(servicePaths, () => refreshServiceTrustAfterCaRotation({
58
+ onCaRotate: () => withServiceLockRetry(servicePaths, () => refreshServiceTrustAfterCaRotation({
61
59
  serviceDir: servicePaths.serviceDir,
62
60
  trustRecordPath: servicePaths.trustRecordPath,
63
61
  sentinel: TRUST_SENTINEL,
@@ -28,7 +28,7 @@ export function resolveGitRepo(options = {}) {
28
28
  const cwd = options.cwd ?? process.cwd();
29
29
  const inside = gitTrimmed(["rev-parse", "--is-inside-work-tree"], { cwd, env });
30
30
  if (inside !== "true") {
31
- return { error: "not a git repository — run dg guard-commit inside a repo" };
31
+ return { error: "not a git repository — run dg commit-guard inside a repo" };
32
32
  }
33
33
  const root = gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
34
34
  if (!root) {
@@ -113,7 +113,7 @@ export function applyGitHook(context, now = new Date()) {
113
113
  {
114
114
  name: "hook-writable",
115
115
  ok: false,
116
- detail: `${context.hookTarget} is a symlink; dg refuses to replace it — remove the link or point it at a real file, then re-run dg guard-commit`
116
+ detail: `${context.hookTarget} is a symlink; dg refuses to replace it — remove the link or point it at a real file, then re-run dg commit-guard`
117
117
  }
118
118
  ],
119
119
  active: false