@westbayberry/dg 2.2.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 (48) hide show
  1. package/dist/agents/claude-code.js +10 -0
  2. package/dist/agents/codex.js +1 -1
  3. package/dist/agents/cursor.js +6 -1
  4. package/dist/agents/gate-posture.js +21 -0
  5. package/dist/agents/persistence.js +68 -2
  6. package/dist/agents/registry.js +4 -3
  7. package/dist/agents/routing.js +118 -0
  8. package/dist/agents/windsurf.js +1 -1
  9. package/dist/audit/rules.js +6 -2
  10. package/dist/auth/store.js +9 -2
  11. package/dist/commands/agents.js +45 -1
  12. package/dist/commands/audit.js +6 -0
  13. package/dist/commands/setup.js +1 -2
  14. package/dist/commands/uninstall.js +2 -1
  15. package/dist/config/settings.js +35 -4
  16. package/dist/install-ui/LiveInstall.js +3 -2
  17. package/dist/install-ui/block-render.js +17 -13
  18. package/dist/launcher/agent-check.js +455 -25
  19. package/dist/launcher/agent-hook-exec.js +1 -1
  20. package/dist/launcher/agent-hook-io.js +12 -4
  21. package/dist/launcher/classify.js +27 -1
  22. package/dist/launcher/env.js +39 -7
  23. package/dist/launcher/install-preflight.js +15 -6
  24. package/dist/launcher/live-install.js +4 -3
  25. package/dist/launcher/manifest-screen.js +171 -0
  26. package/dist/launcher/output-redaction.js +4 -1
  27. package/dist/launcher/preflight-prompt.js +4 -3
  28. package/dist/launcher/run.js +90 -18
  29. package/dist/project/dgfile.js +2 -1
  30. package/dist/project/override-trust.js +0 -0
  31. package/dist/proxy/metadata-map.js +29 -13
  32. package/dist/proxy/server.js +130 -14
  33. package/dist/runtime/first-run.js +2 -1
  34. package/dist/sbom-ui/inventory.js +5 -1
  35. package/dist/scan/staged.js +31 -8
  36. package/dist/scan-ui/hooks/useScan.js +4 -1
  37. package/dist/security/sanitize.js +8 -4
  38. package/dist/service/state.js +1 -1
  39. package/dist/service/trust-store.js +5 -9
  40. package/dist/service/worker.js +3 -3
  41. package/dist/setup/plan.js +156 -12
  42. package/dist/setup/uninstall-standalone.js +25 -0
  43. package/dist/setup-ui/wizard.js +9 -1
  44. package/dist/standalone/uninstall.mjs +2123 -0
  45. package/dist/verify/local.js +2 -2
  46. package/dist/verify/package-check.js +3 -1
  47. package/npm-shrinkwrap.json +2 -2
  48. package/package.json +2 -1
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { formatScreenedNote } from "../launcher/agent-check.js";
2
3
  import { dirExists, mergedJsonHook, LEGACY_AGENT_HOOK_SENTINEL } from "./persistence.js";
3
4
  const SIGNATURE = "hook-exec claude-code";
4
5
  function configPath(home) {
@@ -74,6 +75,15 @@ function parseInput(stdin) {
74
75
  // silent ({}) on allow so we never override the user's normal permission flow.
75
76
  function emitDecision(verdict) {
76
77
  if (verdict.decision === "allow") {
78
+ const note = verdict.screened ? formatScreenedNote(verdict.screened) : "";
79
+ if (note) {
80
+ return {
81
+ stdout: JSON.stringify({
82
+ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: note },
83
+ }),
84
+ exitCode: 0,
85
+ };
86
+ }
77
87
  return { stdout: "{}", exitCode: 0 };
78
88
  }
79
89
  return {
@@ -21,7 +21,7 @@ function emitDecision(verdict) {
21
21
  return { stdout: "", exitCode: 0 };
22
22
  }
23
23
  const reason = verdict.reason ?? "Dependency Guardian firewall";
24
- const suffix = verdict.decision === "ask" ? " (flagged for review — run the install in a terminal to decide)" : "";
24
+ const suffix = verdict.decision === "ask" ? " (dg flagged this for human review — not auto-approved)" : "";
25
25
  return {
26
26
  stdout: JSON.stringify({ decision: "block", reason: `${reason}${suffix}` }),
27
27
  exitCode: 0,
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { formatScreenedNote } from "../launcher/agent-check.js";
2
3
  import { dirExists, mergedJsonHook, readSettings } from "./persistence.js";
3
4
  const SIGNATURE = "hook-exec cursor";
4
5
  function configPath(home) {
@@ -73,7 +74,11 @@ function parseInput(stdin) {
73
74
  }
74
75
  function emitDecision(verdict) {
75
76
  if (verdict.decision === "allow") {
76
- return { stdout: JSON.stringify({ permission: "allow" }), exitCode: 0 };
77
+ const note = verdict.screened ? formatScreenedNote(verdict.screened) : "";
78
+ return {
79
+ stdout: JSON.stringify(note ? { permission: "allow", agent_message: note } : { permission: "allow" }),
80
+ exitCode: 0,
81
+ };
77
82
  }
78
83
  const reason = verdict.reason ?? "Dependency Guardian firewall";
79
84
  return {
@@ -0,0 +1,21 @@
1
+ import { readServiceState } from "../service/state.js";
2
+ export function readNetworkGatePosture(env = process.env) {
3
+ try {
4
+ const { state } = readServiceState(env);
5
+ if (state.running && state.proxy) {
6
+ return { live: true, proxyUrl: state.proxy.proxyUrl };
7
+ }
8
+ }
9
+ catch {
10
+ // fall through to the off posture; a missing/unreadable service state means
11
+ // the gate is not live, which is exactly what we report.
12
+ }
13
+ return { live: false };
14
+ }
15
+ export const GATE_OFF_COVERAGE_GAPS = [
16
+ "absolute-path installs (e.g. /usr/local/bin/npm install evil)",
17
+ "manifest-only installs (npm ci, npm install with no package named)",
18
+ "dynamically-built or stdin-fed commands the static parser defers on",
19
+ "recognized-but-unsupported managers (bun, deno, poetry, pdm)",
20
+ ];
21
+ export const GATE_ENABLE_HINT = "dg setup --service --yes && dg service start";
@@ -81,12 +81,16 @@ async function recordHookEntry(ctx, legacySentinels, created) {
81
81
  for (const sentinel of legacySentinels) {
82
82
  await removeCleanupEntry(ctx.paths, { kind: "agent-hook", path: ctx.settingsPath, sentinel });
83
83
  }
84
+ // Preserve the dg-created provenance on re-apply: `created` is false the second
85
+ // time the hook is installed, but if dg created the file originally, uninstall
86
+ // must still remove it, so keep the marker.
87
+ const dgCreated = created || (await wasCreatedByDg(ctx));
84
88
  await recordCleanupEntry(ctx.paths, {
85
89
  kind: "agent-hook",
86
90
  path: ctx.settingsPath,
87
91
  mode: "mode1",
88
92
  sentinel: agentHookSentinel(ctx.agent),
89
- ...(created ? { original: "dg-created" } : {}),
93
+ ...(dgCreated ? { original: "dg-created" } : {}),
90
94
  });
91
95
  }
92
96
  async function removeHookEntries(ctx, legacySentinels) {
@@ -95,6 +99,48 @@ async function removeHookEntries(ctx, legacySentinels) {
95
99
  await removeCleanupEntry(ctx.paths, { kind: "agent-hook", path: ctx.settingsPath, sentinel });
96
100
  }
97
101
  }
102
+ function findHookCommand(node, signature) {
103
+ if (typeof node === "string") {
104
+ return node.includes(signature) ? node : null;
105
+ }
106
+ if (Array.isArray(node)) {
107
+ for (const value of node) {
108
+ const found = findHookCommand(value, signature);
109
+ if (found) {
110
+ return found;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+ if (node && typeof node === "object") {
116
+ for (const value of Object.values(node)) {
117
+ const found = findHookCommand(value, signature);
118
+ if (found) {
119
+ return found;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ return null;
125
+ }
126
+ function hookResolvesCheck(settings, agent) {
127
+ const command = findHookCommand(settings, `hook-exec ${agent}`);
128
+ if (!command) {
129
+ return null;
130
+ }
131
+ const head = command.split(/\s+hook-exec\s+/)[0] ?? command;
132
+ const broken = head
133
+ .split(/\s+/)
134
+ .filter((token) => token.startsWith("/"))
135
+ .filter((token) => !existsSync(token));
136
+ return {
137
+ name: "hook command resolves",
138
+ ok: broken.length === 0,
139
+ detail: broken.length === 0
140
+ ? "dg path in the hook resolves"
141
+ : `hook references a missing path (${broken.join(", ")}); re-run 'dg agents on ${agent}'`,
142
+ };
143
+ }
98
144
  export function mergedJsonHook(config) {
99
145
  const legacy = config.legacySentinels ?? [];
100
146
  return {
@@ -138,14 +184,22 @@ export function mergedJsonHook(config) {
138
184
  if (!present) {
139
185
  return checks;
140
186
  }
187
+ let settings = null;
141
188
  let installed = false;
142
189
  try {
143
- installed = config.isInstalled(readSettings(ctx.settingsPath).settings);
190
+ settings = readSettings(ctx.settingsPath).settings;
191
+ installed = config.isInstalled(settings);
144
192
  }
145
193
  catch {
146
194
  installed = false;
147
195
  }
148
196
  checks.push({ name: config.checkName, ok: installed, detail: installed ? "installed" : "not installed" });
197
+ if (installed && settings) {
198
+ const resolves = hookResolvesCheck(settings, ctx.agent);
199
+ if (resolves) {
200
+ checks.push(resolves);
201
+ }
202
+ }
149
203
  return checks;
150
204
  },
151
205
  reverseEntry(entry, removed, missing, warnings) {
@@ -227,6 +281,18 @@ export function ownedJsonHook(config) {
227
281
  }
228
282
  const installed = ownsFile(ctx.settingsPath);
229
283
  checks.push({ name: config.checkName, ok: installed, detail: installed ? "installed" : "present but not dg-owned" });
284
+ if (installed) {
285
+ try {
286
+ const settings = JSON.parse(readFileSync(ctx.settingsPath, "utf8"));
287
+ const resolves = hookResolvesCheck(settings, ctx.agent);
288
+ if (resolves) {
289
+ checks.push(resolves);
290
+ }
291
+ }
292
+ catch {
293
+ // unreadable file: the installed check already reflects the broken state
294
+ }
295
+ }
230
296
  return checks;
231
297
  },
232
298
  reverseEntry(entry, removed, missing, warnings) {
@@ -37,9 +37,10 @@ export function defaultDgCommand(agent) {
37
37
  catch {
38
38
  // keep argv[1]; the hook still resolves via PATH as a fallback
39
39
  }
40
- // Absolute node + script path so the agent's non-interactive subprocess
41
- // can't lose it to a PATH change (the whole point of the hook).
42
- return `${process.execPath} ${bin} ${agentHookSignature(agent)}`;
40
+ // Run the dg script via its own shebang rather than baking process.execPath:
41
+ // a version-pinned node path (e.g. .../Cellar/node/<ver>/bin/node) vanishes on
42
+ // a node upgrade and would silently disable the hook.
43
+ return `${bin} ${agentHookSignature(agent)}`;
43
44
  }
44
45
  export function resolveAgentHookContext(agent, options = {}) {
45
46
  const env = options.env ?? process.env;
@@ -0,0 +1,118 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { buildAgentRoutingEnv } from "../launcher/env.js";
4
+ import { readServiceState } from "../service/state.js";
5
+ import { resolveDgPaths } from "../state/index.js";
6
+ import { readSettings, writeSettingsAtomic } from "./persistence.js";
7
+ import { getAgent } from "./registry.js";
8
+ // The agent's install hook screens statically; this routes the agent's ACTUAL
9
+ // fetches through dg's proxy so a wrapped/dynamic install (eval, $VAR, python -m
10
+ // pip, …) the static hook can't decode is still screened at fetch time. Only
11
+ // applied when `dg service` is running, so the proxy endpoint baked into the
12
+ // agent's env is always live — never a dead HTTPS_PROXY that breaks installs.
13
+ const CODEX_BEGIN = "# >>> dg routing >>>";
14
+ const CODEX_END = "# <<< dg routing <<<";
15
+ function backupPath(agent, env) {
16
+ return join(resolveDgPaths(env).stateDir, `routing-${agent}.json`);
17
+ }
18
+ export function resolveServiceRoutingEnv(env = process.env) {
19
+ const { state } = readServiceState(env);
20
+ if (!state.running || !state.proxy) {
21
+ return { error: "dg service is not running — run 'dg service start' so the proxy gate is live before routing agents through it" };
22
+ }
23
+ return { env: buildAgentRoutingEnv(state.proxy.proxyUrl, state.proxy.caPath), proxyUrl: state.proxy.proxyUrl };
24
+ }
25
+ function escapeRegex(value) {
26
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27
+ }
28
+ function stripCodexBlock(content) {
29
+ const block = new RegExp(`\\n?${escapeRegex(CODEX_BEGIN)}[\\s\\S]*?${escapeRegex(CODEX_END)}\\n?`, "g");
30
+ return content.replace(block, "\n").replace(/\n{3,}/g, "\n\n");
31
+ }
32
+ function applyClaudeRouting(settingsPath, routing, backup) {
33
+ const { settings } = readSettings(settingsPath);
34
+ const envObj = typeof settings.env === "object" && settings.env !== null ? { ...settings.env } : {};
35
+ const prior = {};
36
+ for (const [k, v] of Object.entries(routing)) {
37
+ prior[k] = k in envObj ? String(envObj[k]) : null;
38
+ envObj[k] = v;
39
+ }
40
+ settings.env = envObj;
41
+ writeSettingsAtomic(settingsPath, settings);
42
+ mkdirSync(dirname(backup), { recursive: true, mode: 0o700 });
43
+ writeFileSync(backup, `${JSON.stringify({ kind: "claude-env", path: settingsPath, prior }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
44
+ }
45
+ function removeClaudeRouting(settingsPath, backup) {
46
+ if (!existsSync(backup)) {
47
+ return;
48
+ }
49
+ let prior = {};
50
+ try {
51
+ prior = JSON.parse(readFileSync(backup, "utf8")).prior ?? {};
52
+ }
53
+ catch {
54
+ prior = {};
55
+ }
56
+ if (existsSync(settingsPath)) {
57
+ const { settings } = readSettings(settingsPath);
58
+ const envObj = typeof settings.env === "object" && settings.env !== null ? { ...settings.env } : {};
59
+ for (const [k, v] of Object.entries(prior)) {
60
+ if (v === null) {
61
+ delete envObj[k];
62
+ }
63
+ else {
64
+ envObj[k] = v;
65
+ }
66
+ }
67
+ if (Object.keys(envObj).length === 0) {
68
+ delete settings.env;
69
+ }
70
+ else {
71
+ settings.env = envObj;
72
+ }
73
+ writeSettingsAtomic(settingsPath, settings);
74
+ }
75
+ rmSync(backup, { force: true });
76
+ }
77
+ function applyCodexRouting(configPath, routing) {
78
+ const content = existsSync(configPath) ? stripCodexBlock(readFileSync(configPath, "utf8")) : "";
79
+ if (/^\s*\[shell_environment_policy\]/m.test(content)) {
80
+ return { applied: false, detail: "~/.codex/config.toml already defines [shell_environment_policy] — add dg's proxy vars (HTTPS_PROXY, NODE_EXTRA_CA_CERTS, …) to its `set` table manually" };
81
+ }
82
+ const set = Object.entries(routing).map(([k, v]) => `${k} = ${JSON.stringify(v)}`).join(", ");
83
+ const block = `${CODEX_BEGIN}\n[shell_environment_policy]\nset = { ${set} }\n${CODEX_END}\n`;
84
+ const next = content === "" || content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
85
+ writeFileSync(configPath, next, { encoding: "utf8", mode: 0o600 });
86
+ return { applied: true, detail: configPath };
87
+ }
88
+ function removeCodexRouting(configPath) {
89
+ if (!existsSync(configPath)) {
90
+ return;
91
+ }
92
+ writeFileSync(configPath, stripCodexBlock(readFileSync(configPath, "utf8")), { encoding: "utf8" });
93
+ }
94
+ function codexConfigPath(home) {
95
+ return join(home, ".codex", "config.toml");
96
+ }
97
+ export function applyAgentRouting(agent, routing, home, env = process.env) {
98
+ if (agent === "codex") {
99
+ return applyCodexRouting(codexConfigPath(home), routing);
100
+ }
101
+ // Every other supported agent reads a JSON settings file with an `env` block.
102
+ applyClaudeRouting(getAgent(agent).configPath(home), routing, backupPath(agent, env));
103
+ return { applied: true, detail: getAgent(agent).configPath(home) };
104
+ }
105
+ export function removeAgentRouting(agent, home, env = process.env) {
106
+ if (agent === "codex") {
107
+ removeCodexRouting(codexConfigPath(home));
108
+ return;
109
+ }
110
+ removeClaudeRouting(getAgent(agent).configPath(home), backupPath(agent, env));
111
+ }
112
+ export function routingInstalled(agent, home, env = process.env) {
113
+ if (agent === "codex") {
114
+ const p = codexConfigPath(home);
115
+ return existsSync(p) && readFileSync(p, "utf8").includes(CODEX_BEGIN);
116
+ }
117
+ return existsSync(backupPath(agent, env));
118
+ }
@@ -64,7 +64,7 @@ function emitDecision(verdict) {
64
64
  return { stdout: "", exitCode: 0 };
65
65
  }
66
66
  const reason = verdict.reason ?? "Dependency Guardian firewall";
67
- const suffix = verdict.decision === "ask" ? " (flagged for review — run the install in a terminal to decide)" : "";
67
+ const suffix = verdict.decision === "ask" ? " (dg flagged this for human review — not auto-approved)" : "";
68
68
  return { stdout: `${reason}${suffix}\n`, exitCode: 2 };
69
69
  }
70
70
  function probeHookSupport(home) {
@@ -384,7 +384,7 @@ export const CONTENT_RULES = [
384
384
  {
385
385
  id: "db-connection-string",
386
386
  re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:@\s/]+:[^@\s/]+@[^/\s]+/u,
387
- allow: /:\/\/[^:@/]+:(password|pass|user|example|changeme|xxx+|\*+)@|@(localhost|127\.0\.0\.1|example\.|host)/iu,
387
+ allow: /:\/\/[^:@/]+:(password|pass|user|example|changeme|xxx+|\*+)@|@(localhost|127\.0\.0\.1|example\.|host\b)/iu,
388
388
  category: "bundled-secret",
389
389
  severity: 4,
390
390
  title: "Database connection string with password embedded in a published file",
@@ -421,6 +421,10 @@ export const RISKY_SCRIPT_NAMES = [
421
421
  "preuninstall",
422
422
  "uninstall"
423
423
  ];
424
- export const DANGEROUS_SCRIPT_RE = /\|\s*(?:sh|bash|zsh)\b|\beval\s|node\s+-e\b|python[0-9.]*\s+-c\b|base64\s+-d|(?:curl|wget|fetch)\b[^\n]*\|\s*\w*sh\b/iu;
424
+ // Best-effort heuristic on the LOCAL audit only; a public regex is evadable, so
425
+ // the authoritative verdict is the behavioral scanner. Covers pipe-to-shell, eval,
426
+ // node -e, python -c, base64 -d, curl|sh, AND download-then-execute where the fetch
427
+ // is chained (&& / ;) to a shell or a freshly-downloaded file.
428
+ export const DANGEROUS_SCRIPT_RE = /\|\s*(?:sh|bash|zsh)\b|\beval\s|node\s+-e\b|python[0-9.]*\s+-c\b|base64\s+-d|(?:curl|wget|fetch)\b[^\n]*\|\s*\w*sh\b|(?:curl|wget|fetch)\b[^\n]*(?:&&|;)\s*(?:sh|bash|zsh|source|\.\/)/iu;
425
429
  export const INVISIBLE_UNICODE_RE = /[\u200B-\u200F\u2060-\u2064\u202A-\u202E\u2066-\u2069\uFEFF]/u;
426
430
  export const BIDI_OVERRIDE_RE = /[\u202A-\u202E\u2066-\u2069]/u;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { dirname, join } from "node:path";
4
- import { loadUserConfig, saveUserConfig, withUserConfigLock } from "../config/settings.js";
4
+ import { loadUserConfig, parseUrl, saveUserConfig, withUserConfigLock } from "../config/settings.js";
5
5
  import { resolveDgPaths } from "../state/index.js";
6
6
  import { envAuthToken } from "./env-token.js";
7
7
  export class AuthError extends Error {
@@ -24,6 +24,13 @@ export function readAuthState(env = process.env) {
24
24
  if (parsed.version !== 1 || !parsed.token || !parsed.apiBaseUrl || parsed.loggedInAt === undefined) {
25
25
  throw new AuthError("unsupported auth state");
26
26
  }
27
+ let apiBaseUrl;
28
+ try {
29
+ apiBaseUrl = parseUrl(parsed.apiBaseUrl);
30
+ }
31
+ catch {
32
+ throw new AuthError("auth state has an invalid api base URL; run 'dg login' again");
33
+ }
27
34
  const email = typeof parsed.email === "string" && parsed.email.length > 0 ? parsed.email : undefined;
28
35
  const tier = typeof parsed.tier === "string" && parsed.tier.length > 0 ? parsed.tier : undefined;
29
36
  const name = typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : undefined;
@@ -31,7 +38,7 @@ export function readAuthState(env = process.env) {
31
38
  version: 1,
32
39
  token: parsed.token,
33
40
  tokenPreview: parsed.tokenPreview ?? redactToken(parsed.token),
34
- apiBaseUrl: parsed.apiBaseUrl,
41
+ apiBaseUrl,
35
42
  orgId: parsed.orgId ?? "",
36
43
  loggedInAt: parsed.loggedInAt,
37
44
  ...(email ? { email } : {}),
@@ -1,6 +1,8 @@
1
1
  import { homedir } from "node:os";
2
2
  import { AGENT_IDS, AGENTS, agentLabel, collectAgentOffers, getAgent, isAgentId, resolveAgentHookContext, } from "../agents/registry.js";
3
3
  import { AgentHookError } from "../agents/persistence.js";
4
+ import { applyAgentRouting, removeAgentRouting, resolveServiceRoutingEnv, routingInstalled } from "../agents/routing.js";
5
+ import { GATE_ENABLE_HINT, GATE_OFF_COVERAGE_GAPS, readNetworkGatePosture } from "../agents/gate-posture.js";
4
6
  import { resolvePresentation } from "../presentation/mode.js";
5
7
  import { createTheme } from "../presentation/theme.js";
6
8
  import { tildifyPath as tildify } from "../setup/plan.js";
@@ -101,6 +103,23 @@ function renderStatusCard(rows, theme) {
101
103
  lines.push("", ` ${muted("protect all:")} ${accent("dg agents on")} ${muted("·")} ${muted("remove:")} ${accent("dg agents off")}`, "");
102
104
  return lines.join("\n");
103
105
  }
106
+ function gatePostureLines(env, theme) {
107
+ const accent = (text) => theme.paint("accent", text);
108
+ const muted = (text) => theme.paint("muted", text);
109
+ const posture = readNetworkGatePosture(env);
110
+ if (posture.live) {
111
+ return [` ${theme.paint("pass", "✓ network gate live")} ${muted(`at ${posture.proxyUrl} — installs are screened at fetch by artifact hash, however the command is written`)}`];
112
+ }
113
+ const lines = [
114
+ ` ${theme.paint("warn", "⚠ network gate OFF — static pre-screen only")}`,
115
+ ` ${muted("these are NOT screened until the gate is on:")}`,
116
+ ];
117
+ for (const gap of GATE_OFF_COVERAGE_GAPS) {
118
+ lines.push(` ${muted(`· ${gap}`)}`);
119
+ }
120
+ lines.push(` ${muted("enable full fetch-time screening:")} ${accent(GATE_ENABLE_HINT)}`);
121
+ return lines;
122
+ }
104
123
  async function applyAgents(targets, env, home, theme, recordFixture) {
105
124
  const accent = (text) => theme.paint("accent", text);
106
125
  const muted = (text) => theme.paint("muted", text);
@@ -119,6 +138,16 @@ async function applyAgents(targets, env, home, theme, recordFixture) {
119
138
  try {
120
139
  await integration.apply({ ...ctx, dgCommand });
121
140
  lines.push(` ${theme.paint("pass", `✓ ${integration.label} installs route through dg`)} ${muted(`(${tildify(ctx.settingsPath, home)})`)}`);
141
+ // When the persistent proxy is live, also route the agent's real fetches
142
+ // through it so a wrapped/dynamic install the static hook can't decode is
143
+ // still screened at fetch time.
144
+ const routing = resolveServiceRoutingEnv(env);
145
+ if (!("error" in routing)) {
146
+ const r = applyAgentRouting(agent, routing.env, home, env);
147
+ lines.push(r.applied
148
+ ? ` ${muted(" ↳ fetches also routed through the live dg proxy (fetch-time screening)")}`
149
+ : ` ${muted(` ↳ routing skipped: ${r.detail}`)}`);
150
+ }
122
151
  if (integration.maturity === "unverified") {
123
152
  lines.push(` ${muted(` hook installed from ${integration.label}'s documented schema; payload format not yet verified against a live ${integration.label} run`)}`);
124
153
  }
@@ -135,7 +164,7 @@ async function applyAgents(targets, env, home, theme, recordFixture) {
135
164
  if (lines.length === 0) {
136
165
  return { exitCode: 0, stdout: ` ${muted("No unprotected agents detected.")} ${muted("See")} ${accent("dg agents")}\n`, stderr: "" };
137
166
  }
138
- lines.push(` ${muted("Reverse:")} ${accent("dg agents off")}`);
167
+ lines.push("", ...gatePostureLines(env, theme), "", ` ${muted("Reverse:")} ${accent("dg agents off")}`);
139
168
  return { exitCode: failures > 0 && failures === targets.length ? 1 : 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
140
169
  }
141
170
  async function removeAgents(targets, env, home, theme) {
@@ -146,6 +175,7 @@ async function removeAgents(targets, env, home, theme) {
146
175
  const ctx = resolveAgentHookContext(agent, { env, home });
147
176
  try {
148
177
  const result = await integration.remove(ctx);
178
+ removeAgentRouting(agent, home, env);
149
179
  lines.push(result.removed
150
180
  ? ` ${theme.paint("pass", `✓ ${integration.label} hook removed`)} ${muted(`(${tildify(ctx.settingsPath, home)})`)}`
151
181
  : ` ${muted(`· ${integration.label} — no dg hook was installed`)}`);
@@ -171,6 +201,20 @@ function checkAgents(targets, env, home) {
171
201
  lines.push(` ${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`);
172
202
  allOk = allOk && check.ok;
173
203
  }
204
+ const routed = routingInstalled(agent, home, env);
205
+ lines.push(` ${routed ? "✓" : "·"} fetch-time routing: ${routed ? "installs routed through the dg proxy" : "not routed (start dg service, then re-run dg agents on)"}`);
206
+ }
207
+ const posture = readNetworkGatePosture(env);
208
+ lines.push("", "network gate:");
209
+ if (posture.live) {
210
+ lines.push(` ✓ live at ${posture.proxyUrl} — installs screened at fetch by artifact hash, however the command is written`);
211
+ }
212
+ else {
213
+ lines.push(" ⚠ OFF — static pre-screen only. NOT screened until the gate is on:");
214
+ for (const gap of GATE_OFF_COVERAGE_GAPS) {
215
+ lines.push(` · ${gap}`);
216
+ }
217
+ lines.push(` enable: ${GATE_ENABLE_HINT}`);
174
218
  }
175
219
  return { exitCode: allOk ? 0 : 1, stdout: `${lines.join("\n")}\n`, stderr: "" };
176
220
  }
@@ -131,6 +131,12 @@ export async function maybeAudit(args) {
131
131
  }
132
132
  }
133
133
  }
134
+ // Org policy must gate every upload, not just the consent prompt: a pre-granted
135
+ // consent (DG_AUDIT_UPLOAD=1 / config) must not transmit the tarball when the
136
+ // organization has disabled artifact upload.
137
+ if (decision.upload && (await teamPolicyBlocksUpload())) {
138
+ decision = { upload: false, reason: "deep audit upload is disabled by your organization's policy" };
139
+ }
134
140
  if (shouldLaunchAuditTui({ format: gathered.parsed.format, outputPath: gathered.parsed.outputPath })) {
135
141
  const uploadAbort = new AbortController();
136
142
  const deepPromise = decision.upload
@@ -270,8 +270,7 @@ function renderSecurityNotesText(theme) {
270
270
  "",
271
271
  " 1. dg can make mistakes.",
272
272
  " A PASS verdict does not guarantee a package is safe. You are",
273
- " responsible for what you install and should review new",
274
- " dependencies.",
273
+ " responsible for what you install.",
275
274
  "",
276
275
  " 2. By continuing you confirm you have read and understand the",
277
276
  " Terms of Service and Privacy Policy.",
@@ -14,7 +14,8 @@ export const uninstallCommand = {
14
14
  ],
15
15
  examples: ["dg uninstall", "dg uninstall --yes --keep-config", "dg uninstall --all --yes"],
16
16
  details: [
17
- "Removes only dg-owned writes (shims, shell-rc block, git hooks), tolerates missing or malformed state, runs twice safely, and preserves user content."
17
+ "Removes only dg-owned writes (shims, shell-rc block, git hooks), tolerates missing or malformed state, runs twice safely, and preserves user content.",
18
+ "Running 'npm uninstall -g @westbayberry/dg' on its own leaves these writes behind; the next npm or pip command then clears them automatically. Run this to remove everything immediately."
18
19
  ],
19
20
  handler: (context) => uninstallHandler(context.args)
20
21
  };
@@ -9,6 +9,8 @@ export const CONFIG_KEYS = Object.freeze([
9
9
  "policy.trustProjectAllowlists",
10
10
  "policy.allowForceOverride",
11
11
  "policy.scriptHardening",
12
+ "policy.shimFailClosed",
13
+ "policy.strictEgress",
12
14
  "scriptGate.mode",
13
15
  "scriptGate.observe",
14
16
  "gitHook.onWarn",
@@ -33,7 +35,9 @@ export const DEFAULT_CONFIG = Object.freeze({
33
35
  mode: "block",
34
36
  trustProjectAllowlists: false,
35
37
  allowForceOverride: true,
36
- scriptHardening: false
38
+ scriptHardening: false,
39
+ shimFailClosed: false,
40
+ strictEgress: false
37
41
  },
38
42
  scriptGate: {
39
43
  mode: "observe",
@@ -48,7 +52,7 @@ export const DEFAULT_CONFIG = Object.freeze({
48
52
  npmAge: "",
49
53
  pypiAge: "",
50
54
  cargoAge: "",
51
- onUnknown: "allow",
55
+ onUnknown: "block",
52
56
  exempt: ""
53
57
  },
54
58
  audit: {
@@ -106,6 +110,17 @@ export function updateUserConfig(apply, env = process.env) {
106
110
  return next;
107
111
  });
108
112
  }
113
+ // Whether repo-supplied project overrides (allowlists, cooldown exemptions,
114
+ // warn-decisions) are trusted wholesale. A corrupt config fails closed to
115
+ // untrusted so a hostile repo can never relax screening by breaking the config.
116
+ export function trustsProjectOverrides(env = process.env) {
117
+ try {
118
+ return loadUserConfig(env).policy.trustProjectAllowlists;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
109
124
  export function getConfigValue(config, key) {
110
125
  if (key === "api.baseUrl") {
111
126
  return config.api.baseUrl;
@@ -125,6 +140,12 @@ export function getConfigValue(config, key) {
125
140
  if (key === "policy.scriptHardening") {
126
141
  return String(config.policy.scriptHardening);
127
142
  }
143
+ if (key === "policy.shimFailClosed") {
144
+ return String(config.policy.shimFailClosed);
145
+ }
146
+ if (key === "policy.strictEgress") {
147
+ return String(config.policy.strictEgress);
148
+ }
128
149
  if (key === "scriptGate.mode") {
129
150
  return config.scriptGate.mode;
130
151
  }
@@ -160,6 +181,8 @@ export function getConfigValue(config, key) {
160
181
  export const ADVANCED_CONFIG_KEYS = new Set([
161
182
  "org.id",
162
183
  "policy.scriptHardening",
184
+ "policy.shimFailClosed",
185
+ "policy.strictEgress",
163
186
  "scriptGate.observe",
164
187
  "cooldown.npm.age",
165
188
  "cooldown.pypi.age",
@@ -206,6 +229,12 @@ export function setConfigValue(config, key, rawValue) {
206
229
  if (key === "policy.scriptHardening") {
207
230
  return withPolicyBoolean(config, "scriptHardening", rawValue);
208
231
  }
232
+ if (key === "policy.shimFailClosed") {
233
+ return withPolicyBoolean(config, "shimFailClosed", rawValue);
234
+ }
235
+ if (key === "policy.strictEgress") {
236
+ return withPolicyBoolean(config, "strictEgress", rawValue);
237
+ }
209
238
  if (key === "scriptGate.mode") {
210
239
  return {
211
240
  ...config,
@@ -300,7 +329,9 @@ function normalizeConfig(raw) {
300
329
  mode: parsePolicyMode(fieldString(policy, "policy.mode", "mode") ?? DEFAULT_CONFIG.policy.mode),
301
330
  trustProjectAllowlists: fieldBoolean(policy, "policy.trustProjectAllowlists", "trustProjectAllowlists") ?? DEFAULT_CONFIG.policy.trustProjectAllowlists,
302
331
  allowForceOverride: fieldBoolean(policy, "policy.allowForceOverride", "allowForceOverride") ?? DEFAULT_CONFIG.policy.allowForceOverride,
303
- scriptHardening
332
+ scriptHardening,
333
+ shimFailClosed: fieldBoolean(policy, "policy.shimFailClosed", "shimFailClosed") ?? DEFAULT_CONFIG.policy.shimFailClosed,
334
+ strictEgress: fieldBoolean(policy, "policy.strictEgress", "strictEgress") ?? DEFAULT_CONFIG.policy.strictEgress
304
335
  },
305
336
  scriptGate: {
306
337
  mode: parseScriptGateMode(fieldString(scriptGate, "scriptGate.mode", "mode") ?? (scriptHardening ? "enforce" : DEFAULT_CONFIG.scriptGate.mode)),
@@ -452,7 +483,7 @@ function parseBoolean(value, field) {
452
483
  }
453
484
  throw new ConfigError(`${field} must be true or false`);
454
485
  }
455
- function parseUrl(value) {
486
+ export function parseUrl(value) {
456
487
  const trimmed = value.trim();
457
488
  try {
458
489
  const url = new URL(trimmed);
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import InkSpinner from "ink-spinner";
4
4
  import { formatResetDate } from "./block-render.js";
5
+ import { sanitizeLine } from "../security/sanitize.js";
5
6
  function packageCount(n) {
6
7
  return n === 1 ? "1 package" : `${n} packages`;
7
8
  }
@@ -15,7 +16,7 @@ export const LiveInstall = ({ view }) => {
15
16
  ? "DG starting protection…"
16
17
  : view.resolvedTotal !== undefined && view.total <= view.resolvedTotal
17
18
  ? `DG verifying ${view.total}/${view.resolvedTotal}…`
18
- : `DG verifying ${packageCount(view.total)}…`] }), view.current ? _jsxs(Text, { dimColor: true, children: [" ", view.current] }) : null] }));
19
+ : `DG verifying ${packageCount(view.total)}…`] }), view.current ? _jsxs(Text, { dimColor: true, children: [" ", sanitizeLine(view.current)] }) : null] }));
19
20
  }
20
21
  if (view.total === 0 && !view.blocked) {
21
22
  return null;
@@ -30,7 +31,7 @@ export const LiveInstall = ({ view }) => {
30
31
  }
31
32
  const total = view.verified + view.flagged;
32
33
  if (view.flagged > 0) {
33
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: "yellow", children: ["\u26A0 DG verified ", packageCount(total), " \u2014 ", view.flagged, " flagged"] }), (view.flaggedItems ?? []).map((item, index) => (_jsxs(Text, { dimColor: true, children: [" ", item.packageName, " ", item.reason] }, `${item.packageName}-${index}`)))] }));
34
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: "yellow", children: ["\u26A0 DG verified ", packageCount(total), " \u2014 ", view.flagged, " flagged"] }), (view.flaggedItems ?? []).map((item, index) => (_jsxs(Text, { dimColor: true, children: [" ", sanitizeLine(item.packageName), " ", sanitizeLine(item.reason)] }, `${item.packageName}-${index}`)))] }));
34
35
  }
35
36
  return (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: "green", children: ["\u2713 DG verified ", packageCount(total), " \u2014 clean"] }) }));
36
37
  };