@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
@@ -4,12 +4,50 @@ import { userInfo } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { gitTrimmed } from "../util/git.js";
6
6
  import { writeJsonAtomic } from "../util/json-file.js";
7
+ import { canonicalCooldownName } from "../policy/pypi-name.js";
8
+ import { acquireLockSyncWithRetry, resolveDgPaths } from "../state/index.js";
9
+ import { stampAuthoredEntries } from "./override-trust.js";
10
+ const DG_FILE_LOCK_STALE_MS = 10_000;
11
+ const DG_FILE_LOCK_TIMEOUT_MS = 5_000;
12
+ export function withDgFileLock(root, env, action) {
13
+ const name = `dgfile-${createHash("sha1").update(root).digest("hex").slice(0, 16)}`;
14
+ const lock = acquireLockSyncWithRetry(resolveDgPaths(env), name, {
15
+ staleMs: DG_FILE_LOCK_STALE_MS,
16
+ timeoutMs: DG_FILE_LOCK_TIMEOUT_MS
17
+ });
18
+ try {
19
+ return action();
20
+ }
21
+ finally {
22
+ lock.release();
23
+ }
24
+ }
25
+ // Serialized read-modify-write: re-reads dg.json under the lock so a concurrent
26
+ // cooldown/decisions writer can never clobber the other's section.
27
+ export function mutateDgFile(root, env, mutate) {
28
+ return withDgFileLock(root, env, () => {
29
+ const file = loadDgFile(root);
30
+ if (!file.readable) {
31
+ throw new Error(`refusing to rewrite ${file.path}: ${file.failure ?? "unreadable"}`);
32
+ }
33
+ const next = stampAuthoredEntries(mutate(file), file, root, env);
34
+ saveDgFile(next);
35
+ return next;
36
+ });
37
+ }
38
+ function canonicalExemptionName(ecosystem, name) {
39
+ return canonicalCooldownName(ecosystem, name);
40
+ }
41
+ export class CooldownExemptionCapError extends Error {
42
+ }
7
43
  export const DG_FILE_NAME = "dg.json";
8
44
  export const DECISION_ENTRY_CAP = 500;
45
+ export const COOLDOWN_EXEMPTION_CAP = 500;
9
46
  export const DECISION_REASON_MAX = 500;
10
- const KNOWN_TOP_LEVEL_KEYS = new Set(["version", "scriptApprovals", "decisions"]);
47
+ const KNOWN_TOP_LEVEL_KEYS = new Set(["version", "scriptApprovals", "decisions", "cooldownExemptions"]);
11
48
  const KNOWN_SCRIPT_APPROVAL_KEYS = new Set(["npm", "observed"]);
12
49
  const SCRIPT_HOOKS = ["preinstall", "install", "postinstall", "gyp"];
50
+ const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
13
51
  export function dgFilePath(root) {
14
52
  return join(root, DG_FILE_NAME);
15
53
  }
@@ -22,7 +60,7 @@ export function findProjectRoot(cwd, env = process.env) {
22
60
  }
23
61
  }
24
62
  export function emptyDgFile(path) {
25
- return { path, exists: false, readable: true, raw: {}, decisions: [], scriptApprovals: emptyScriptApprovals() };
63
+ return { path, exists: false, readable: true, raw: {}, decisions: [], cooldownExemptions: [], scriptApprovals: emptyScriptApprovals() };
26
64
  }
27
65
  export function loadDgFile(root) {
28
66
  const path = dgFilePath(root);
@@ -61,6 +99,20 @@ export function loadDgFile(root) {
61
99
  decisions.push(parsed);
62
100
  }
63
101
  }
102
+ const exemptionsRaw = raw.cooldownExemptions;
103
+ if (exemptionsRaw !== undefined && !Array.isArray(exemptionsRaw)) {
104
+ return failOpen(path, "cooldownExemptions must be an array");
105
+ }
106
+ if (Array.isArray(exemptionsRaw) && exemptionsRaw.length > COOLDOWN_EXEMPTION_CAP) {
107
+ return failOpen(path, `more than ${COOLDOWN_EXEMPTION_CAP} cooldownExemptions`);
108
+ }
109
+ const cooldownExemptions = [];
110
+ for (const entry of Array.isArray(exemptionsRaw) ? exemptionsRaw : []) {
111
+ const parsed = parseCooldownExemption(entry);
112
+ if (parsed) {
113
+ cooldownExemptions.push(parsed);
114
+ }
115
+ }
64
116
  const approvals = approvalsRaw ?? {};
65
117
  return {
66
118
  path,
@@ -68,6 +120,7 @@ export function loadDgFile(root) {
68
120
  readable: true,
69
121
  raw,
70
122
  decisions,
123
+ cooldownExemptions,
71
124
  scriptApprovals: {
72
125
  npm: parseEntryMap(approvals.npm, parseApprovalEntry),
73
126
  observed: parseEntryMap(approvals.observed, parseObservedEntry),
@@ -92,6 +145,85 @@ export function appendDecisions(file, additions, now = new Date()) {
92
145
  export function removeDecisions(file, ids) {
93
146
  return { ...file, decisions: file.decisions.filter((entry) => !ids.has(entry.id)) };
94
147
  }
148
+ export function appendCooldownExemptions(file, additions, now = new Date()) {
149
+ const canonicalAll = additions.map((a) => ({ ...a, name: canonicalExemptionName(a.ecosystem, a.name) }));
150
+ const canonical = [...new Map(canonicalAll.map((a) => [`${a.ecosystem}:${a.name}`, a])).values()];
151
+ const priorExtra = new Map(file.cooldownExemptions.map((e) => [`${e.ecosystem}:${e.name}`, e.extra]));
152
+ const keep = file.cooldownExemptions.filter((e) => !canonical.some((a) => a.ecosystem === e.ecosystem && a.name === e.name));
153
+ const added = canonical.map((a) => {
154
+ const carried = priorExtra.get(`${a.ecosystem}:${a.name}`);
155
+ return {
156
+ ...a,
157
+ reason: a.reason.slice(0, DECISION_REASON_MAX),
158
+ acceptedAt: a.acceptedAt ?? now.toISOString(),
159
+ ...(carried !== undefined ? { extra: carried } : {})
160
+ };
161
+ });
162
+ const next = [...keep, ...added];
163
+ if (next.length > COOLDOWN_EXEMPTION_CAP) {
164
+ const live = next.filter((e) => cooldownExemptionActive(e, now));
165
+ if (live.length > COOLDOWN_EXEMPTION_CAP) {
166
+ throw new CooldownExemptionCapError(`cooldown exemptions would exceed the ${COOLDOWN_EXEMPTION_CAP} cap; run 'dg cooldown prune' or remove some with 'dg cooldown rm'`);
167
+ }
168
+ return { ...file, cooldownExemptions: live };
169
+ }
170
+ return { ...file, cooldownExemptions: next };
171
+ }
172
+ export function removeCooldownExemptions(file, predicate) {
173
+ return { ...file, cooldownExemptions: file.cooldownExemptions.filter((e) => !predicate(e)) };
174
+ }
175
+ // Active when there is no expiry, or the expiry parses to a future instant. A
176
+ // present-but-unparseable expiry fails CLOSED (treated as expired) so a typo'd
177
+ // date can never become a permanent cooldown bypass.
178
+ export function cooldownExemptionActive(e, now = new Date()) {
179
+ if (!e.expiresAt) {
180
+ return true;
181
+ }
182
+ const expiry = Date.parse(e.expiresAt);
183
+ return Number.isFinite(expiry) && expiry > now.getTime();
184
+ }
185
+ function serializeCooldownExemption(e) {
186
+ return {
187
+ ...e.extra,
188
+ ecosystem: e.ecosystem,
189
+ name: e.name,
190
+ reason: e.reason,
191
+ acceptedBy: e.acceptedBy,
192
+ acceptedAt: e.acceptedAt,
193
+ ...(e.expiresAt ? { expiresAt: e.expiresAt } : {})
194
+ };
195
+ }
196
+ const EXEMPTION_FIELDS = new Set(["ecosystem", "name", "reason", "acceptedBy", "acceptedAt", "expiresAt"]);
197
+ export function parseCooldownExemption(value) {
198
+ if (!isPlainObject(value)) {
199
+ return null;
200
+ }
201
+ const ecosystem = value.ecosystem;
202
+ if (ecosystem !== "npm" && ecosystem !== "pypi" && ecosystem !== "cargo") {
203
+ return null;
204
+ }
205
+ const rawName = value.name;
206
+ if (typeof rawName !== "string" || rawName.length === 0 || /[\u0000-\u001f\u007f\s*]/u.test(rawName)) {
207
+ return null;
208
+ }
209
+ const name = canonicalExemptionName(ecosystem, rawName);
210
+ const extra = {};
211
+ for (const [key, field] of Object.entries(value)) {
212
+ if (!EXEMPTION_FIELDS.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
213
+ extra[key] = field;
214
+ }
215
+ }
216
+ const expiresAt = value.expiresAt;
217
+ return {
218
+ ecosystem,
219
+ name,
220
+ reason: typeof value.reason === "string" ? value.reason.slice(0, DECISION_REASON_MAX) : "",
221
+ acceptedBy: typeof value.acceptedBy === "string" && value.acceptedBy.length > 0 ? value.acceptedBy : "unknown",
222
+ acceptedAt: typeof value.acceptedAt === "string" ? value.acceptedAt : "",
223
+ ...(typeof expiresAt === "string" ? { expiresAt } : {}),
224
+ ...(Object.keys(extra).length > 0 ? { extra } : {})
225
+ };
226
+ }
95
227
  export function saveDgFile(file) {
96
228
  if (!file.readable) {
97
229
  throw new Error(`refusing to rewrite ${file.path}: ${file.failure ?? "unreadable"}`);
@@ -100,6 +232,9 @@ export function saveDgFile(file) {
100
232
  if (file.decisions.length > 0) {
101
233
  top.decisions = file.decisions.map(serializeDecisionEntry);
102
234
  }
235
+ if (file.cooldownExemptions.length > 0) {
236
+ top.cooldownExemptions = file.cooldownExemptions.map(serializeCooldownExemption);
237
+ }
103
238
  const approvals = { ...file.scriptApprovals.unknownKeys };
104
239
  if (Object.keys(file.scriptApprovals.npm).length > 0) {
105
240
  approvals.npm = sortedRecord(file.scriptApprovals.npm);
@@ -174,7 +309,7 @@ function parseDecisionEntry(value) {
174
309
  }
175
310
  const extra = {};
176
311
  for (const [key, field] of Object.entries(value)) {
177
- if (!ENTRY_FIELDS.has(key)) {
312
+ if (!ENTRY_FIELDS.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
178
313
  extra[key] = field;
179
314
  }
180
315
  }
@@ -215,7 +350,9 @@ function parseFindings(value) {
215
350
  if (typeof severity !== "number" || !Number.isInteger(severity) || severity < 1 || severity > 5) {
216
351
  return null;
217
352
  }
218
- findings[category] = severity;
353
+ if (!UNSAFE_OBJECT_KEYS.has(category)) {
354
+ findings[category] = severity;
355
+ }
219
356
  }
220
357
  return findings;
221
358
  }
@@ -228,6 +365,9 @@ function parseEntryMap(raw, parseEntry) {
228
365
  }
229
366
  const entries = {};
230
367
  for (const [name, value] of Object.entries(raw)) {
368
+ if (UNSAFE_OBJECT_KEYS.has(name)) {
369
+ continue;
370
+ }
231
371
  const parsed = parseEntry(value);
232
372
  if (parsed !== null) {
233
373
  entries[name] = parsed;
@@ -290,7 +430,7 @@ function sortedRecord(record) {
290
430
  function unknownKeysOf(raw, known) {
291
431
  const unknown = {};
292
432
  for (const [key, value] of Object.entries(raw)) {
293
- if (!known.has(key)) {
433
+ if (!known.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
294
434
  unknown[key] = value;
295
435
  }
296
436
  }
@@ -303,5 +443,5 @@ function emptyScriptApprovals() {
303
443
  return { npm: {}, observed: {}, unknownKeys: {} };
304
444
  }
305
445
  function failOpen(path, failure) {
306
- return { path, exists: true, readable: false, failure, raw: {}, decisions: [], scriptApprovals: emptyScriptApprovals() };
446
+ return { path, exists: true, readable: false, failure, raw: {}, decisions: [], cooldownExemptions: [], scriptApprovals: emptyScriptApprovals() };
307
447
  }
Binary file
@@ -0,0 +1,42 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export const PROXY_AUTH_TOKEN_FILENAME = "proxy-auth-token";
5
+ export const PROXY_AUTH_USER = "dg";
6
+ export function generateProxyAuthToken() {
7
+ return randomBytes(32).toString("hex");
8
+ }
9
+ export function proxyAuthTokenPath(sessionDir) {
10
+ return join(sessionDir, PROXY_AUTH_TOKEN_FILENAME);
11
+ }
12
+ export function writeProxyAuthToken(sessionDir, token) {
13
+ writeFileSync(proxyAuthTokenPath(sessionDir), `${token}\n`, {
14
+ encoding: "utf8",
15
+ mode: 0o600
16
+ });
17
+ }
18
+ export function readProxyAuthToken(sessionDir) {
19
+ try {
20
+ const token = readFileSync(proxyAuthTokenPath(sessionDir), "utf8").trim();
21
+ return token.length > 0 ? token : undefined;
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ export function proxyUrlWithAuth(proxyUrl, token) {
28
+ const url = new URL(proxyUrl);
29
+ return `${url.protocol}//${PROXY_AUTH_USER}:${token}@${url.host}`;
30
+ }
31
+ export function proxyAuthorizationValue(token) {
32
+ return `Basic ${Buffer.from(`${PROXY_AUTH_USER}:${token}`, "utf8").toString("base64")}`;
33
+ }
34
+ export function verifyProxyAuthorization(header, token) {
35
+ if (typeof header !== "string" || header.length === 0) {
36
+ return false;
37
+ }
38
+ return timingSafeEqual(digest(header), digest(proxyAuthorizationValue(token)));
39
+ }
40
+ function digest(value) {
41
+ return createHash("sha256").update(value, "utf8").digest();
42
+ }
package/dist/proxy/ca.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { generateKeyPairSync, randomBytes } from "node:crypto";
2
2
  import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { isIP } from "node:net";
@@ -17,6 +17,12 @@ export function createEphemeralCertificateAuthority(caPath, options = {}) {
17
17
  }
18
18
  active = issueAuthority(caPath, lifetimeMs, now(), active.certPem);
19
19
  leafs.clear();
20
+ try {
21
+ options.onRotate?.(active.certPem);
22
+ }
23
+ catch {
24
+ return;
25
+ }
20
26
  };
21
27
  setInterval(rotateIfDue, ROTATION_CHECK_INTERVAL_MS).unref();
22
28
  return {
@@ -37,11 +43,24 @@ export function createEphemeralCertificateAuthority(caPath, options = {}) {
37
43
  }
38
44
  };
39
45
  }
40
- function issueAuthority(caPath, lifetimeMs, nowMs, previousCertPem) {
41
- const keys = forge.pki.rsa.generateKeyPair({
42
- bits: 2048,
43
- workers: -1
46
+ // node:crypto keygen piped into forge via PEM so keygen never falls back to forge's pure-JS path.
47
+ function generateRsaKeyPair() {
48
+ const { privateKey, publicKey } = generateKeyPairSync("rsa", {
49
+ modulusLength: 2048
44
50
  });
51
+ return {
52
+ privateKey: forge.pki.privateKeyFromPem(privateKey.export({
53
+ type: "pkcs8",
54
+ format: "pem"
55
+ }).toString()),
56
+ publicKey: forge.pki.publicKeyFromPem(publicKey.export({
57
+ type: "spki",
58
+ format: "pem"
59
+ }).toString())
60
+ };
61
+ }
62
+ function issueAuthority(caPath, lifetimeMs, nowMs, previousCertPem) {
63
+ const keys = generateRsaKeyPair();
45
64
  const cert = forge.pki.createCertificate();
46
65
  cert.publicKey = keys.publicKey;
47
66
  cert.serialNumber = serialNumber();
@@ -65,6 +84,10 @@ function issueAuthority(caPath, lifetimeMs, nowMs, previousCertPem) {
65
84
  cRLSign: true,
66
85
  critical: true
67
86
  },
87
+ {
88
+ name: "extKeyUsage",
89
+ serverAuth: true
90
+ },
68
91
  {
69
92
  name: "subjectKeyIdentifier"
70
93
  }
@@ -102,10 +125,7 @@ function writeCaBundleAtomic(caPath, bundle) {
102
125
  }
103
126
  }
104
127
  function createLeafCertificate(host, issuer, lifetimeMs, nowMs) {
105
- const keys = forge.pki.rsa.generateKeyPair({
106
- bits: 2048,
107
- workers: -1
108
- });
128
+ const keys = generateRsaKeyPair();
109
129
  const cert = forge.pki.createCertificate();
110
130
  cert.publicKey = keys.publicKey;
111
131
  cert.serialNumber = serialNumber();
@@ -0,0 +1,33 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseCooldownExemption } from "../project/dgfile.js";
4
+ export const COOLDOWN_EXEMPTIONS_FILE = "cooldown-exemptions.json";
5
+ export const COOLDOWN_EXEMPTIONS_ENV = "DG_PROXY_COOLDOWN_EXEMPTIONS_FILE";
6
+ export function writeCooldownExemptionsFile(sessionDir, exemptions) {
7
+ if (exemptions.length === 0) {
8
+ return {};
9
+ }
10
+ try {
11
+ const path = join(sessionDir, COOLDOWN_EXEMPTIONS_FILE);
12
+ writeFileSync(path, JSON.stringify(exemptions), { encoding: "utf8", mode: 0o600 });
13
+ return { [COOLDOWN_EXEMPTIONS_ENV]: path };
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ }
19
+ export function loadCooldownExemptionsFile(path) {
20
+ if (!path) {
21
+ return [];
22
+ }
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
25
+ if (!Array.isArray(parsed)) {
26
+ return [];
27
+ }
28
+ return parsed.map((entry) => parseCooldownExemption(entry)).filter((entry) => entry !== null);
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
@@ -1,5 +1,5 @@
1
1
  import { recordAuditEvent } from "../audit/events.js";
2
- import { loadUserConfig } from "../config/settings.js";
2
+ import { DEFAULT_CONFIG, loadUserConfig } from "../config/settings.js";
3
3
  import { applyForceOverride, evaluatePackagePolicy, resolveEffectivePolicy } from "../policy/evaluate.js";
4
4
  export function parseForceOverrideRequest(raw) {
5
5
  if (!raw) {
@@ -16,8 +16,31 @@ export function parseForceOverrideRequest(raw) {
16
16
  return undefined;
17
17
  }
18
18
  }
19
+ let sideEffectFailureNoticed = false;
20
+ export function noteEnforcementSideEffectFailure(error) {
21
+ if (sideEffectFailureNoticed) {
22
+ return;
23
+ }
24
+ sideEffectFailureNoticed = true;
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ try {
27
+ process.stderr.write(`dg: local state read/write failed (${message}); the install decision is still enforced.\n`);
28
+ }
29
+ catch {
30
+ return;
31
+ }
32
+ }
33
+ function loadUserConfigOrDefault(env) {
34
+ try {
35
+ return loadUserConfig(env);
36
+ }
37
+ catch (error) {
38
+ noteEnforcementSideEffectFailure(error);
39
+ return DEFAULT_CONFIG;
40
+ }
41
+ }
19
42
  export function enforceProtectedInstall(request) {
20
- const userConfig = request.userConfig ?? loadUserConfig(request.env);
43
+ const userConfig = request.userConfig ?? loadUserConfigOrDefault(request.env);
21
44
  const policy = resolveEffectivePolicy({ userConfig });
22
45
  const proxyVerdict = request.proxyVerdict ?? failClosedVerdict(request.classification);
23
46
  const packageName = proxyVerdict.packageName ?? derivePackageName(request.classification);
@@ -39,13 +62,24 @@ export function enforceProtectedInstall(request) {
39
62
  if (baseDecision.action !== "block") {
40
63
  return baseDecision;
41
64
  }
42
- const force = applyForceOverride({
43
- packageName,
44
- currentAction: "block",
45
- force: request.forceOverride?.force ?? false,
46
- policy,
47
- ...(request.now ? { now: request.now } : {})
48
- }, request.env);
65
+ let force;
66
+ try {
67
+ force = applyForceOverride({
68
+ packageName,
69
+ currentAction: "block",
70
+ force: request.forceOverride?.force ?? false,
71
+ policy,
72
+ ...(request.now ? { now: request.now } : {})
73
+ }, request.env);
74
+ }
75
+ catch (error) {
76
+ noteEnforcementSideEffectFailure(error);
77
+ force = {
78
+ allowed: false,
79
+ reason: "force override is unavailable because dg local state could not be read or written",
80
+ auditRecorded: false
81
+ };
82
+ }
49
83
  if (force.allowed) {
50
84
  return {
51
85
  ...baseDecision,
@@ -57,13 +91,18 @@ export function enforceProtectedInstall(request) {
57
91
  }
58
92
  };
59
93
  }
60
- recordAuditEvent({
61
- type: "install.blocked",
62
- packageName,
63
- reason: baseDecision.reason,
64
- policyMode: policy.mode,
65
- createdAt: (request.now ?? new Date()).toISOString()
66
- }, request.env);
94
+ try {
95
+ recordAuditEvent({
96
+ type: "install.blocked",
97
+ packageName,
98
+ reason: baseDecision.reason,
99
+ policyMode: policy.mode,
100
+ createdAt: (request.now ?? new Date()).toISOString()
101
+ }, request.env);
102
+ }
103
+ catch (error) {
104
+ noteEnforcementSideEffectFailure(error);
105
+ }
67
106
  if (!request.forceOverride?.force) {
68
107
  return baseDecision;
69
108
  }
@@ -1,13 +1,26 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from "node:zlib";
3
+ // decodeURIComponent throws on malformed percent-encoding (e.g. a stray % in a
4
+ // registry href); fall back to the raw value so one bad entry cannot throw out of
5
+ // the whole metadata-extraction pass and drop every identity.
6
+ function safeDecodeUriComponent(value) {
7
+ try {
8
+ return decodeURIComponent(value);
9
+ }
10
+ catch {
11
+ return value;
12
+ }
13
+ }
2
14
  export function extractRegistryMetadataIdentities(metadataUrl, response) {
15
+ const decoded = { headers: response.headers, body: decodeContentEncoding(response.headers, response.body) };
3
16
  // PyPI Simple index (PEP 503 HTML or PEP 691 JSON) — the pip/uv/pipx flow.
4
17
  if (isPypiSimpleIndexUrl(metadataUrl)) {
5
- return extractPypiSimpleIdentities(metadataUrl, response);
18
+ return extractPypiSimpleIdentities(metadataUrl, decoded);
6
19
  }
7
- if (!looksLikeJson(response.headers["content-type"])) {
20
+ if (!looksLikeJson(decoded.headers["content-type"])) {
8
21
  return [];
9
22
  }
10
- const parsed = parseJson(response.body);
23
+ const parsed = parseJson(decoded.body);
11
24
  if (!isRecord(parsed)) {
12
25
  return [];
13
26
  }
@@ -16,6 +29,46 @@ export function extractRegistryMetadataIdentities(metadataUrl, response) {
16
29
  ...extractPypiIdentities(metadataUrl, parsed)
17
30
  ];
18
31
  }
32
+ // A MITM'd registry can return a tiny gzip/brotli body that expands to gigabytes;
33
+ // cap the decoded size so a decompression bomb cannot exhaust proxy memory.
34
+ const MAX_METADATA_DECODED_BYTES = 32 * 1024 * 1024;
35
+ // Content-Encoding lists encodings in the order they were applied; decode in
36
+ // reverse. Any unknown token or decode failure returns the body untouched so
37
+ // the response falls back to artifact handling instead of crashing the proxy.
38
+ function decodeContentEncoding(headers, body) {
39
+ const raw = headers["content-encoding"];
40
+ const encodings = (Array.isArray(raw) ? raw.join(",") : String(raw ?? ""))
41
+ .split(",")
42
+ .map((token) => token.trim().toLowerCase())
43
+ .filter((token) => token.length > 0 && token !== "identity");
44
+ const cap = { maxOutputLength: MAX_METADATA_DECODED_BYTES };
45
+ let decoded = body;
46
+ for (const encoding of encodings.reverse()) {
47
+ try {
48
+ if (encoding === "gzip" || encoding === "x-gzip") {
49
+ decoded = gunzipSync(decoded, cap);
50
+ }
51
+ else if (encoding === "br") {
52
+ decoded = brotliDecompressSync(decoded, cap);
53
+ }
54
+ else if (encoding === "deflate") {
55
+ try {
56
+ decoded = inflateSync(decoded, cap);
57
+ }
58
+ catch {
59
+ decoded = inflateRawSync(decoded, cap);
60
+ }
61
+ }
62
+ else {
63
+ return body;
64
+ }
65
+ }
66
+ catch {
67
+ return body;
68
+ }
69
+ }
70
+ return decoded;
71
+ }
19
72
  // A registry INDEX/metadata request (not an artifact download). These must be
20
73
  // passed through untouched — never verified as a package — so e.g. pip's
21
74
  // `pypi.org/simple/<pkg>/` index is not mistaken for a package named "simple".
@@ -27,7 +80,8 @@ export function extractRegistryMetadataIdentities(metadataUrl, response) {
27
80
  export function isRegistryIndexRequest(url) {
28
81
  return (isPypiSimpleIndexUrl(url) ||
29
82
  /\/pypi\/[^/]+\/json\/?$/i.test(url.pathname) ||
30
- /\.metadata$/i.test(url.pathname) ||
83
+ // PEP 658 sidecar: only `<wheel|sdist>.metadata`, not any path ending .metadata
84
+ /\.(whl|tar\.gz|tgz|zip)\.metadata$/i.test(url.pathname) ||
31
85
  /^\/-\//.test(url.pathname));
32
86
  }
33
87
  function isPypiSimpleIndexUrl(url) {
@@ -65,7 +119,7 @@ function extractPypiSimpleIdentities(metadataUrl, response) {
65
119
  catch {
66
120
  continue;
67
121
  }
68
- const file = entry.filename ?? decodeURIComponent(absolute.pathname.split("/").filter(Boolean).at(-1) ?? "");
122
+ const file = entry.filename ?? safeDecodeUriComponent(absolute.pathname.split("/").filter(Boolean).at(-1) ?? "");
69
123
  const parsed = parsePypiArtifactFilename(file);
70
124
  if (!parsed) {
71
125
  continue;
@@ -201,7 +255,7 @@ function extractPypiIdentities(metadataUrl, parsed) {
201
255
  function fallbackIdentity(artifactUrl, classification) {
202
256
  const ecosystem = ecosystemForManager(classification.manager);
203
257
  const parsed = ecosystem === "pypi"
204
- ? parsePypiArtifactFilename(decodeURIComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "")) ?? parsePackageVersionFromUrl(artifactUrl)
258
+ ? parsePypiArtifactFilename(safeDecodeUriComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "")) ?? parsePackageVersionFromUrl(artifactUrl)
205
259
  : ecosystem === "cargo"
206
260
  ? parseCargoArtifactUrl(artifactUrl) ?? parsePackageVersionFromUrl(artifactUrl)
207
261
  : parsePackageVersionFromUrl(artifactUrl);
@@ -223,11 +277,11 @@ function parseCargoArtifactUrl(artifactUrl) {
223
277
  const downloadMatch = /^\/(?:api\/v1\/)?crates\/([^/]+)\/([^/]+)\/download\/?$/.exec(artifactUrl.pathname);
224
278
  if (downloadMatch && downloadMatch[1] && downloadMatch[2]) {
225
279
  return {
226
- name: decodeURIComponent(downloadMatch[1]),
227
- version: decodeURIComponent(downloadMatch[2])
280
+ name: safeDecodeUriComponent(downloadMatch[1]),
281
+ version: safeDecodeUriComponent(downloadMatch[2])
228
282
  };
229
283
  }
230
- const file = decodeURIComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "");
284
+ const file = safeDecodeUriComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "");
231
285
  if (/\.crate$/i.test(file)) {
232
286
  const stem = file.slice(0, -".crate".length);
233
287
  const match = /^(.+)-(\d[0-9A-Za-z.+-]*)$/.exec(stem);
@@ -238,7 +292,7 @@ function parseCargoArtifactUrl(artifactUrl) {
238
292
  return null;
239
293
  }
240
294
  function parsePackageVersionFromUrl(artifactUrl) {
241
- const parts = artifactUrl.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
295
+ const parts = artifactUrl.pathname.split("/").filter(Boolean).map((part) => safeDecodeUriComponent(part));
242
296
  const file = parts.at(-1) ?? artifactUrl.hostname;
243
297
  const npmPackage = npmPackageFromTarballPath(parts);
244
298
  const packageName = npmPackage ?? parts.at(-2) ?? file.replace(/\.(?:tgz|tar\.gz|zip|whl)$/i, "");
@@ -300,14 +354,14 @@ function parseNpmPackageSpec(spec) {
300
354
  return { name, version };
301
355
  }
302
356
  function packageNameFromNpmMetadataPath(metadataUrl) {
303
- const parts = metadataUrl.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
357
+ const parts = metadataUrl.pathname.split("/").filter(Boolean).map((part) => safeDecodeUriComponent(part));
304
358
  if (parts[0]?.startsWith("@") && parts[1]) {
305
359
  return `${parts[0]}/${parts[1]}`;
306
360
  }
307
361
  return parts[0] ?? metadataUrl.hostname;
308
362
  }
309
363
  function packageNameFromPypiMetadataPath(metadataUrl) {
310
- const parts = metadataUrl.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
364
+ const parts = metadataUrl.pathname.split("/").filter(Boolean).map((part) => safeDecodeUriComponent(part));
311
365
  const projectIndex = parts.findIndex((part) => part.toLowerCase() === "pypi");
312
366
  return parts[projectIndex + 1] ?? parts[0] ?? metadataUrl.hostname;
313
367
  }
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const PREVERIFIED_FILE = "preverified.json";
4
+ export function preverifiedKey(ecosystem, name, version) {
5
+ const canonicalName = ecosystem === "pypi" ? name.toLowerCase().replace(/[-_.]+/g, "-") : name;
6
+ return `${ecosystem}:${canonicalName}@${version}`;
7
+ }
8
+ export function writePreverifiedFile(sessionDir, entries) {
9
+ writeFileSync(join(sessionDir, PREVERIFIED_FILE), `${JSON.stringify(entries)}\n`, {
10
+ encoding: "utf8",
11
+ mode: 0o600
12
+ });
13
+ }
14
+ let loaded = null;
15
+ export function loadPreverifiedMap(sessionDir) {
16
+ if (loaded?.dir === sessionDir) {
17
+ return loaded.map;
18
+ }
19
+ const path = join(sessionDir, PREVERIFIED_FILE);
20
+ if (!existsSync(path)) {
21
+ return new Map();
22
+ }
23
+ const map = new Map();
24
+ try {
25
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
26
+ if (Array.isArray(parsed)) {
27
+ for (const entry of parsed) {
28
+ if (isPreverifiedEntry(entry)) {
29
+ map.set(preverifiedKey(entry.ecosystem, entry.name, entry.version), entry);
30
+ }
31
+ }
32
+ }
33
+ }
34
+ catch {
35
+ return new Map();
36
+ }
37
+ loaded = { dir: sessionDir, map };
38
+ return map;
39
+ }
40
+ export function resetPreverifiedCache() {
41
+ loaded = null;
42
+ }
43
+ function isPreverifiedEntry(value) {
44
+ if (typeof value !== "object" || value === null) {
45
+ return false;
46
+ }
47
+ const entry = value;
48
+ return ((entry.ecosystem === "npm" || entry.ecosystem === "pypi") &&
49
+ typeof entry.name === "string" && entry.name.length > 0 &&
50
+ typeof entry.version === "string" && entry.version.length > 0 &&
51
+ (entry.action === "pass" || entry.action === "warn") &&
52
+ (entry.reason === undefined || typeof entry.reason === "string") &&
53
+ (entry.scannedSha256 === undefined || typeof entry.scannedSha256 === "string") &&
54
+ typeof entry.cooldownEvaluated === "boolean");
55
+ }