@westbayberry/dg 2.1.0 → 2.3.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 (148) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +123 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +118 -0
  6. package/dist/agents/gate-posture.js +21 -0
  7. package/dist/agents/gemini.js +107 -0
  8. package/dist/agents/persistence.js +351 -0
  9. package/dist/agents/registry.js +128 -0
  10. package/dist/agents/routing.js +118 -0
  11. package/dist/agents/types.js +1 -0
  12. package/dist/agents/windsurf.js +93 -0
  13. package/dist/api/analyze.js +1 -1
  14. package/dist/audit/detectors.js +0 -11
  15. package/dist/audit/events.js +5 -21
  16. package/dist/audit/rules.js +6 -2
  17. package/dist/audit-ui/AuditApp.js +2 -0
  18. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  19. package/dist/audit-ui/export.js +0 -4
  20. package/dist/audit-ui/format.js +0 -3
  21. package/dist/audit-ui/launch.js +10 -1
  22. package/dist/auth/device-login.js +4 -5
  23. package/dist/auth/login-app.js +7 -7
  24. package/dist/auth/store.js +17 -5
  25. package/dist/bin/dg.js +59 -52
  26. package/dist/commands/agents.js +275 -0
  27. package/dist/commands/audit.js +28 -3
  28. package/dist/commands/config.js +15 -7
  29. package/dist/commands/cooldown.js +389 -0
  30. package/dist/commands/decisions.js +23 -9
  31. package/dist/commands/doctor.js +1 -1
  32. package/dist/commands/help.js +1 -1
  33. package/dist/commands/licenses.js +10 -22
  34. package/dist/commands/logout.js +4 -11
  35. package/dist/commands/router.js +6 -4
  36. package/dist/commands/sbom.js +206 -0
  37. package/dist/commands/scan.js +1 -1
  38. package/dist/commands/service.js +32 -13
  39. package/dist/commands/setup.js +196 -27
  40. package/dist/commands/types.js +1 -0
  41. package/dist/commands/uninstall.js +2 -1
  42. package/dist/commands/update.js +17 -8
  43. package/dist/commands/verify.js +8 -5
  44. package/dist/config/settings.js +49 -48
  45. package/dist/decisions/remember-prompt.js +5 -10
  46. package/dist/export-ui/ExportDialog.js +198 -0
  47. package/dist/install-ui/LiveInstall.js +5 -4
  48. package/dist/install-ui/block-render.js +18 -14
  49. package/dist/install-ui/prep-spinner.js +32 -0
  50. package/dist/launcher/agent-check.js +896 -0
  51. package/dist/launcher/agent-hook-exec.js +70 -0
  52. package/dist/launcher/agent-hook-io.js +39 -0
  53. package/dist/launcher/cargo-cache.js +40 -0
  54. package/dist/launcher/classify.js +44 -7
  55. package/dist/launcher/env.js +105 -26
  56. package/dist/launcher/install-preflight.js +64 -18
  57. package/dist/launcher/live-install.js +29 -8
  58. package/dist/launcher/manifest-screen.js +171 -0
  59. package/dist/launcher/output-redaction.js +10 -4
  60. package/dist/launcher/preflight-prompt.js +18 -4
  61. package/dist/launcher/run.js +178 -103
  62. package/dist/launcher/spawn-invocation.js +21 -0
  63. package/dist/policy/cooldown.js +18 -5
  64. package/dist/policy/evaluate.js +5 -6
  65. package/dist/policy/pypi-name.js +17 -0
  66. package/dist/presentation/mode.js +3 -2
  67. package/dist/presentation/package-page.js +9 -0
  68. package/dist/presentation/theme.js +7 -7
  69. package/dist/project/dgfile.js +146 -6
  70. package/dist/project/override-trust.js +0 -0
  71. package/dist/proxy/auth.js +42 -0
  72. package/dist/proxy/ca.js +29 -9
  73. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  74. package/dist/proxy/enforcement.js +55 -16
  75. package/dist/proxy/metadata-map.js +66 -12
  76. package/dist/proxy/preverified.js +55 -0
  77. package/dist/proxy/server.js +568 -53
  78. package/dist/proxy/worker.js +16 -1
  79. package/dist/publish-set/collect.js +1 -4
  80. package/dist/publish-set/npm.js +8 -5
  81. package/dist/publish-set/pack.js +9 -3
  82. package/dist/runtime/cli.js +0 -4
  83. package/dist/runtime/fatal.js +31 -0
  84. package/dist/runtime/first-run.js +14 -12
  85. package/dist/runtime/node-version.js +43 -6
  86. package/dist/runtime/nudges.js +35 -2
  87. package/dist/sbom/cyclonedx.js +211 -0
  88. package/dist/sbom-ui/SbomApp.js +158 -0
  89. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  90. package/dist/sbom-ui/components/SbomList.js +52 -0
  91. package/dist/sbom-ui/inventory.js +132 -0
  92. package/dist/sbom-ui/launch.js +51 -0
  93. package/dist/sbom-ui/run.js +55 -0
  94. package/dist/sbom-ui/store.js +26 -0
  95. package/dist/scan/collect.js +10 -6
  96. package/dist/scan/command.js +17 -10
  97. package/dist/scan/discovery.js +11 -2
  98. package/dist/scan/render.js +28 -4
  99. package/dist/scan/scanner-report.js +15 -9
  100. package/dist/scan/staged.js +33 -9
  101. package/dist/scan-ui/LegacyApp.js +9 -13
  102. package/dist/scan-ui/alt-screen.js +5 -8
  103. package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
  104. package/dist/scan-ui/components/ProgressBar.js +3 -14
  105. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  106. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  107. package/dist/scan-ui/components/SetupBanner.js +0 -6
  108. package/dist/scan-ui/format-helpers.js +61 -5
  109. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  110. package/dist/scan-ui/hooks/useScan.js +19 -2
  111. package/dist/scan-ui/launch.js +7 -4
  112. package/dist/scan-ui/shims.js +11 -4
  113. package/dist/scripts/detect.js +7 -2
  114. package/dist/scripts/gate.js +1 -1
  115. package/dist/security/sanitize.js +8 -4
  116. package/dist/service/state.js +28 -9
  117. package/dist/service/trust-refresh.js +92 -0
  118. package/dist/service/trust-store.js +5 -9
  119. package/dist/service/worker.js +23 -1
  120. package/dist/setup/activate-shell.js +28 -0
  121. package/dist/setup/git-hook.js +49 -4
  122. package/dist/setup/plan.js +227 -49
  123. package/dist/setup/uninstall-standalone.js +25 -0
  124. package/dist/setup-ui/gate.js +39 -0
  125. package/dist/setup-ui/offer.js +42 -0
  126. package/dist/setup-ui/selector.js +27 -0
  127. package/dist/setup-ui/tasks.js +56 -0
  128. package/dist/setup-ui/wizard.js +233 -0
  129. package/dist/standalone/uninstall.mjs +2123 -0
  130. package/dist/state/cooldown-held.js +66 -0
  131. package/dist/state/index.js +1 -0
  132. package/dist/state/locks.js +4 -2
  133. package/dist/state/store.js +2 -1
  134. package/dist/util/external-tool.js +25 -0
  135. package/dist/util/git.js +10 -3
  136. package/dist/util/report-writer.js +57 -0
  137. package/dist/verify/local.js +242 -44
  138. package/dist/verify/package-check.js +87 -29
  139. package/dist/verify/preflight.js +242 -49
  140. package/dist/verify/render.js +15 -1
  141. package/npm-shrinkwrap.json +2383 -0
  142. package/package.json +7 -8
  143. package/NOTICE +0 -5
  144. package/dist/commands/completion.js +0 -117
  145. package/dist/commands/explain.js +0 -236
  146. package/dist/commands/unavailable.js +0 -11
  147. package/dist/scripts/rebuild.js +0 -28
  148. package/dist/telemetry/events.js +0 -40
@@ -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,19 +1,24 @@
1
- import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { accessSync, constants, copyFileSync, 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 { fileURLToPath } from "node:url";
5
+ import { randomBytes } from "node:crypto";
6
+ import { homedir } from "node:os";
4
7
  import { createTheme } from "../presentation/theme.js";
8
+ import { AGENTS, AGENT_IDS, agentLabel, resolveAgentHookContext, reverseAgentHookEntry } from "../agents/registry.js";
9
+ import { GATE_ENABLE_HINT, readNetworkGatePosture } from "../agents/gate-posture.js";
5
10
  import { acquireLockSync, findStaleSessionsSync, preserveCorruptCleanupRegistrySync, resolveDgPaths, sweepStaleSessionsSync, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
6
11
  import { currentNodeVersion, isSupportedNode } from "../runtime/node-version.js";
7
12
  import { dgVersion } from "../commands/version.js";
8
13
  import { compareVersions, readLatestVersion } from "../commands/update.js";
9
14
  import { AuthError, authStatus, displayTier, readAuthState } from "../auth/store.js";
10
- import { ConfigError, loadUserConfig } from "../config/settings.js";
15
+ import { ConfigError, DEFAULT_CONFIG, loadUserConfig } from "../config/settings.js";
11
16
  import { describeCooldownSettings } from "../policy/cooldown.js";
12
17
  import { resolveRealBinary } from "../launcher/resolve-real-binary.js";
13
18
  import { packageManagerNames } from "../launcher/classify.js";
14
19
  import { readServiceState } from "../service/state.js";
15
20
  import { OPTIONAL_SUPPORT_GATES } from "./optional-support.js";
16
- export const SHIM_COMMANDS = Object.freeze(["npm", "npx", "pnpm", "pnpx", "yarn", "pip", "pipx", "uv", "uvx", "cargo"]);
21
+ export const SHIM_COMMANDS = Object.freeze(["npm", "npx", "pnpm", "pnpx", "yarn", "pip", "pip3", "pipx", "uv", "uvx", "cargo"]);
17
22
  export const SHIM_SENTINEL = "dg-shim-v1";
18
23
  export const RC_SENTINEL = "dg-shell-rc-v1";
19
24
  export const RC_FUNCTIONS_SENTINEL = "dg-shim-functions-v1";
@@ -28,9 +33,18 @@ const LEGACY_RC_CANDIDATES = [".zshrc", ".bashrc", ".bash_profile", ".profile",
28
33
  const LEGACY_PYTHON_HOOK_PY = "dg_pip_hook.py";
29
34
  const LEGACY_PYTHON_HOOK_PTH = "dg_pip_hook.pth";
30
35
  const LEGACY_PYTHON_HOOK_MARKER = "Dependency Guardian pip-install interceptor";
36
+ const LEGACY_PYTHON_HOOK_PTH_MARKER = "import dg_pip_hook";
31
37
  export const SETUP_UNINSTALL_LOCK = "setup-uninstall";
32
38
  export const SETUP_UNINSTALL_LOCK_STALE_MS = 30 * 60 * 1000;
33
39
  export const STALE_SESSION_OLDER_THAN_MS = 24 * 60 * 60 * 1000;
40
+ function readShimFailClosed(env) {
41
+ try {
42
+ return loadUserConfig(env).policy.shimFailClosed;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
34
48
  const DOCTOR_GROUP_BY_NAME = {
35
49
  node: "environment",
36
50
  package: "environment",
@@ -44,7 +58,6 @@ const DOCTOR_GROUP_BY_NAME = {
44
58
  config: "setup",
45
59
  policy: "setup",
46
60
  "script-gate": "setup",
47
- telemetry: "setup",
48
61
  shims: "setup",
49
62
  "shell-rc": "setup",
50
63
  "python-hook-drift": "setup",
@@ -53,6 +66,7 @@ const DOCTOR_GROUP_BY_NAME = {
53
66
  "commit-guard": "setup",
54
67
  "stale-sessions": "setup",
55
68
  service: "setup",
69
+ "agent-gate": "setup",
56
70
  auth: "account",
57
71
  api: "account"
58
72
  };
@@ -64,7 +78,6 @@ const DOCTOR_FIX_BY_NAME = {
64
78
  "cleanup-registry-stale-entries": "re-run dg setup to refresh",
65
79
  config: "fix or remove ~/.dg, then re-run dg setup",
66
80
  policy: "fix ~/.dg config",
67
- telemetry: "fix ~/.dg config",
68
81
  shims: "dg setup",
69
82
  "shell-rc": "dg setup",
70
83
  "python-hook-drift": "dg uninstall, or re-run dg setup, to remove the stale pip hook",
@@ -100,6 +113,7 @@ export function buildSetupPlan(options) {
100
113
  shell,
101
114
  shimDir,
102
115
  rcPath,
116
+ failClosed: readShimFailClosed(env),
103
117
  writes: [
104
118
  {
105
119
  kind: "directory",
@@ -145,18 +159,19 @@ export function applySetupPlan(plan, now = new Date()) {
145
159
  });
146
160
  for (const command of SHIM_COMMANDS) {
147
161
  const path = join(plan.shimDir, command);
148
- writeFileSync(path, shimSource(command), {
162
+ writeFileSync(path, shimSource(command, { failClosed: plan.failClosed }), {
149
163
  encoding: "utf8",
150
164
  mode: 0o755
151
165
  });
152
166
  chmodSync(path, 0o755);
153
167
  entries.push(cleanupEntry("shim", path, "mode1", now, SHIM_SENTINEL));
154
168
  }
169
+ installStandaloneUninstaller(plan.paths.homeDir);
155
170
  mkdirSync(dirname(plan.rcPath), {
156
171
  recursive: true,
157
172
  mode: 0o700
158
173
  });
159
- writeFileSync(plan.rcPath, withRcBlock(readText(plan.rcPath), plan), "utf8");
174
+ writeRcFileAtomic(plan.rcPath, withRcBlock(readText(plan.rcPath), plan));
160
175
  entries.push(cleanupEntry("rc", plan.rcPath, "mode1", now, RC_SENTINEL));
161
176
  sweepLegacyPythonHooks(plan.paths.homeDir, [], []);
162
177
  const registry = withRegistryLock(plan.paths, () => {
@@ -180,6 +195,45 @@ export function applySetupPlanWithLock(plan, now = new Date()) {
180
195
  lock.release();
181
196
  }
182
197
  }
198
+ // Shims written by a dg that predates the self-cleaning template carry no
199
+ // auto-cleanup, so a plain `npm uninstall -g` leaves them stranded forever.
200
+ // Rewriting the installed shims and reinstalling the standalone uninstaller on
201
+ // the first run after a version bump propagates the current template to setups
202
+ // created by older releases.
203
+ export function refreshSetupOnUpgrade(env = process.env) {
204
+ const paths = resolveDgPaths(env);
205
+ const shimDir = join(paths.homeDir, ".dg", "shims");
206
+ if (!isShimFile(join(shimDir, "npm"))) {
207
+ return false;
208
+ }
209
+ const failClosed = readShimFailClosed(env);
210
+ try {
211
+ const lock = acquireLockSync(paths, SETUP_UNINSTALL_LOCK, {
212
+ staleMs: SETUP_UNINSTALL_LOCK_STALE_MS
213
+ });
214
+ try {
215
+ for (const command of SHIM_COMMANDS) {
216
+ const shimPath = join(shimDir, command);
217
+ if (!isShimFile(shimPath)) {
218
+ continue;
219
+ }
220
+ writeFileSync(shimPath, shimSource(command, { failClosed }), {
221
+ encoding: "utf8",
222
+ mode: 0o755
223
+ });
224
+ chmodSync(shimPath, 0o755);
225
+ }
226
+ installStandaloneUninstaller(paths.homeDir);
227
+ }
228
+ finally {
229
+ lock.release();
230
+ }
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ return true;
236
+ }
183
237
  export function uninstallSetup(options) {
184
238
  const paths = resolveDgPaths(options.env ?? process.env);
185
239
  const hadCacheDirBeforeLock = existsSync(paths.cacheDir);
@@ -213,6 +267,9 @@ export function uninstallSetup(options) {
213
267
  else if (entry.kind === "git-hook") {
214
268
  reverseGitHookEntry(entry, removed, missing, warnings);
215
269
  }
270
+ else if (entry.kind === "agent-hook") {
271
+ reverseAgentHookEntry(entry, removed, missing, warnings);
272
+ }
216
273
  }
217
274
  sweepLegacyRcBlocks(paths.homeDir, removed, warnings);
218
275
  sweepLegacyPythonHooks(paths.homeDir, removed, warnings);
@@ -278,7 +335,6 @@ export function doctorReport(options = {}) {
278
335
  checks.push(authCheck(env));
279
336
  checks.push(policyCheck(env));
280
337
  checks.push(scriptGateCheck(env));
281
- checks.push(telemetryCheck(env));
282
338
  const missingShims = SHIM_COMMANDS.filter((command) => !validShim(join(shimDir, command), command));
283
339
  checks.push({
284
340
  name: "shims",
@@ -324,6 +380,7 @@ export function doctorReport(options = {}) {
324
380
  });
325
381
  checks.push(...unavailableDoctorChecks());
326
382
  checks.push(serviceCheck(env));
383
+ checks.push(agentGateCheck(env, paths.homeDir));
327
384
  return {
328
385
  version: dgVersion(),
329
386
  checks: checks.map(enrichDoctorCheck)
@@ -568,6 +625,9 @@ function shellRcPath(homeDir, shell) {
568
625
  }
569
626
  return join(homeDir, ".zshrc");
570
627
  }
628
+ export function tildifyPath(path, home = homedir()) {
629
+ return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
630
+ }
571
631
  export function activationCommand(shell, rcDisplay) {
572
632
  if (shell === "fish") {
573
633
  return `source ${rcDisplay}`;
@@ -589,10 +649,10 @@ function reloadInstructions(shell) {
589
649
  // The baked absolute dg path stops a PATH-shadowing dg from hijacking the shim;
590
650
  // the fallbacks make it fail open — a removed or moved dg (uninstall, mid-upgrade)
591
651
  // runs the real manager instead of bricking it.
592
- export function shimSource(command) {
652
+ export function shimSource(command, options = {}) {
593
653
  const dg = escapeDoubleQuotedSh(dgEntrypoint());
594
654
  const nonce = '"${DG_SHIM_ACTIVE:+$DG_SHIM_ACTIVE,}' + `${command}:$$"`;
595
- return [
655
+ const head = [
596
656
  "#!/bin/sh",
597
657
  `# ${SHIM_SENTINEL}`,
598
658
  `if [ -x "${dg}" ]; then`,
@@ -603,6 +663,27 @@ export function shimSource(command) {
603
663
  `dg_bin=$(PATH="$dg_path" command -v dg 2>/dev/null)`,
604
664
  `if [ -n "$dg_bin" ]; then`,
605
665
  ` DG_SHIM_ACTIVE=${nonce} exec "$dg_bin" ${command} "$@"`,
666
+ "fi"
667
+ ];
668
+ // dg is gone from this machine. fail-closed (managed/CI) refuses the command
669
+ // so an unscanned install can't slip through; the operator removes the guard
670
+ // explicitly via `dg uninstall` while dg is present. fail-open (default)
671
+ // self-cleans dg's footprint and falls back to the real binary, because a
672
+ // plain `npm uninstall -g dg` cannot run cleanup and must not brick npm.
673
+ const failClosedTail = [
674
+ `echo "dg: install firewall required by policy (policy.shimFailClosed) but dg is unavailable; refusing to run ${command}" >&2`,
675
+ `echo "dg: reinstall dg, or run 'dg uninstall' while dg is present, to remove this guard" >&2`,
676
+ "exit 127",
677
+ ""
678
+ ];
679
+ const failOpenTail = [
680
+ `dg_home=$(dirname "$shim_dir")`,
681
+ `if [ -f "$dg_home/uninstall.mjs" ] && command -v node >/dev/null 2>&1; then`,
682
+ ` ( sleep 10`,
683
+ ` if [ ! -x "${dg}" ] && ! PATH="$dg_path" command -v dg >/dev/null 2>&1; then`,
684
+ ` node "$dg_home/uninstall.mjs" --quiet >/dev/null 2>&1`,
685
+ ` fi`,
686
+ ` ) >/dev/null 2>&1 &`,
606
687
  "fi",
607
688
  `real_bin=$(PATH="$dg_path" command -v ${command} 2>/dev/null)`,
608
689
  `if [ -n "$real_bin" ]; then`,
@@ -611,7 +692,8 @@ export function shimSource(command) {
611
692
  `echo "dg: protection unavailable and no real ${command} found on PATH" >&2`,
612
693
  "exit 127",
613
694
  ""
614
- ].join("\n");
695
+ ];
696
+ return [...head, ...(options.failClosed ? failClosedTail : failOpenTail)].join("\n");
615
697
  }
616
698
  function escapeDoubleQuotedSh(value) {
617
699
  return value.replace(/[\\"$`]/g, "\\$&");
@@ -623,6 +705,25 @@ function dgEntrypoint() {
623
705
  const argv1 = process.argv[1];
624
706
  return argv1 ? resolve(argv1) : "dg";
625
707
  }
708
+ function standaloneUninstallerSource() {
709
+ try {
710
+ return fileURLToPath(new URL("../standalone/uninstall.mjs", import.meta.url));
711
+ }
712
+ catch {
713
+ return undefined;
714
+ }
715
+ }
716
+ function installStandaloneUninstaller(homeDir) {
717
+ const source = standaloneUninstallerSource();
718
+ if (!source || !existsSync(source)) {
719
+ return;
720
+ }
721
+ const dgRoot = join(homeDir, ".dg");
722
+ mkdirSync(dgRoot, { recursive: true, mode: 0o700 });
723
+ const dest = join(dgRoot, "uninstall.mjs");
724
+ copyFileSync(source, dest);
725
+ chmodSync(dest, 0o600);
726
+ }
626
727
  function withRcBlock(existing, plan) {
627
728
  const withoutExisting = stripRcBlock(existing);
628
729
  const block = plan.shell === "fish" ? fishRcBlock(plan.shimDir) : posixRcBlock(plan.shimDir);
@@ -647,6 +748,26 @@ function fishRcBlock(shimDir) {
647
748
  function stripRcBlock(existing) {
648
749
  return stripRcBlockDetailed(existing).content;
649
750
  }
751
+ function writeRcFileAtomic(rcPath, content) {
752
+ let target = rcPath;
753
+ let mode = 0o644;
754
+ try {
755
+ target = realpathSync(rcPath);
756
+ mode = statSync(target).mode & 0o7777;
757
+ }
758
+ catch {
759
+ // no existing file: write a fresh rc at the requested path
760
+ }
761
+ const tempPath = join(dirname(target), `.${basename(target)}.dg-${randomBytes(6).toString("hex")}.tmp`);
762
+ writeFileSync(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
763
+ try {
764
+ renameSync(tempPath, target);
765
+ }
766
+ catch (error) {
767
+ rmSync(tempPath, { force: true });
768
+ throw error;
769
+ }
770
+ }
650
771
  // An unterminated begin marker must never strip to EOF: only lines verifiably
651
772
  // written by dg are removed, so user content below a stale marker survives.
652
773
  function stripRcBlockDetailed(existing) {
@@ -696,7 +817,7 @@ function sweepLegacyRcBlocks(homeDir, removed, warnings) {
696
817
  continue;
697
818
  }
698
819
  try {
699
- writeFileSync(rcPath, next, "utf8");
820
+ writeRcFileAtomic(rcPath, next);
700
821
  removed.push(`${rcPath} (legacy dg block)`);
701
822
  }
702
823
  catch (error) {
@@ -704,19 +825,23 @@ function sweepLegacyRcBlocks(homeDir, removed, warnings) {
704
825
  }
705
826
  }
706
827
  }
828
+ function isLegacyPthHook(pthPath) {
829
+ const content = readText(pthPath);
830
+ return content.includes(LEGACY_PYTHON_HOOK_PTH_MARKER) || content.includes(LEGACY_PYTHON_HOOK_MARKER);
831
+ }
707
832
  export function legacyPythonHookSites(homeDir) {
708
- return candidateSitePackagesDirs(homeDir).filter((dir) => existsSync(join(dir, LEGACY_PYTHON_HOOK_PTH)) || readText(join(dir, LEGACY_PYTHON_HOOK_PY)).includes(LEGACY_PYTHON_HOOK_MARKER));
833
+ return candidateSitePackagesDirs(homeDir).filter((dir) => isLegacyPthHook(join(dir, LEGACY_PYTHON_HOOK_PTH)) || readText(join(dir, LEGACY_PYTHON_HOOK_PY)).includes(LEGACY_PYTHON_HOOK_MARKER));
709
834
  }
710
835
  export function sweepLegacyPythonHooks(homeDir, removed, warnings) {
711
836
  for (const dir of candidateSitePackagesDirs(homeDir)) {
712
837
  const pyPath = join(dir, LEGACY_PYTHON_HOOK_PY);
713
838
  const pthPath = join(dir, LEGACY_PYTHON_HOOK_PTH);
714
839
  const pyIsHook = readText(pyPath).includes(LEGACY_PYTHON_HOOK_MARKER);
715
- const pthPresent = existsSync(pthPath);
716
- if (!pyIsHook && !pthPresent) {
840
+ const pthIsHook = isLegacyPthHook(pthPath);
841
+ if (!pyIsHook && !pthIsHook) {
717
842
  continue;
718
843
  }
719
- if (pthPresent) {
844
+ if (pthIsHook) {
720
845
  removePythonHookFile(pthPath, removed, warnings);
721
846
  }
722
847
  if (pyIsHook) {
@@ -815,7 +940,19 @@ function configCheck(paths, env) {
815
940
  }
816
941
  try {
817
942
  accessSync(paths.configDir, constants.R_OK);
818
- loadUserConfig(env);
943
+ const config = loadUserConfig(env);
944
+ if (config.api.baseUrl !== DEFAULT_CONFIG.api.baseUrl) {
945
+ // The verdict API is the firewall's source of truth. A non-default
946
+ // endpoint is legitimate for enterprise self-host, but a silent repoint
947
+ // is also how an attacker would make every verdict come back clean, so
948
+ // surface it on the standard health check rather than trusting it quietly.
949
+ return {
950
+ name: "config",
951
+ status: "warn",
952
+ message: `dg is fetching verdicts from a non-default API endpoint: ${config.api.baseUrl} (default ${DEFAULT_CONFIG.api.baseUrl})`,
953
+ fix: `if you did not set this, your verdict source may be tampered — restore with: dg config set api.baseUrl ${DEFAULT_CONFIG.api.baseUrl}`
954
+ };
955
+ }
819
956
  return {
820
957
  name: "config",
821
958
  status: "pass",
@@ -873,7 +1010,7 @@ function dgPathCheck(env) {
873
1010
  };
874
1011
  }
875
1012
  function currentDgBinaryPath(env) {
876
- const explicit = env.DG_TEST_CURRENT_DG_PATH;
1013
+ const explicit = env.NODE_ENV === "test" ? env.DG_TEST_CURRENT_DG_PATH : undefined;
877
1014
  if (explicit) {
878
1015
  return explicit;
879
1016
  }
@@ -931,6 +1068,45 @@ function serviceCheck(env) {
931
1068
  message: `Service mode is running at ${state.proxy.proxyUrl}; trust installed: ${state.trustInstalled ? "yes" : "no"}`
932
1069
  };
933
1070
  }
1071
+ function hookedAgentLabels(env, home) {
1072
+ const labels = [];
1073
+ for (const agent of AGENT_IDS) {
1074
+ const integration = AGENTS[agent];
1075
+ if (!integration.detect(home)) {
1076
+ continue;
1077
+ }
1078
+ const ctx = resolveAgentHookContext(agent, { env, home });
1079
+ const hooked = integration.verify(ctx).find((check) => check.name === integration.isInstalledCheckName)?.ok ?? false;
1080
+ if (hooked) {
1081
+ labels.push(agentLabel(agent));
1082
+ }
1083
+ }
1084
+ return labels;
1085
+ }
1086
+ function agentGateCheck(env, home) {
1087
+ const hooked = hookedAgentLabels(env, home);
1088
+ if (hooked.length === 0) {
1089
+ return {
1090
+ name: "agent-gate",
1091
+ status: "unavailable",
1092
+ message: "No AI agent has the dg install hook; run 'dg agents on' to protect agent-run installs"
1093
+ };
1094
+ }
1095
+ const posture = readNetworkGatePosture(env);
1096
+ if (posture.live) {
1097
+ return {
1098
+ name: "agent-gate",
1099
+ status: "pass",
1100
+ message: `${hooked.join(", ")} hooked; network gate live at ${posture.proxyUrl} (installs screened at fetch by artifact hash)`
1101
+ };
1102
+ }
1103
+ return {
1104
+ name: "agent-gate",
1105
+ status: "warn",
1106
+ message: `${hooked.join(", ")} hooked but the fetch-time network gate is OFF — static pre-screen only; absolute-path, manifest-only, dynamic, and unsupported-manager installs are NOT screened`,
1107
+ fix: GATE_ENABLE_HINT
1108
+ };
1109
+ }
934
1110
  function pathPrecedenceCheck(env, shimDir, functionsPresent) {
935
1111
  const pathEntries = (env.PATH ?? "").split(delimiter).filter(Boolean);
936
1112
  const shimIndex = pathEntries.indexOf(shimDir);
@@ -1117,9 +1293,8 @@ function scriptGateCheck(env) {
1117
1293
  if (mode === "enforce") {
1118
1294
  return {
1119
1295
  name: "script-gate",
1120
- status: "warn",
1121
- message: "scriptGate.mode is enforce, but this dg release observes only installs report script-running packages without blocking",
1122
- fix: "enforcement ships in an upcoming release; dg config set scriptGate.mode observe clears this warning"
1296
+ status: "pass",
1297
+ message: "Install-script gate enforces: protected npm/yarn installs run with --ignore-scripts so packages cannot run lifecycle scripts (pnpm blocks them natively)"
1123
1298
  };
1124
1299
  }
1125
1300
  return {
@@ -1136,34 +1311,24 @@ function scriptGateCheck(env) {
1136
1311
  };
1137
1312
  }
1138
1313
  }
1139
- function telemetryCheck(env) {
1140
- try {
1141
- const config = loadUserConfig(env);
1142
- return {
1143
- name: "telemetry",
1144
- status: "pass",
1145
- message: config.telemetry.enabled ? "Telemetry is enabled with minimized event fields" : "Telemetry is disabled"
1146
- };
1147
- }
1148
- catch (error) {
1149
- return {
1150
- name: "telemetry",
1151
- status: "fail",
1152
- message: error instanceof ConfigError ? error.message : "Unable to read telemetry config"
1153
- };
1154
- }
1155
- }
1156
1314
  export function writeRegistry(paths, registry) {
1157
1315
  mkdirSync(dirname(paths.cleanupRegistryPath), {
1158
1316
  recursive: true,
1159
1317
  mode: 0o700
1160
1318
  });
1161
- const tempPath = `${paths.cleanupRegistryPath}.${process.pid}.tmp`;
1319
+ const tempPath = `${paths.cleanupRegistryPath}.${randomBytes(6).toString("hex")}.tmp`;
1162
1320
  writeFileSync(tempPath, `${JSON.stringify(registry, null, 2)}\n`, {
1163
1321
  encoding: "utf8",
1164
- mode: 0o600
1322
+ mode: 0o600,
1323
+ flag: "wx"
1165
1324
  });
1166
- renameSync(tempPath, paths.cleanupRegistryPath);
1325
+ try {
1326
+ renameSync(tempPath, paths.cleanupRegistryPath);
1327
+ }
1328
+ catch (error) {
1329
+ rmSync(tempPath, { force: true });
1330
+ throw error;
1331
+ }
1167
1332
  }
1168
1333
  function withRegistryLock(paths, action) {
1169
1334
  const lock = acquireLockSync(paths, CLEANUP_REGISTRY_LOCK, {
@@ -1204,7 +1369,13 @@ function removeRcBlock(entry, removed, missing, warnings) {
1204
1369
  return;
1205
1370
  }
1206
1371
  const stripped = stripRcBlockDetailed(existing);
1207
- writeFileSync(entry.path, stripped.content, "utf8");
1372
+ try {
1373
+ writeRcFileAtomic(entry.path, stripped.content);
1374
+ }
1375
+ catch (error) {
1376
+ warnings.push(`could not rewrite shell rc ${entry.path}: ${error instanceof Error ? error.message : "write error"}`);
1377
+ return;
1378
+ }
1208
1379
  if (stripped.repairedLines.length > 0) {
1209
1380
  warnings.push(`repaired an unterminated dg block in ${entry.path}: removed only dg-written lines (${stripped.repairedLines.join(", ")})`);
1210
1381
  }
@@ -1214,28 +1385,35 @@ function removeRcBlock(entry, removed, missing, warnings) {
1214
1385
  // a one-line notice and lets the commit (and any chained hook) proceed instead
1215
1386
  // of blocking every commit with exit 127.
1216
1387
  export function guardHookScript(dgPath, chainedOriginal) {
1388
+ const dg = escapeDoubleQuotedSh(dgPath);
1217
1389
  const lines = [
1218
1390
  "#!/bin/sh",
1219
1391
  `# ${GUARD_HOOK_SENTINEL}`,
1220
- `if command -v "${dgPath}" >/dev/null 2>&1; then`,
1221
- ` "${dgPath}" scan --staged --hook || exit $?`,
1392
+ `if command -v "${dg}" >/dev/null 2>&1; then`,
1393
+ ` "${dg}" scan --staged --hook || exit $?`,
1222
1394
  "else",
1223
- ` echo "dg: pre-commit scan skipped (dg not runnable at ${dgPath}); commit allowed" >&2`,
1395
+ ` echo "dg: pre-commit scan skipped (dg not runnable at ${dg}); commit allowed" >&2`,
1224
1396
  "fi"
1225
1397
  ];
1226
1398
  if (chainedOriginal) {
1227
- lines.push(`[ -x "${chainedOriginal}" ] && exec "${chainedOriginal}" "$@"`);
1399
+ const chained = escapeDoubleQuotedSh(chainedOriginal);
1400
+ lines.push(`[ -x "${chained}" ] && exec "${chained}" "$@"`);
1228
1401
  }
1229
1402
  lines.push("exit 0");
1230
1403
  return `${lines.join("\n")}\n`;
1231
1404
  }
1405
+ function unescapeDoubleQuotedSh(value) {
1406
+ return value.replace(/\\([\\"$`])/g, "$1");
1407
+ }
1232
1408
  export function chainedHookOriginal(content) {
1233
- return content.match(/^\[ -x "(.+)" \] && exec "\1" "\$@"$/m)?.[1] ?? null;
1409
+ const matched = content.match(/^\[ -x "(.+)" \] && exec "\1" "\$@"$/m)?.[1];
1410
+ return matched ? unescapeDoubleQuotedSh(matched) : null;
1234
1411
  }
1235
1412
  export function guardHookDgPath(content) {
1236
- return (content.match(/^if command -v "(.+)" >\/dev\/null 2>&1; then$/m)?.[1] ??
1413
+ const matched = content.match(/^if command -v "(.+)" >\/dev\/null 2>&1; then$/m)?.[1] ??
1237
1414
  content.match(/^"(.+)" scan --staged --hook/m)?.[1] ??
1238
- null);
1415
+ null;
1416
+ return matched ? unescapeDoubleQuotedSh(matched) : null;
1239
1417
  }
1240
1418
  function commitGuardCheck(registry, env) {
1241
1419
  const hooks = registry.entries.filter((entry) => entry.owner === "dg" && entry.kind === "git-hook");
@@ -0,0 +1,25 @@
1
+ import { rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { uninstallSetup } from "./plan.js";
4
+ import { LockBusyError, resolveDgPaths } from "../state/index.js";
5
+ function run() {
6
+ const quiet = process.argv.includes("--quiet");
7
+ try {
8
+ const result = uninstallSetup({ keepConfig: false, all: true });
9
+ if (!quiet) {
10
+ process.stderr.write(`Dependency Guardian cleaned up ${result.removed.length} leftover item(s) after the package was removed.\n`);
11
+ }
12
+ }
13
+ catch (error) {
14
+ if (!quiet && !(error instanceof LockBusyError)) {
15
+ process.stderr.write(`dg self-cleanup could not finish: ${error.message}\n`);
16
+ }
17
+ }
18
+ try {
19
+ rmSync(join(resolveDgPaths().homeDir, ".dg"), { recursive: true, force: true });
20
+ }
21
+ catch {
22
+ return;
23
+ }
24
+ }
25
+ run();
@@ -0,0 +1,39 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { isCiEnv } from "../presentation/mode.js";
4
+ import { resolveDgPaths } from "../state/index.js";
5
+ export function setupApplied(env = process.env) {
6
+ return existsSync(join(resolveDgPaths(env).homeDir, ".dg", "shims", "npm"));
7
+ }
8
+ export function securityNotesMarkerPath(env = process.env) {
9
+ return join(resolveDgPaths(env).stateDir, "security-notes-shown");
10
+ }
11
+ export function wizardSkippedMarkerPath(env = process.env) {
12
+ return join(resolveDgPaths(env).stateDir, "setup-wizard-skipped");
13
+ }
14
+ export function securityNotesShown(env = process.env) {
15
+ return existsSync(securityNotesMarkerPath(env));
16
+ }
17
+ export function markSecurityNotesShown(env = process.env, now = new Date()) {
18
+ writeMarker(securityNotesMarkerPath(env), now);
19
+ }
20
+ export function markWizardSkipped(env = process.env, now = new Date()) {
21
+ writeMarker(wizardSkippedMarkerPath(env), now);
22
+ }
23
+ export function shouldOfferSetupWizard(env = process.env, stdin = process.stdin, stderr = process.stderr) {
24
+ return (process.platform !== "win32" &&
25
+ Boolean(stdin.isTTY) &&
26
+ Boolean(stderr.isTTY) &&
27
+ !isCiEnv(env) &&
28
+ !setupApplied(env) &&
29
+ !existsSync(wizardSkippedMarkerPath(env)));
30
+ }
31
+ function writeMarker(path, now) {
32
+ try {
33
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
34
+ writeFileSync(path, `${now.toISOString()}\n`, { encoding: "utf8", mode: 0o600 });
35
+ }
36
+ catch {
37
+ return;
38
+ }
39
+ }