@westbayberry/dg 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +113 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +113 -0
  6. package/dist/agents/gemini.js +107 -0
  7. package/dist/agents/persistence.js +285 -0
  8. package/dist/agents/registry.js +127 -0
  9. package/dist/agents/types.js +1 -0
  10. package/dist/agents/windsurf.js +93 -0
  11. package/dist/api/analyze.js +1 -1
  12. package/dist/audit/detectors.js +0 -11
  13. package/dist/audit/events.js +5 -21
  14. package/dist/audit-ui/AuditApp.js +2 -0
  15. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  16. package/dist/audit-ui/export.js +0 -4
  17. package/dist/audit-ui/format.js +0 -3
  18. package/dist/audit-ui/launch.js +10 -1
  19. package/dist/auth/device-login.js +4 -5
  20. package/dist/auth/login-app.js +7 -7
  21. package/dist/auth/store.js +8 -3
  22. package/dist/bin/dg.js +59 -52
  23. package/dist/commands/agents.js +231 -0
  24. package/dist/commands/audit.js +22 -3
  25. package/dist/commands/config.js +15 -7
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +23 -9
  28. package/dist/commands/doctor.js +1 -1
  29. package/dist/commands/help.js +1 -1
  30. package/dist/commands/licenses.js +10 -22
  31. package/dist/commands/logout.js +4 -11
  32. package/dist/commands/router.js +6 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +1 -1
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/types.js +1 -0
  38. package/dist/commands/update.js +17 -8
  39. package/dist/commands/verify.js +8 -5
  40. package/dist/config/settings.js +14 -44
  41. package/dist/decisions/remember-prompt.js +5 -10
  42. package/dist/export-ui/ExportDialog.js +198 -0
  43. package/dist/install-ui/LiveInstall.js +2 -2
  44. package/dist/install-ui/block-render.js +1 -1
  45. package/dist/install-ui/prep-spinner.js +32 -0
  46. package/dist/launcher/agent-check.js +466 -0
  47. package/dist/launcher/agent-hook-exec.js +70 -0
  48. package/dist/launcher/agent-hook-io.js +31 -0
  49. package/dist/launcher/cargo-cache.js +40 -0
  50. package/dist/launcher/classify.js +17 -6
  51. package/dist/launcher/env.js +71 -24
  52. package/dist/launcher/install-preflight.js +51 -14
  53. package/dist/launcher/live-install.js +25 -5
  54. package/dist/launcher/output-redaction.js +7 -4
  55. package/dist/launcher/preflight-prompt.js +14 -1
  56. package/dist/launcher/run.js +89 -86
  57. package/dist/launcher/spawn-invocation.js +21 -0
  58. package/dist/policy/cooldown.js +18 -5
  59. package/dist/policy/evaluate.js +5 -6
  60. package/dist/policy/pypi-name.js +17 -0
  61. package/dist/presentation/mode.js +3 -2
  62. package/dist/presentation/package-page.js +9 -0
  63. package/dist/presentation/theme.js +7 -7
  64. package/dist/project/dgfile.js +145 -6
  65. package/dist/proxy/auth.js +42 -0
  66. package/dist/proxy/ca.js +29 -9
  67. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  68. package/dist/proxy/enforcement.js +55 -16
  69. package/dist/proxy/metadata-map.js +41 -3
  70. package/dist/proxy/preverified.js +55 -0
  71. package/dist/proxy/server.js +448 -49
  72. package/dist/proxy/worker.js +16 -1
  73. package/dist/publish-set/collect.js +1 -4
  74. package/dist/publish-set/npm.js +8 -5
  75. package/dist/publish-set/pack.js +9 -3
  76. package/dist/runtime/cli.js +0 -4
  77. package/dist/runtime/fatal.js +31 -0
  78. package/dist/runtime/first-run.js +12 -11
  79. package/dist/runtime/node-version.js +43 -6
  80. package/dist/runtime/nudges.js +35 -2
  81. package/dist/sbom/cyclonedx.js +211 -0
  82. package/dist/sbom-ui/SbomApp.js +158 -0
  83. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  84. package/dist/sbom-ui/components/SbomList.js +52 -0
  85. package/dist/sbom-ui/inventory.js +128 -0
  86. package/dist/sbom-ui/launch.js +51 -0
  87. package/dist/sbom-ui/run.js +55 -0
  88. package/dist/sbom-ui/store.js +26 -0
  89. package/dist/scan/collect.js +10 -6
  90. package/dist/scan/command.js +17 -10
  91. package/dist/scan/discovery.js +11 -2
  92. package/dist/scan/render.js +28 -4
  93. package/dist/scan/scanner-report.js +15 -9
  94. package/dist/scan/staged.js +2 -1
  95. package/dist/scan-ui/LegacyApp.js +9 -13
  96. package/dist/scan-ui/alt-screen.js +5 -8
  97. package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
  98. package/dist/scan-ui/components/ProgressBar.js +3 -14
  99. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  100. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  101. package/dist/scan-ui/components/SetupBanner.js +0 -6
  102. package/dist/scan-ui/format-helpers.js +61 -5
  103. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  104. package/dist/scan-ui/hooks/useScan.js +16 -2
  105. package/dist/scan-ui/launch.js +7 -4
  106. package/dist/scan-ui/shims.js +11 -4
  107. package/dist/scripts/detect.js +7 -2
  108. package/dist/scripts/gate.js +1 -1
  109. package/dist/service/state.js +27 -8
  110. package/dist/service/trust-refresh.js +92 -0
  111. package/dist/service/worker.js +23 -1
  112. package/dist/setup/activate-shell.js +28 -0
  113. package/dist/setup/git-hook.js +49 -4
  114. package/dist/setup/plan.js +73 -39
  115. package/dist/setup-ui/gate.js +39 -0
  116. package/dist/setup-ui/offer.js +42 -0
  117. package/dist/setup-ui/selector.js +27 -0
  118. package/dist/setup-ui/tasks.js +56 -0
  119. package/dist/setup-ui/wizard.js +225 -0
  120. package/dist/state/cooldown-held.js +66 -0
  121. package/dist/state/index.js +1 -0
  122. package/dist/state/locks.js +4 -2
  123. package/dist/state/store.js +2 -1
  124. package/dist/util/external-tool.js +25 -0
  125. package/dist/util/git.js +10 -3
  126. package/dist/util/report-writer.js +57 -0
  127. package/dist/verify/local.js +240 -42
  128. package/dist/verify/package-check.js +84 -28
  129. package/dist/verify/preflight.js +242 -49
  130. package/dist/verify/render.js +15 -1
  131. package/npm-shrinkwrap.json +2383 -0
  132. package/package.json +6 -8
  133. package/NOTICE +0 -5
  134. package/dist/commands/completion.js +0 -117
  135. package/dist/commands/explain.js +0 -236
  136. package/dist/commands/unavailable.js +0 -11
  137. package/dist/scripts/rebuild.js +0 -28
  138. package/dist/telemetry/events.js +0 -40
@@ -4,12 +4,49 @@ 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
+ const DG_FILE_LOCK_STALE_MS = 10_000;
10
+ const DG_FILE_LOCK_TIMEOUT_MS = 5_000;
11
+ export function withDgFileLock(root, env, action) {
12
+ const name = `dgfile-${createHash("sha1").update(root).digest("hex").slice(0, 16)}`;
13
+ const lock = acquireLockSyncWithRetry(resolveDgPaths(env), name, {
14
+ staleMs: DG_FILE_LOCK_STALE_MS,
15
+ timeoutMs: DG_FILE_LOCK_TIMEOUT_MS
16
+ });
17
+ try {
18
+ return action();
19
+ }
20
+ finally {
21
+ lock.release();
22
+ }
23
+ }
24
+ // Serialized read-modify-write: re-reads dg.json under the lock so a concurrent
25
+ // cooldown/decisions writer can never clobber the other's section.
26
+ export function mutateDgFile(root, env, mutate) {
27
+ return withDgFileLock(root, env, () => {
28
+ const file = loadDgFile(root);
29
+ if (!file.readable) {
30
+ throw new Error(`refusing to rewrite ${file.path}: ${file.failure ?? "unreadable"}`);
31
+ }
32
+ const next = mutate(file);
33
+ saveDgFile(next);
34
+ return next;
35
+ });
36
+ }
37
+ function canonicalExemptionName(ecosystem, name) {
38
+ return canonicalCooldownName(ecosystem, name);
39
+ }
40
+ export class CooldownExemptionCapError extends Error {
41
+ }
7
42
  export const DG_FILE_NAME = "dg.json";
8
43
  export const DECISION_ENTRY_CAP = 500;
44
+ export const COOLDOWN_EXEMPTION_CAP = 500;
9
45
  export const DECISION_REASON_MAX = 500;
10
- const KNOWN_TOP_LEVEL_KEYS = new Set(["version", "scriptApprovals", "decisions"]);
46
+ const KNOWN_TOP_LEVEL_KEYS = new Set(["version", "scriptApprovals", "decisions", "cooldownExemptions"]);
11
47
  const KNOWN_SCRIPT_APPROVAL_KEYS = new Set(["npm", "observed"]);
12
48
  const SCRIPT_HOOKS = ["preinstall", "install", "postinstall", "gyp"];
49
+ const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
13
50
  export function dgFilePath(root) {
14
51
  return join(root, DG_FILE_NAME);
15
52
  }
@@ -22,7 +59,7 @@ export function findProjectRoot(cwd, env = process.env) {
22
59
  }
23
60
  }
24
61
  export function emptyDgFile(path) {
25
- return { path, exists: false, readable: true, raw: {}, decisions: [], scriptApprovals: emptyScriptApprovals() };
62
+ return { path, exists: false, readable: true, raw: {}, decisions: [], cooldownExemptions: [], scriptApprovals: emptyScriptApprovals() };
26
63
  }
27
64
  export function loadDgFile(root) {
28
65
  const path = dgFilePath(root);
@@ -61,6 +98,20 @@ export function loadDgFile(root) {
61
98
  decisions.push(parsed);
62
99
  }
63
100
  }
101
+ const exemptionsRaw = raw.cooldownExemptions;
102
+ if (exemptionsRaw !== undefined && !Array.isArray(exemptionsRaw)) {
103
+ return failOpen(path, "cooldownExemptions must be an array");
104
+ }
105
+ if (Array.isArray(exemptionsRaw) && exemptionsRaw.length > COOLDOWN_EXEMPTION_CAP) {
106
+ return failOpen(path, `more than ${COOLDOWN_EXEMPTION_CAP} cooldownExemptions`);
107
+ }
108
+ const cooldownExemptions = [];
109
+ for (const entry of Array.isArray(exemptionsRaw) ? exemptionsRaw : []) {
110
+ const parsed = parseCooldownExemption(entry);
111
+ if (parsed) {
112
+ cooldownExemptions.push(parsed);
113
+ }
114
+ }
64
115
  const approvals = approvalsRaw ?? {};
65
116
  return {
66
117
  path,
@@ -68,6 +119,7 @@ export function loadDgFile(root) {
68
119
  readable: true,
69
120
  raw,
70
121
  decisions,
122
+ cooldownExemptions,
71
123
  scriptApprovals: {
72
124
  npm: parseEntryMap(approvals.npm, parseApprovalEntry),
73
125
  observed: parseEntryMap(approvals.observed, parseObservedEntry),
@@ -92,6 +144,85 @@ export function appendDecisions(file, additions, now = new Date()) {
92
144
  export function removeDecisions(file, ids) {
93
145
  return { ...file, decisions: file.decisions.filter((entry) => !ids.has(entry.id)) };
94
146
  }
147
+ export function appendCooldownExemptions(file, additions, now = new Date()) {
148
+ const canonicalAll = additions.map((a) => ({ ...a, name: canonicalExemptionName(a.ecosystem, a.name) }));
149
+ const canonical = [...new Map(canonicalAll.map((a) => [`${a.ecosystem}:${a.name}`, a])).values()];
150
+ const priorExtra = new Map(file.cooldownExemptions.map((e) => [`${e.ecosystem}:${e.name}`, e.extra]));
151
+ const keep = file.cooldownExemptions.filter((e) => !canonical.some((a) => a.ecosystem === e.ecosystem && a.name === e.name));
152
+ const added = canonical.map((a) => {
153
+ const carried = priorExtra.get(`${a.ecosystem}:${a.name}`);
154
+ return {
155
+ ...a,
156
+ reason: a.reason.slice(0, DECISION_REASON_MAX),
157
+ acceptedAt: a.acceptedAt ?? now.toISOString(),
158
+ ...(carried !== undefined ? { extra: carried } : {})
159
+ };
160
+ });
161
+ const next = [...keep, ...added];
162
+ if (next.length > COOLDOWN_EXEMPTION_CAP) {
163
+ const live = next.filter((e) => cooldownExemptionActive(e, now));
164
+ if (live.length > COOLDOWN_EXEMPTION_CAP) {
165
+ throw new CooldownExemptionCapError(`cooldown exemptions would exceed the ${COOLDOWN_EXEMPTION_CAP} cap; run 'dg cooldown prune' or remove some with 'dg cooldown rm'`);
166
+ }
167
+ return { ...file, cooldownExemptions: live };
168
+ }
169
+ return { ...file, cooldownExemptions: next };
170
+ }
171
+ export function removeCooldownExemptions(file, predicate) {
172
+ return { ...file, cooldownExemptions: file.cooldownExemptions.filter((e) => !predicate(e)) };
173
+ }
174
+ // Active when there is no expiry, or the expiry parses to a future instant. A
175
+ // present-but-unparseable expiry fails CLOSED (treated as expired) so a typo'd
176
+ // date can never become a permanent cooldown bypass.
177
+ export function cooldownExemptionActive(e, now = new Date()) {
178
+ if (!e.expiresAt) {
179
+ return true;
180
+ }
181
+ const expiry = Date.parse(e.expiresAt);
182
+ return Number.isFinite(expiry) && expiry > now.getTime();
183
+ }
184
+ function serializeCooldownExemption(e) {
185
+ return {
186
+ ...e.extra,
187
+ ecosystem: e.ecosystem,
188
+ name: e.name,
189
+ reason: e.reason,
190
+ acceptedBy: e.acceptedBy,
191
+ acceptedAt: e.acceptedAt,
192
+ ...(e.expiresAt ? { expiresAt: e.expiresAt } : {})
193
+ };
194
+ }
195
+ const EXEMPTION_FIELDS = new Set(["ecosystem", "name", "reason", "acceptedBy", "acceptedAt", "expiresAt"]);
196
+ export function parseCooldownExemption(value) {
197
+ if (!isPlainObject(value)) {
198
+ return null;
199
+ }
200
+ const ecosystem = value.ecosystem;
201
+ if (ecosystem !== "npm" && ecosystem !== "pypi" && ecosystem !== "cargo") {
202
+ return null;
203
+ }
204
+ const rawName = value.name;
205
+ if (typeof rawName !== "string" || rawName.length === 0 || /[\u0000-\u001f\u007f\s*]/u.test(rawName)) {
206
+ return null;
207
+ }
208
+ const name = canonicalExemptionName(ecosystem, rawName);
209
+ const extra = {};
210
+ for (const [key, field] of Object.entries(value)) {
211
+ if (!EXEMPTION_FIELDS.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
212
+ extra[key] = field;
213
+ }
214
+ }
215
+ const expiresAt = value.expiresAt;
216
+ return {
217
+ ecosystem,
218
+ name,
219
+ reason: typeof value.reason === "string" ? value.reason.slice(0, DECISION_REASON_MAX) : "",
220
+ acceptedBy: typeof value.acceptedBy === "string" && value.acceptedBy.length > 0 ? value.acceptedBy : "unknown",
221
+ acceptedAt: typeof value.acceptedAt === "string" ? value.acceptedAt : "",
222
+ ...(typeof expiresAt === "string" ? { expiresAt } : {}),
223
+ ...(Object.keys(extra).length > 0 ? { extra } : {})
224
+ };
225
+ }
95
226
  export function saveDgFile(file) {
96
227
  if (!file.readable) {
97
228
  throw new Error(`refusing to rewrite ${file.path}: ${file.failure ?? "unreadable"}`);
@@ -100,6 +231,9 @@ export function saveDgFile(file) {
100
231
  if (file.decisions.length > 0) {
101
232
  top.decisions = file.decisions.map(serializeDecisionEntry);
102
233
  }
234
+ if (file.cooldownExemptions.length > 0) {
235
+ top.cooldownExemptions = file.cooldownExemptions.map(serializeCooldownExemption);
236
+ }
103
237
  const approvals = { ...file.scriptApprovals.unknownKeys };
104
238
  if (Object.keys(file.scriptApprovals.npm).length > 0) {
105
239
  approvals.npm = sortedRecord(file.scriptApprovals.npm);
@@ -174,7 +308,7 @@ function parseDecisionEntry(value) {
174
308
  }
175
309
  const extra = {};
176
310
  for (const [key, field] of Object.entries(value)) {
177
- if (!ENTRY_FIELDS.has(key)) {
311
+ if (!ENTRY_FIELDS.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
178
312
  extra[key] = field;
179
313
  }
180
314
  }
@@ -215,7 +349,9 @@ function parseFindings(value) {
215
349
  if (typeof severity !== "number" || !Number.isInteger(severity) || severity < 1 || severity > 5) {
216
350
  return null;
217
351
  }
218
- findings[category] = severity;
352
+ if (!UNSAFE_OBJECT_KEYS.has(category)) {
353
+ findings[category] = severity;
354
+ }
219
355
  }
220
356
  return findings;
221
357
  }
@@ -228,6 +364,9 @@ function parseEntryMap(raw, parseEntry) {
228
364
  }
229
365
  const entries = {};
230
366
  for (const [name, value] of Object.entries(raw)) {
367
+ if (UNSAFE_OBJECT_KEYS.has(name)) {
368
+ continue;
369
+ }
231
370
  const parsed = parseEntry(value);
232
371
  if (parsed !== null) {
233
372
  entries[name] = parsed;
@@ -290,7 +429,7 @@ function sortedRecord(record) {
290
429
  function unknownKeysOf(raw, known) {
291
430
  const unknown = {};
292
431
  for (const [key, value] of Object.entries(raw)) {
293
- if (!known.has(key)) {
432
+ if (!known.has(key) && !UNSAFE_OBJECT_KEYS.has(key)) {
294
433
  unknown[key] = value;
295
434
  }
296
435
  }
@@ -303,5 +442,5 @@ function emptyScriptApprovals() {
303
442
  return { npm: {}, observed: {}, unknownKeys: {} };
304
443
  }
305
444
  function failOpen(path, failure) {
306
- return { path, exists: true, readable: false, failure, raw: {}, decisions: [], scriptApprovals: emptyScriptApprovals() };
445
+ return { path, exists: true, readable: false, failure, raw: {}, decisions: [], cooldownExemptions: [], scriptApprovals: emptyScriptApprovals() };
307
446
  }
@@ -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,15 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from "node:zlib";
2
3
  export function extractRegistryMetadataIdentities(metadataUrl, response) {
4
+ const decoded = { headers: response.headers, body: decodeContentEncoding(response.headers, response.body) };
3
5
  // PyPI Simple index (PEP 503 HTML or PEP 691 JSON) — the pip/uv/pipx flow.
4
6
  if (isPypiSimpleIndexUrl(metadataUrl)) {
5
- return extractPypiSimpleIdentities(metadataUrl, response);
7
+ return extractPypiSimpleIdentities(metadataUrl, decoded);
6
8
  }
7
- if (!looksLikeJson(response.headers["content-type"])) {
9
+ if (!looksLikeJson(decoded.headers["content-type"])) {
8
10
  return [];
9
11
  }
10
- const parsed = parseJson(response.body);
12
+ const parsed = parseJson(decoded.body);
11
13
  if (!isRecord(parsed)) {
12
14
  return [];
13
15
  }
@@ -16,6 +18,42 @@ export function extractRegistryMetadataIdentities(metadataUrl, response) {
16
18
  ...extractPypiIdentities(metadataUrl, parsed)
17
19
  ];
18
20
  }
21
+ // Content-Encoding lists encodings in the order they were applied; decode in
22
+ // reverse. Any unknown token or decode failure returns the body untouched so
23
+ // the response falls back to artifact handling instead of crashing the proxy.
24
+ function decodeContentEncoding(headers, body) {
25
+ const raw = headers["content-encoding"];
26
+ const encodings = (Array.isArray(raw) ? raw.join(",") : String(raw ?? ""))
27
+ .split(",")
28
+ .map((token) => token.trim().toLowerCase())
29
+ .filter((token) => token.length > 0 && token !== "identity");
30
+ let decoded = body;
31
+ for (const encoding of encodings.reverse()) {
32
+ try {
33
+ if (encoding === "gzip" || encoding === "x-gzip") {
34
+ decoded = gunzipSync(decoded);
35
+ }
36
+ else if (encoding === "br") {
37
+ decoded = brotliDecompressSync(decoded);
38
+ }
39
+ else if (encoding === "deflate") {
40
+ try {
41
+ decoded = inflateSync(decoded);
42
+ }
43
+ catch {
44
+ decoded = inflateRawSync(decoded);
45
+ }
46
+ }
47
+ else {
48
+ return body;
49
+ }
50
+ }
51
+ catch {
52
+ return body;
53
+ }
54
+ }
55
+ return decoded;
56
+ }
19
57
  // A registry INDEX/metadata request (not an artifact download). These must be
20
58
  // passed through untouched — never verified as a package — so e.g. pip's
21
59
  // `pypi.org/simple/<pkg>/` index is not mistaken for a package named "simple".
@@ -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
+ }