@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,10 +1,11 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, isAbsolute, join, relative } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
  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
+ import { clearTrustRefreshError, readTrustRefreshError } from "./trust-refresh.js";
8
9
  import { acquireLockSync, cleanupSessionSync, createSessionSync, preserveCorruptCleanupRegistrySync, resolveDgPaths, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
9
10
  export const SERVICE_SENTINEL = "dg-service-mode-v1";
10
11
  export const TRUST_SENTINEL = "dg-service-trust-v1";
@@ -292,6 +293,7 @@ export function installServiceTrust(env, now = new Date()) {
292
293
  const plan = trustStoreOperation(() => resolveTrustInstallPlan(runtime.caPath, env ?? process.env));
293
294
  const existing = readServiceTrustRecord(paths.trustRecordPath, TRUST_SENTINEL);
294
295
  if (existing && existing.fingerprintSha256 === plan.fingerprintSha256 && existing.provider === plan.provider && existing.target === plan.target) {
296
+ clearTrustRefreshError(paths.serviceDir);
295
297
  const state = {
296
298
  ...current,
297
299
  trustInstalled: true,
@@ -311,6 +313,7 @@ export function installServiceTrust(env, now = new Date()) {
311
313
  trustStoreOperation(() => applyTrustUninstall(existing));
312
314
  }
313
315
  const record = trustStoreOperation(() => applyTrustInstall(plan, now, TRUST_SENTINEL));
316
+ clearTrustRefreshError(paths.serviceDir);
314
317
  const state = {
315
318
  ...current,
316
319
  trustInstalled: true,
@@ -339,6 +342,7 @@ export function uninstallServiceTrust(env, now = new Date()) {
339
342
  if (record) {
340
343
  trustStoreOperation(() => applyTrustUninstall(record));
341
344
  }
345
+ clearTrustRefreshError(paths.serviceDir);
342
346
  const removedTrust = removeFile(paths.trustRecordPath);
343
347
  if (!current.configured && !removedTrust) {
344
348
  return {
@@ -518,31 +522,42 @@ function readState(paths) {
518
522
  function applyRuntimeDiagnostics(paths, state) {
519
523
  const runtime = readRuntime(paths) ?? state.proxy;
520
524
  if (!runtime) {
521
- return state;
525
+ return withTrustRefreshError(paths, state);
522
526
  }
523
527
  const drift = detectTrustDrift(state.trust, runtime);
524
528
  if (!state.running) {
525
- return {
529
+ return withTrustRefreshError(paths, {
526
530
  ...state,
527
531
  proxy: runtime,
528
532
  trustDrift: drift,
529
533
  lastError: "stale service runtime state: runtime file exists while service is stopped"
530
- };
534
+ });
531
535
  }
532
536
  const health = runtimeHealth(runtime);
533
537
  if (!health.healthy) {
534
- return {
538
+ return withTrustRefreshError(paths, {
535
539
  ...state,
536
540
  running: false,
537
541
  proxy: runtime,
538
542
  trustDrift: drift,
539
543
  lastError: `stale service runtime state: ${health.reason}`
540
- };
544
+ });
541
545
  }
542
- return {
546
+ return withTrustRefreshError(paths, {
543
547
  ...state,
544
548
  proxy: runtime,
545
549
  trustDrift: drift
550
+ });
551
+ }
552
+ function withTrustRefreshError(paths, state) {
553
+ const refreshError = readTrustRefreshError(paths.serviceDir);
554
+ if (!refreshError) {
555
+ return state;
556
+ }
557
+ return {
558
+ ...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.`
546
561
  };
547
562
  }
548
563
  function runtimeHealth(runtime) {
@@ -711,7 +726,7 @@ function cleanupRuntime(paths, runtime) {
711
726
  if (runtime?.pid && processIsAlive(runtime.pid)) {
712
727
  killProcess(runtime.pid);
713
728
  }
714
- if (runtime?.sessionDir && runtime.sessionDir.startsWith(paths.paths.sessionsDir)) {
729
+ if (runtime?.sessionDir && isContainedIn(paths.paths.sessionsDir, runtime.sessionDir)) {
715
730
  rmSync(runtime.sessionDir, {
716
731
  force: true,
717
732
  recursive: true
@@ -719,6 +734,10 @@ function cleanupRuntime(paths, runtime) {
719
734
  }
720
735
  removeFile(paths.runtimePath);
721
736
  }
737
+ function isContainedIn(parent, child) {
738
+ const related = relative(parent, child);
739
+ return related.length > 0 && !related.startsWith("..") && !isAbsolute(related);
740
+ }
722
741
  function processIsAlive(pid) {
723
742
  if (pid <= 0) {
724
743
  return false;
@@ -0,0 +1,92 @@
1
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { applyTrustInstall, applyTrustUninstall, readServiceTrustRecord, resolveTrustInstallPlan, writeServiceTrustRecord } from "./trust-store.js";
4
+ export const TRUST_REFRESH_ERROR_FILENAME = "trust-refresh-error.json";
5
+ export function trustRefreshErrorPath(serviceDir) {
6
+ return join(serviceDir, TRUST_REFRESH_ERROR_FILENAME);
7
+ }
8
+ export function readTrustRefreshError(serviceDir) {
9
+ try {
10
+ const path = trustRefreshErrorPath(serviceDir);
11
+ if (!existsSync(path)) {
12
+ return undefined;
13
+ }
14
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
15
+ if (typeof parsed.at !== "string" || typeof parsed.message !== "string") {
16
+ return undefined;
17
+ }
18
+ return {
19
+ at: parsed.at,
20
+ message: parsed.message
21
+ };
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ export function clearTrustRefreshError(serviceDir) {
28
+ rmSync(trustRefreshErrorPath(serviceDir), {
29
+ force: true
30
+ });
31
+ }
32
+ export function refreshServiceTrustAfterCaRotation(options) {
33
+ const record = readServiceTrustRecord(options.trustRecordPath, options.sentinel);
34
+ if (!record) {
35
+ return;
36
+ }
37
+ try {
38
+ const plan = resolveTrustInstallPlan(options.caPath, options.env);
39
+ applyTrustUninstall(record);
40
+ const next = applyTrustInstall(plan, options.now ?? new Date(), options.sentinel);
41
+ sweepStaleDgTrustFiles(next.provider, next.target);
42
+ writeServiceTrustRecord(options.trustRecordPath, next);
43
+ clearTrustRefreshError(options.serviceDir);
44
+ }
45
+ catch (error) {
46
+ writeTrustRefreshError(options.serviceDir, error, options.now ?? new Date());
47
+ }
48
+ }
49
+ function sweepStaleDgTrustFiles(provider, targetPath) {
50
+ if (provider === "darwin-user-keychain") {
51
+ return;
52
+ }
53
+ const dir = dirname(targetPath);
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(dir);
57
+ }
58
+ catch {
59
+ return;
60
+ }
61
+ for (const entry of entries) {
62
+ if (!/^dependency-guardian-[0-9a-f]{16}\.(pem|crt)$/.test(entry)) {
63
+ continue;
64
+ }
65
+ const path = join(dir, entry);
66
+ if (path === targetPath) {
67
+ continue;
68
+ }
69
+ rmSync(path, {
70
+ force: true
71
+ });
72
+ }
73
+ }
74
+ function writeTrustRefreshError(serviceDir, error, now) {
75
+ try {
76
+ mkdirSync(serviceDir, {
77
+ recursive: true,
78
+ mode: 0o700
79
+ });
80
+ const record = {
81
+ at: now.toISOString(),
82
+ message: error instanceof Error ? error.message : String(error)
83
+ };
84
+ writeFileSync(trustRefreshErrorPath(serviceDir), `${JSON.stringify(record, null, 2)}\n`, {
85
+ encoding: "utf8",
86
+ mode: 0o600
87
+ });
88
+ }
89
+ catch {
90
+ return;
91
+ }
92
+ }
@@ -1,6 +1,8 @@
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 } from "./state.js";
5
+ import { refreshServiceTrustAfterCaRotation } from "./trust-refresh.js";
4
6
  const sessionPath = process.argv[2];
5
7
  const apiBaseUrl = process.argv[3];
6
8
  const runtimePath = process.argv[4];
@@ -37,11 +39,31 @@ process.on("SIGTERM", () => {
37
39
  process.on("SIGINT", () => {
38
40
  close().finally(() => process.exit(0));
39
41
  });
42
+ function exitOnFatal(error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ try {
45
+ process.stderr.write(`dg service worker: unexpected error — ${message}\n`);
46
+ }
47
+ catch {
48
+ // stderr is gone; nothing left to report to.
49
+ }
50
+ close().finally(() => process.exit(1));
51
+ }
52
+ process.on("uncaughtException", exitOnFatal);
53
+ process.on("unhandledRejection", exitOnFatal);
54
+ const servicePaths = resolveServicePaths(process.env);
40
55
  proxy = await startProductionHttpProxy({
41
56
  session,
42
57
  apiBaseUrl: requiredApiBaseUrl,
43
58
  classification,
44
- env: process.env
59
+ env: process.env,
60
+ onCaRotate: () => refreshServiceTrustAfterCaRotation({
61
+ serviceDir: servicePaths.serviceDir,
62
+ trustRecordPath: servicePaths.trustRecordPath,
63
+ sentinel: TRUST_SENTINEL,
64
+ caPath: session.files.ca,
65
+ env: process.env
66
+ })
45
67
  });
46
68
  healthServer = await startHealthServer(proxy.port);
47
69
  const healthAddress = healthServer.address();
@@ -0,0 +1,28 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ import { isCiEnv } from "../presentation/mode.js";
4
+ export function activationOffer(options = {}) {
5
+ const env = options.env ?? process.env;
6
+ if (env.DG_ACTIVATED_SHELL) {
7
+ return "none";
8
+ }
9
+ const stdin = options.stdin ?? process.stdin;
10
+ const stdout = options.stdout ?? process.stdout;
11
+ if (!stdin.isTTY || !stdout.isTTY || isCiEnv(env)) {
12
+ return "hint";
13
+ }
14
+ return "prompt";
15
+ }
16
+ // dg cannot mutate the parent shell's environment, so activation nests a protected interactive child shell.
17
+ export function activateShell(options = {}) {
18
+ const env = options.env ?? process.env;
19
+ const spawner = options.spawner ?? defaultShellSpawner;
20
+ const binary = env.SHELL || "/bin/sh";
21
+ const name = basename(binary);
22
+ return spawner({
23
+ binary,
24
+ args: name === "bash" || name === "zsh" ? ["-i"] : [],
25
+ env: { ...env, DG_ACTIVATED_SHELL: "1" }
26
+ });
27
+ }
28
+ const defaultShellSpawner = (request) => spawnSync(request.binary, request.args, { stdio: "inherit", env: request.env }).status ?? 0;
@@ -1,8 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
- import { isAbsolute, join, resolve, sep } from "node:path";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { acquireLockSync, CLEANUP_REGISTRY_LOCK, resolveDgPaths } from "../state/index.js";
6
+ import { resolveToolPath } from "../util/external-tool.js";
6
7
  import { gitTrimmed } from "../util/git.js";
7
8
  import { GUARD_HOOK_SENTINEL, SETUP_UNINSTALL_LOCK, SETUP_UNINSTALL_LOCK_STALE_MS, chainedHookOriginal, guardHookScript, mergeRegistry, readRegistry, reverseGitHookEntry, writeRegistry } from "./plan.js";
8
9
  export { GUARD_HOOK_SENTINEL } from "./plan.js";
@@ -84,7 +85,40 @@ function registryOriginal(context) {
84
85
  const entry = readRegistry(context.paths).registry.entries.find((candidate) => candidate.kind === "git-hook" && candidate.owner === "dg" && candidate.path === context.hookTarget);
85
86
  return entry?.original ?? null;
86
87
  }
88
+ function isSymlinkPath(path) {
89
+ try {
90
+ return lstatSync(path).isSymbolicLink();
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ function writeHookAtomic(target, content) {
97
+ const tmp = join(dirname(target), `.pre-commit.dg-${randomBytes(6).toString("hex")}.tmp`);
98
+ writeFileSync(tmp, content, { encoding: "utf8", mode: 0o755, flag: "wx" });
99
+ try {
100
+ renameSync(tmp, target);
101
+ }
102
+ catch (error) {
103
+ rmSync(tmp, { force: true });
104
+ throw error;
105
+ }
106
+ }
87
107
  export function applyGitHook(context, now = new Date()) {
108
+ if (gitHookState(context) !== "foreign" && isSymlinkPath(context.hookTarget)) {
109
+ return {
110
+ hookTarget: context.hookTarget,
111
+ chainedOriginal: null,
112
+ checks: [
113
+ {
114
+ name: "hook-writable",
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`
117
+ }
118
+ ],
119
+ active: false
120
+ };
121
+ }
88
122
  const lock = acquireLockSync(context.paths, SETUP_UNINSTALL_LOCK, { staleMs: SETUP_UNINSTALL_LOCK_STALE_MS });
89
123
  let chainedOriginal = null;
90
124
  try {
@@ -98,7 +132,7 @@ export function applyGitHook(context, now = new Date()) {
98
132
  else if (state === "managed") {
99
133
  chainedOriginal = chainedHookOriginal(hookText(context.hookTarget)) ?? registryOriginal(context);
100
134
  }
101
- writeFileSync(context.hookTarget, guardHookScript(context.dgPath, chainedOriginal), { encoding: "utf8", mode: 0o755 });
135
+ writeHookAtomic(context.hookTarget, guardHookScript(context.dgPath, chainedOriginal));
102
136
  chmodSync(context.hookTarget, 0o755);
103
137
  const entry = {
104
138
  kind: "git-hook",
@@ -176,7 +210,11 @@ function runSelfTest(context) {
176
210
  if (!existsSync(context.hookTarget) || !isManaged(context.hookTarget)) {
177
211
  return { ok: false, detail: "no dg hook to exercise" };
178
212
  }
179
- const result = spawnSync("sh", [context.hookTarget], {
213
+ const sh = resolveToolPath("sh", context.env);
214
+ if (!sh) {
215
+ return { ok: false, detail: "sh not found on PATH; hook not exercised" };
216
+ }
217
+ const result = spawnSync(sh, [context.hookTarget], {
180
218
  cwd: context.root,
181
219
  env: { ...context.env, [GUARD_SELFTEST_ENV]: "1" },
182
220
  encoding: "utf8",
@@ -219,6 +257,13 @@ export function gitHookStatusState(options = {}) {
219
257
  const installedHere = registry.entries.some((entry) => entry.kind === "git-hook" && entry.owner === "dg" && isUnderRoot(entry.path, context.root));
220
258
  return installedHere ? "dead" : "off";
221
259
  }
260
+ export function commitGuardOffer(options = {}) {
261
+ const resolved = resolveGitRepo(options);
262
+ if ("error" in resolved) {
263
+ return null;
264
+ }
265
+ return gitHookStatusState(options) === "off" ? resolved : null;
266
+ }
222
267
  export function removeGitHookForRepo(context) {
223
268
  const lock = acquireLockSync(context.paths, SETUP_UNINSTALL_LOCK, { staleMs: SETUP_UNINSTALL_LOCK_STALE_MS });
224
269
  const removed = [];
@@ -1,13 +1,17 @@
1
- import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
3
3
  import { chmodSync } from "node:fs";
4
+ import { randomBytes } from "node:crypto";
5
+ import { homedir } from "node:os";
4
6
  import { createTheme } from "../presentation/theme.js";
7
+ import { reverseAgentHookEntry } from "../agents/registry.js";
5
8
  import { acquireLockSync, findStaleSessionsSync, preserveCorruptCleanupRegistrySync, resolveDgPaths, sweepStaleSessionsSync, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
6
9
  import { currentNodeVersion, isSupportedNode } from "../runtime/node-version.js";
7
10
  import { dgVersion } from "../commands/version.js";
8
11
  import { compareVersions, readLatestVersion } from "../commands/update.js";
9
12
  import { AuthError, authStatus, displayTier, readAuthState } from "../auth/store.js";
10
13
  import { ConfigError, loadUserConfig } from "../config/settings.js";
14
+ import { describeCooldownSettings } from "../policy/cooldown.js";
11
15
  import { resolveRealBinary } from "../launcher/resolve-real-binary.js";
12
16
  import { packageManagerNames } from "../launcher/classify.js";
13
17
  import { readServiceState } from "../service/state.js";
@@ -27,6 +31,7 @@ const LEGACY_RC_CANDIDATES = [".zshrc", ".bashrc", ".bash_profile", ".profile",
27
31
  const LEGACY_PYTHON_HOOK_PY = "dg_pip_hook.py";
28
32
  const LEGACY_PYTHON_HOOK_PTH = "dg_pip_hook.pth";
29
33
  const LEGACY_PYTHON_HOOK_MARKER = "Dependency Guardian pip-install interceptor";
34
+ const LEGACY_PYTHON_HOOK_PTH_MARKER = "import dg_pip_hook";
30
35
  export const SETUP_UNINSTALL_LOCK = "setup-uninstall";
31
36
  export const SETUP_UNINSTALL_LOCK_STALE_MS = 30 * 60 * 1000;
32
37
  export const STALE_SESSION_OLDER_THAN_MS = 24 * 60 * 60 * 1000;
@@ -42,7 +47,7 @@ const DOCTOR_GROUP_BY_NAME = {
42
47
  "cleanup-registry-stale-entries": "setup",
43
48
  config: "setup",
44
49
  policy: "setup",
45
- telemetry: "setup",
50
+ "script-gate": "setup",
46
51
  shims: "setup",
47
52
  "shell-rc": "setup",
48
53
  "python-hook-drift": "setup",
@@ -62,7 +67,6 @@ const DOCTOR_FIX_BY_NAME = {
62
67
  "cleanup-registry-stale-entries": "re-run dg setup to refresh",
63
68
  config: "fix or remove ~/.dg, then re-run dg setup",
64
69
  policy: "fix ~/.dg config",
65
- telemetry: "fix ~/.dg config",
66
70
  shims: "dg setup",
67
71
  "shell-rc": "dg setup",
68
72
  "python-hook-drift": "dg uninstall, or re-run dg setup, to remove the stale pip hook",
@@ -154,7 +158,7 @@ export function applySetupPlan(plan, now = new Date()) {
154
158
  recursive: true,
155
159
  mode: 0o700
156
160
  });
157
- writeFileSync(plan.rcPath, withRcBlock(readText(plan.rcPath), plan), "utf8");
161
+ writeRcFileAtomic(plan.rcPath, withRcBlock(readText(plan.rcPath), plan));
158
162
  entries.push(cleanupEntry("rc", plan.rcPath, "mode1", now, RC_SENTINEL));
159
163
  sweepLegacyPythonHooks(plan.paths.homeDir, [], []);
160
164
  const registry = withRegistryLock(plan.paths, () => {
@@ -211,6 +215,9 @@ export function uninstallSetup(options) {
211
215
  else if (entry.kind === "git-hook") {
212
216
  reverseGitHookEntry(entry, removed, missing, warnings);
213
217
  }
218
+ else if (entry.kind === "agent-hook") {
219
+ reverseAgentHookEntry(entry, removed, missing, warnings);
220
+ }
214
221
  }
215
222
  sweepLegacyRcBlocks(paths.homeDir, removed, warnings);
216
223
  sweepLegacyPythonHooks(paths.homeDir, removed, warnings);
@@ -275,7 +282,7 @@ export function doctorReport(options = {}) {
275
282
  checks.push(configCheck(paths, env));
276
283
  checks.push(authCheck(env));
277
284
  checks.push(policyCheck(env));
278
- checks.push(telemetryCheck(env));
285
+ checks.push(scriptGateCheck(env));
279
286
  const missingShims = SHIM_COMMANDS.filter((command) => !validShim(join(shimDir, command), command));
280
287
  checks.push({
281
288
  name: "shims",
@@ -565,6 +572,9 @@ function shellRcPath(homeDir, shell) {
565
572
  }
566
573
  return join(homeDir, ".zshrc");
567
574
  }
575
+ export function tildifyPath(path, home = homedir()) {
576
+ return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
577
+ }
568
578
  export function activationCommand(shell, rcDisplay) {
569
579
  if (shell === "fish") {
570
580
  return `source ${rcDisplay}`;
@@ -644,6 +654,26 @@ function fishRcBlock(shimDir) {
644
654
  function stripRcBlock(existing) {
645
655
  return stripRcBlockDetailed(existing).content;
646
656
  }
657
+ function writeRcFileAtomic(rcPath, content) {
658
+ let target = rcPath;
659
+ let mode = 0o644;
660
+ try {
661
+ target = realpathSync(rcPath);
662
+ mode = statSync(target).mode & 0o7777;
663
+ }
664
+ catch {
665
+ // no existing file: write a fresh rc at the requested path
666
+ }
667
+ const tempPath = join(dirname(target), `.${basename(target)}.dg-${randomBytes(6).toString("hex")}.tmp`);
668
+ writeFileSync(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
669
+ try {
670
+ renameSync(tempPath, target);
671
+ }
672
+ catch (error) {
673
+ rmSync(tempPath, { force: true });
674
+ throw error;
675
+ }
676
+ }
647
677
  // An unterminated begin marker must never strip to EOF: only lines verifiably
648
678
  // written by dg are removed, so user content below a stale marker survives.
649
679
  function stripRcBlockDetailed(existing) {
@@ -693,7 +723,7 @@ function sweepLegacyRcBlocks(homeDir, removed, warnings) {
693
723
  continue;
694
724
  }
695
725
  try {
696
- writeFileSync(rcPath, next, "utf8");
726
+ writeRcFileAtomic(rcPath, next);
697
727
  removed.push(`${rcPath} (legacy dg block)`);
698
728
  }
699
729
  catch (error) {
@@ -701,19 +731,23 @@ function sweepLegacyRcBlocks(homeDir, removed, warnings) {
701
731
  }
702
732
  }
703
733
  }
734
+ function isLegacyPthHook(pthPath) {
735
+ const content = readText(pthPath);
736
+ return content.includes(LEGACY_PYTHON_HOOK_PTH_MARKER) || content.includes(LEGACY_PYTHON_HOOK_MARKER);
737
+ }
704
738
  export function legacyPythonHookSites(homeDir) {
705
- return candidateSitePackagesDirs(homeDir).filter((dir) => existsSync(join(dir, LEGACY_PYTHON_HOOK_PTH)) || readText(join(dir, LEGACY_PYTHON_HOOK_PY)).includes(LEGACY_PYTHON_HOOK_MARKER));
739
+ return candidateSitePackagesDirs(homeDir).filter((dir) => isLegacyPthHook(join(dir, LEGACY_PYTHON_HOOK_PTH)) || readText(join(dir, LEGACY_PYTHON_HOOK_PY)).includes(LEGACY_PYTHON_HOOK_MARKER));
706
740
  }
707
741
  export function sweepLegacyPythonHooks(homeDir, removed, warnings) {
708
742
  for (const dir of candidateSitePackagesDirs(homeDir)) {
709
743
  const pyPath = join(dir, LEGACY_PYTHON_HOOK_PY);
710
744
  const pthPath = join(dir, LEGACY_PYTHON_HOOK_PTH);
711
745
  const pyIsHook = readText(pyPath).includes(LEGACY_PYTHON_HOOK_MARKER);
712
- const pthPresent = existsSync(pthPath);
713
- if (!pyIsHook && !pthPresent) {
746
+ const pthIsHook = isLegacyPthHook(pthPath);
747
+ if (!pyIsHook && !pthIsHook) {
714
748
  continue;
715
749
  }
716
- if (pthPresent) {
750
+ if (pthIsHook) {
717
751
  removePythonHookFile(pthPath, removed, warnings);
718
752
  }
719
753
  if (pyIsHook) {
@@ -870,7 +904,7 @@ function dgPathCheck(env) {
870
904
  };
871
905
  }
872
906
  function currentDgBinaryPath(env) {
873
- const explicit = env.DG_TEST_CURRENT_DG_PATH;
907
+ const explicit = env.NODE_ENV === "test" ? env.DG_TEST_CURRENT_DG_PATH : undefined;
874
908
  if (explicit) {
875
909
  return explicit;
876
910
  }
@@ -1090,7 +1124,7 @@ function policyCheck(env) {
1090
1124
  return {
1091
1125
  name: "policy",
1092
1126
  status: "pass",
1093
- message: `Local policy mode ${config.policy.mode}; project allowlists trusted: ${config.policy.trustProjectAllowlists}`
1127
+ message: `Local policy mode ${config.policy.mode}; project allowlists trusted: ${config.policy.trustProjectAllowlists}; release cooldown ${describeCooldownSettings(config, env)}`
1094
1128
  };
1095
1129
  }
1096
1130
  catch (error) {
@@ -1101,20 +1135,35 @@ function policyCheck(env) {
1101
1135
  };
1102
1136
  }
1103
1137
  }
1104
- function telemetryCheck(env) {
1138
+ function scriptGateCheck(env) {
1105
1139
  try {
1106
- const config = loadUserConfig(env);
1140
+ const mode = loadUserConfig(env).scriptGate.mode;
1141
+ if (mode === "off") {
1142
+ return {
1143
+ name: "script-gate",
1144
+ status: "pass",
1145
+ message: "Install-script gate is off; protected installs do not report script-running packages"
1146
+ };
1147
+ }
1148
+ if (mode === "enforce") {
1149
+ return {
1150
+ name: "script-gate",
1151
+ status: "warn",
1152
+ message: "scriptGate.mode is enforce, but this dg release observes only — installs report script-running packages without blocking",
1153
+ fix: "enforcement ships in an upcoming release; dg config set scriptGate.mode observe clears this warning"
1154
+ };
1155
+ }
1107
1156
  return {
1108
- name: "telemetry",
1157
+ name: "script-gate",
1109
1158
  status: "pass",
1110
- message: config.telemetry.enabled ? "Telemetry is enabled with minimized event fields" : "Telemetry is disabled"
1159
+ message: "Install-script gate observes: protected npm/yarn installs report packages that ran install scripts (pnpm blocks them natively)"
1111
1160
  };
1112
1161
  }
1113
1162
  catch (error) {
1114
1163
  return {
1115
- name: "telemetry",
1164
+ name: "script-gate",
1116
1165
  status: "fail",
1117
- message: error instanceof ConfigError ? error.message : "Unable to read telemetry config"
1166
+ message: error instanceof ConfigError ? error.message : "Unable to read script gate config"
1118
1167
  };
1119
1168
  }
1120
1169
  }
@@ -1123,12 +1172,19 @@ export function writeRegistry(paths, registry) {
1123
1172
  recursive: true,
1124
1173
  mode: 0o700
1125
1174
  });
1126
- const tempPath = `${paths.cleanupRegistryPath}.${process.pid}.tmp`;
1175
+ const tempPath = `${paths.cleanupRegistryPath}.${randomBytes(6).toString("hex")}.tmp`;
1127
1176
  writeFileSync(tempPath, `${JSON.stringify(registry, null, 2)}\n`, {
1128
1177
  encoding: "utf8",
1129
- mode: 0o600
1178
+ mode: 0o600,
1179
+ flag: "wx"
1130
1180
  });
1131
- renameSync(tempPath, paths.cleanupRegistryPath);
1181
+ try {
1182
+ renameSync(tempPath, paths.cleanupRegistryPath);
1183
+ }
1184
+ catch (error) {
1185
+ rmSync(tempPath, { force: true });
1186
+ throw error;
1187
+ }
1132
1188
  }
1133
1189
  function withRegistryLock(paths, action) {
1134
1190
  const lock = acquireLockSync(paths, CLEANUP_REGISTRY_LOCK, {
@@ -1169,7 +1225,13 @@ function removeRcBlock(entry, removed, missing, warnings) {
1169
1225
  return;
1170
1226
  }
1171
1227
  const stripped = stripRcBlockDetailed(existing);
1172
- writeFileSync(entry.path, stripped.content, "utf8");
1228
+ try {
1229
+ writeRcFileAtomic(entry.path, stripped.content);
1230
+ }
1231
+ catch (error) {
1232
+ warnings.push(`could not rewrite shell rc ${entry.path}: ${error instanceof Error ? error.message : "write error"}`);
1233
+ return;
1234
+ }
1173
1235
  if (stripped.repairedLines.length > 0) {
1174
1236
  warnings.push(`repaired an unterminated dg block in ${entry.path}: removed only dg-written lines (${stripped.repairedLines.join(", ")})`);
1175
1237
  }
@@ -1179,28 +1241,35 @@ function removeRcBlock(entry, removed, missing, warnings) {
1179
1241
  // a one-line notice and lets the commit (and any chained hook) proceed instead
1180
1242
  // of blocking every commit with exit 127.
1181
1243
  export function guardHookScript(dgPath, chainedOriginal) {
1244
+ const dg = escapeDoubleQuotedSh(dgPath);
1182
1245
  const lines = [
1183
1246
  "#!/bin/sh",
1184
1247
  `# ${GUARD_HOOK_SENTINEL}`,
1185
- `if command -v "${dgPath}" >/dev/null 2>&1; then`,
1186
- ` "${dgPath}" scan --staged --hook || exit $?`,
1248
+ `if command -v "${dg}" >/dev/null 2>&1; then`,
1249
+ ` "${dg}" scan --staged --hook || exit $?`,
1187
1250
  "else",
1188
- ` echo "dg: pre-commit scan skipped (dg not runnable at ${dgPath}); commit allowed" >&2`,
1251
+ ` echo "dg: pre-commit scan skipped (dg not runnable at ${dg}); commit allowed" >&2`,
1189
1252
  "fi"
1190
1253
  ];
1191
1254
  if (chainedOriginal) {
1192
- lines.push(`[ -x "${chainedOriginal}" ] && exec "${chainedOriginal}" "$@"`);
1255
+ const chained = escapeDoubleQuotedSh(chainedOriginal);
1256
+ lines.push(`[ -x "${chained}" ] && exec "${chained}" "$@"`);
1193
1257
  }
1194
1258
  lines.push("exit 0");
1195
1259
  return `${lines.join("\n")}\n`;
1196
1260
  }
1261
+ function unescapeDoubleQuotedSh(value) {
1262
+ return value.replace(/\\([\\"$`])/g, "$1");
1263
+ }
1197
1264
  export function chainedHookOriginal(content) {
1198
- return content.match(/^\[ -x "(.+)" \] && exec "\1" "\$@"$/m)?.[1] ?? null;
1265
+ const matched = content.match(/^\[ -x "(.+)" \] && exec "\1" "\$@"$/m)?.[1];
1266
+ return matched ? unescapeDoubleQuotedSh(matched) : null;
1199
1267
  }
1200
1268
  export function guardHookDgPath(content) {
1201
- return (content.match(/^if command -v "(.+)" >\/dev\/null 2>&1; then$/m)?.[1] ??
1269
+ const matched = content.match(/^if command -v "(.+)" >\/dev\/null 2>&1; then$/m)?.[1] ??
1202
1270
  content.match(/^"(.+)" scan --staged --hook/m)?.[1] ??
1203
- null);
1271
+ null;
1272
+ return matched ? unescapeDoubleQuotedSh(matched) : null;
1204
1273
  }
1205
1274
  function commitGuardCheck(registry, env) {
1206
1275
  const hooks = registry.entries.filter((entry) => entry.owner === "dg" && entry.kind === "git-hook");