@westbayberry/dg 2.0.11 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +113 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +113 -0
  6. package/dist/agents/gemini.js +107 -0
  7. package/dist/agents/persistence.js +285 -0
  8. package/dist/agents/registry.js +127 -0
  9. package/dist/agents/types.js +1 -0
  10. package/dist/agents/windsurf.js +93 -0
  11. package/dist/api/analyze.js +6 -4
  12. package/dist/audit/detectors.js +0 -11
  13. package/dist/audit/events.js +5 -21
  14. package/dist/audit-ui/AuditApp.js +2 -0
  15. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  16. package/dist/audit-ui/export.js +0 -4
  17. package/dist/audit-ui/format.js +0 -3
  18. package/dist/audit-ui/launch.js +10 -1
  19. package/dist/auth/device-login.js +4 -5
  20. package/dist/auth/login-app.js +7 -7
  21. package/dist/auth/store.js +8 -3
  22. package/dist/bin/dg.js +59 -52
  23. package/dist/commands/agents.js +231 -0
  24. package/dist/commands/audit.js +22 -3
  25. package/dist/commands/config.js +26 -10
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +169 -0
  28. package/dist/commands/doctor.js +1 -1
  29. package/dist/commands/help.js +1 -1
  30. package/dist/commands/licenses.js +10 -22
  31. package/dist/commands/logout.js +4 -11
  32. package/dist/commands/router.js +8 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +3 -2
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/status.js +5 -2
  38. package/dist/commands/types.js +1 -0
  39. package/dist/commands/update.js +17 -8
  40. package/dist/commands/verify.js +8 -5
  41. package/dist/config/settings.js +154 -65
  42. package/dist/decisions/apply.js +128 -0
  43. package/dist/decisions/remember-prompt.js +92 -0
  44. package/dist/export-ui/ExportDialog.js +198 -0
  45. package/dist/install-ui/LiveInstall.js +2 -2
  46. package/dist/install-ui/block-render.js +21 -4
  47. package/dist/install-ui/prep-spinner.js +32 -0
  48. package/dist/install-ui/prompt.js +14 -0
  49. package/dist/launcher/agent-check.js +466 -0
  50. package/dist/launcher/agent-hook-exec.js +70 -0
  51. package/dist/launcher/agent-hook-io.js +31 -0
  52. package/dist/launcher/cargo-cache.js +40 -0
  53. package/dist/launcher/classify.js +17 -6
  54. package/dist/launcher/env.js +71 -24
  55. package/dist/launcher/install-preflight.js +167 -17
  56. package/dist/launcher/live-install.js +25 -5
  57. package/dist/launcher/output-redaction.js +7 -4
  58. package/dist/launcher/preflight-prompt.js +43 -3
  59. package/dist/launcher/run.js +100 -86
  60. package/dist/launcher/spawn-invocation.js +21 -0
  61. package/dist/policy/cooldown.js +117 -0
  62. package/dist/policy/evaluate.js +5 -21
  63. package/dist/policy/pypi-name.js +17 -0
  64. package/dist/presentation/mode.js +3 -2
  65. package/dist/presentation/package-page.js +9 -0
  66. package/dist/presentation/provenance.js +23 -0
  67. package/dist/presentation/theme.js +7 -7
  68. package/dist/project/dgfile.js +446 -0
  69. package/dist/proxy/auth.js +42 -0
  70. package/dist/proxy/ca.js +29 -9
  71. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  72. package/dist/proxy/enforcement.js +57 -17
  73. package/dist/proxy/metadata-map.js +66 -4
  74. package/dist/proxy/preverified.js +55 -0
  75. package/dist/proxy/server.js +473 -45
  76. package/dist/proxy/worker.js +16 -1
  77. package/dist/publish-set/collect.js +1 -4
  78. package/dist/publish-set/npm.js +8 -5
  79. package/dist/publish-set/pack.js +9 -3
  80. package/dist/runtime/cli.js +0 -4
  81. package/dist/runtime/fatal.js +31 -0
  82. package/dist/runtime/first-run.js +12 -11
  83. package/dist/runtime/node-version.js +43 -6
  84. package/dist/runtime/nudges.js +35 -2
  85. package/dist/sbom/cyclonedx.js +211 -0
  86. package/dist/sbom-ui/SbomApp.js +158 -0
  87. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  88. package/dist/sbom-ui/components/SbomList.js +52 -0
  89. package/dist/sbom-ui/inventory.js +128 -0
  90. package/dist/sbom-ui/launch.js +51 -0
  91. package/dist/sbom-ui/run.js +55 -0
  92. package/dist/sbom-ui/store.js +26 -0
  93. package/dist/scan/collect.js +10 -6
  94. package/dist/scan/command.js +51 -17
  95. package/dist/scan/discovery.js +11 -2
  96. package/dist/scan/render.js +63 -8
  97. package/dist/scan/scanner-report.js +42 -9
  98. package/dist/scan/staged.js +71 -11
  99. package/dist/scan-ui/LegacyApp.js +12 -16
  100. package/dist/scan-ui/alt-screen.js +5 -8
  101. package/dist/scan-ui/components/InteractiveResultsView.js +193 -123
  102. package/dist/scan-ui/components/ProgressBar.js +3 -14
  103. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  104. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  105. package/dist/scan-ui/components/SetupBanner.js +0 -6
  106. package/dist/scan-ui/format-helpers.js +61 -5
  107. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  108. package/dist/scan-ui/hooks/useScan.js +46 -4
  109. package/dist/scan-ui/launch.js +7 -4
  110. package/dist/scan-ui/shims.js +14 -4
  111. package/dist/scripts/detect.js +158 -0
  112. package/dist/scripts/gate.js +170 -0
  113. package/dist/service/state.js +27 -8
  114. package/dist/service/trust-refresh.js +92 -0
  115. package/dist/service/worker.js +23 -1
  116. package/dist/setup/activate-shell.js +28 -0
  117. package/dist/setup/git-hook.js +49 -4
  118. package/dist/setup/plan.js +98 -29
  119. package/dist/setup-ui/gate.js +39 -0
  120. package/dist/setup-ui/offer.js +42 -0
  121. package/dist/setup-ui/selector.js +27 -0
  122. package/dist/setup-ui/tasks.js +56 -0
  123. package/dist/setup-ui/wizard.js +225 -0
  124. package/dist/state/cooldown-held.js +66 -0
  125. package/dist/state/index.js +1 -0
  126. package/dist/state/locks.js +4 -2
  127. package/dist/state/store.js +2 -1
  128. package/dist/util/external-tool.js +25 -0
  129. package/dist/util/git.js +10 -3
  130. package/dist/util/json-file.js +24 -0
  131. package/dist/util/report-writer.js +57 -0
  132. package/dist/util/tty-prompt.js +13 -6
  133. package/dist/verify/local.js +240 -42
  134. package/dist/verify/package-check.js +86 -18
  135. package/dist/verify/preflight.js +242 -49
  136. package/dist/verify/render.js +15 -1
  137. package/npm-shrinkwrap.json +2383 -0
  138. package/package.json +14 -8
  139. package/NOTICE +0 -5
  140. package/dist/commands/completion.js +0 -116
  141. package/dist/commands/explain.js +0 -232
  142. package/dist/commands/unavailable.js +0 -11
  143. package/dist/telemetry/events.js +0 -40
@@ -1,5 +1,16 @@
1
1
  import { closeSync, openSync, readSync } from "node:fs";
2
2
  export function promptYesNo(question, defaultYes, out = process.stderr) {
3
+ const answer = promptLine(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, out);
4
+ if (answer === null) {
5
+ return null;
6
+ }
7
+ const normalized = answer.trim().toLowerCase();
8
+ if (normalized === "") {
9
+ return defaultYes;
10
+ }
11
+ return normalized === "y" || normalized === "yes";
12
+ }
13
+ export function promptLine(question, out = process.stderr) {
3
14
  let tty;
4
15
  try {
5
16
  tty = openSync("/dev/tty", "rs");
@@ -8,7 +19,7 @@ export function promptYesNo(question, defaultYes, out = process.stderr) {
8
19
  return null;
9
20
  }
10
21
  try {
11
- out.write(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `);
22
+ out.write(question);
12
23
  const byte = Buffer.alloc(1);
13
24
  let answer = "";
14
25
  for (;;) {
@@ -31,11 +42,7 @@ export function promptYesNo(question, defaultYes, out = process.stderr) {
31
42
  }
32
43
  answer += char;
33
44
  }
34
- const normalized = answer.trim().toLowerCase();
35
- if (normalized === "") {
36
- return defaultYes;
37
- }
38
- return normalized === "y" || normalized === "yes";
45
+ return answer;
39
46
  }
40
47
  finally {
41
48
  closeSync(tty);
@@ -8,6 +8,10 @@ const MAX_UNPACKED_BYTES = 250 * 1024 * 1024;
8
8
  const MAX_ARCHIVE_ENTRIES = 20000;
9
9
  const MAX_ARCHIVE_PATH_LENGTH = 240;
10
10
  const TAR_BLOCK_SIZE = 512;
11
+ const GNU_LONGNAME_TYPE = 0x4c;
12
+ const GNU_LONGLINK_TYPE = 0x4b;
13
+ const PAX_EXTENDED_TYPE = 0x78;
14
+ const PAX_GLOBAL_TYPE = 0x67;
11
15
  export function verifyLocalTarget(targetPath, cwd = process.cwd()) {
12
16
  const absoluteTarget = resolve(cwd, targetPath);
13
17
  if (!existsSync(absoluteTarget)) {
@@ -115,23 +119,73 @@ function scanTarball(bytes, path) {
115
119
  const entries = [];
116
120
  let offset = 0;
117
121
  let unpackedSizeBytes = 0;
122
+ let pendingLongName = null;
123
+ let pendingPax = null;
124
+ let globalPax = null;
118
125
  while (offset + TAR_BLOCK_SIZE <= tarBytes.length) {
119
126
  const header = tarBytes.subarray(offset, offset + TAR_BLOCK_SIZE);
120
127
  if (header.every((byte) => byte === 0)) {
121
128
  break;
122
129
  }
123
- const name = tarEntryName(header);
130
+ const rawName = tarEntryName(header);
124
131
  const size = tarEntrySize(header);
132
+ if (size === null) {
133
+ errors.push("tar archive has an unparseable entry size");
134
+ break;
135
+ }
125
136
  const bodyOffset = offset + TAR_BLOCK_SIZE;
126
137
  const nextOffset = bodyOffset + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
127
- if (name.length === 0 || nextOffset > tarBytes.length) {
138
+ if (rawName.length === 0 || nextOffset > tarBytes.length) {
128
139
  errors.push("tar archive has a malformed entry header");
129
140
  break;
130
141
  }
142
+ const body = tarBytes.subarray(bodyOffset, bodyOffset + size);
143
+ const typeflag = header.readUInt8(156);
144
+ if (typeflag === GNU_LONGNAME_TYPE) {
145
+ pendingLongName = readNullTerminated(body, 0, body.length);
146
+ if (pendingLongName.length === 0) {
147
+ errors.push("tar archive has an empty long-name header");
148
+ break;
149
+ }
150
+ offset = nextOffset;
151
+ continue;
152
+ }
153
+ if (typeflag === GNU_LONGLINK_TYPE) {
154
+ offset = nextOffset;
155
+ continue;
156
+ }
157
+ if (typeflag === PAX_EXTENDED_TYPE || typeflag === PAX_GLOBAL_TYPE) {
158
+ const records = parsePaxRecords(body);
159
+ if (records === null) {
160
+ errors.push("tar archive has an unparseable PAX extended header");
161
+ break;
162
+ }
163
+ if (typeflag === PAX_EXTENDED_TYPE) {
164
+ pendingPax = mergePaxRecords(pendingPax, records);
165
+ }
166
+ else {
167
+ globalPax = mergePaxRecords(globalPax, records);
168
+ }
169
+ offset = nextOffset;
170
+ continue;
171
+ }
172
+ const paxPath = pendingPax?.get("path") ?? globalPax?.get("path") ?? null;
173
+ if (paxPath !== null && pendingLongName !== null && paxPath !== pendingLongName) {
174
+ errors.push("tar entry name metadata is contradictory");
175
+ break;
176
+ }
177
+ const paxSize = pendingPax?.get("size") ?? null;
178
+ if (paxSize !== null && Number.parseInt(paxSize, 10) !== size) {
179
+ errors.push("tar entry size metadata is contradictory");
180
+ break;
181
+ }
182
+ const name = paxPath ?? pendingLongName ?? rawName;
183
+ pendingLongName = null;
184
+ pendingPax = null;
131
185
  unpackedSizeBytes += size;
132
186
  entries.push({
133
187
  name,
134
- body: tarBytes.subarray(bodyOffset, bodyOffset + size)
188
+ body
135
189
  });
136
190
  findings.push(...pathSafetyFindings(name));
137
191
  if (entries.length > MAX_ARCHIVE_ENTRIES) {
@@ -144,6 +198,9 @@ function scanTarball(bytes, path) {
144
198
  }
145
199
  offset = nextOffset;
146
200
  }
201
+ if (errors.length === 0 && (pendingLongName !== null || pendingPax !== null)) {
202
+ errors.push("tar archive ends with an unapplied extended header");
203
+ }
147
204
  findings.push(...packageManifestFindings(entries));
148
205
  return {
149
206
  findings,
@@ -155,56 +212,41 @@ function scanTarball(bytes, path) {
155
212
  }
156
213
  };
157
214
  }
215
+ const ZIP_LOCAL_HEADER_SIG = 0x04034b50;
216
+ const ZIP_CENTRAL_HEADER_SIG = 0x02014b50;
217
+ const ZIP_EOCD_SIG = 0x06054b50;
218
+ const ZIP_DATA_DESCRIPTOR_SIG = 0x08074b50;
219
+ const ZIP_EOCD_MIN_BYTES = 22;
220
+ const ZIP_EOCD_SEARCH_BYTES = ZIP_EOCD_MIN_BYTES + 0xffff;
158
221
  function scanZipLike(bytes, path) {
222
+ const central = readZipCentralDirectory(bytes);
223
+ if ("error" in central) {
224
+ return archiveError(`${basename(path)}: ${central.error}`);
225
+ }
226
+ const mismatch = zipLocalHeaderMismatch(bytes, central);
227
+ if (mismatch) {
228
+ return archiveError(`${basename(path)}: ${mismatch}`);
229
+ }
159
230
  const findings = [];
160
231
  const errors = [];
161
232
  const entries = [];
162
- let offset = 0;
163
233
  let unpackedSizeBytes = 0;
164
- while (offset + 30 <= bytes.length) {
165
- const signature = bytes.readUInt32LE(offset);
166
- if (signature !== 0x04034b50) {
167
- break;
168
- }
169
- const flags = bytes.readUInt16LE(offset + 6);
170
- const method = bytes.readUInt16LE(offset + 8);
171
- const compressedSize = bytes.readUInt32LE(offset + 18);
172
- const uncompressedSize = bytes.readUInt32LE(offset + 22);
173
- const fileNameLength = bytes.readUInt16LE(offset + 26);
174
- const extraLength = bytes.readUInt16LE(offset + 28);
175
- const nameStart = offset + 30;
176
- const dataStart = nameStart + fileNameLength + extraLength;
177
- const dataEnd = dataStart + compressedSize;
178
- if (dataEnd > bytes.length) {
179
- errors.push(`${basename(path)} has a malformed zip entry`);
180
- break;
181
- }
182
- const name = bytes.subarray(nameStart, nameStart + fileNameLength).toString("utf8");
183
- findings.push(...pathSafetyFindings(name));
184
- if ((flags & 0x1) !== 0) {
234
+ for (const entry of central.entries) {
235
+ findings.push(...pathSafetyFindings(entry.name));
236
+ if ((entry.flags & 0x1) !== 0) {
185
237
  findings.push({
186
238
  id: "encrypted-archive-entry",
187
239
  severity: "block",
188
240
  title: "Encrypted archive entry",
189
241
  message: "encrypted archive entries cannot be inspected locally",
190
- location: name
242
+ location: entry.name
191
243
  });
192
244
  }
193
- const body = readZipBody(bytes.subarray(dataStart, dataEnd), method, name, errors);
194
- unpackedSizeBytes += uncompressedSize;
245
+ unpackedSizeBytes += entry.uncompressedSize;
195
246
  entries.push({
196
- name,
197
- body
247
+ name: entry.name,
248
+ body: readZipEntryBody(bytes, entry, errors)
198
249
  });
199
- if ((flags & 0x8) !== 0) {
200
- findings.push({
201
- id: "zip-data-descriptor",
202
- severity: "warn",
203
- title: "Zip data descriptor",
204
- message: "zip entry uses a data descriptor, so byte-size validation is conservative",
205
- location: name
206
- });
207
- }
208
250
  if (entries.length > MAX_ARCHIVE_ENTRIES) {
209
251
  findings.push(limitFinding("archive has too many entries"));
210
252
  break;
@@ -213,7 +255,6 @@ function scanZipLike(bytes, path) {
213
255
  findings.push(limitFinding("archive expands beyond the local verification limit"));
214
256
  break;
215
257
  }
216
- offset = dataEnd;
217
258
  }
218
259
  findings.push(...packageManifestFindings(entries));
219
260
  return {
@@ -226,6 +267,121 @@ function scanZipLike(bytes, path) {
226
267
  }
227
268
  };
228
269
  }
270
+ function readZipCentralDirectory(bytes) {
271
+ const eocdOffset = findZipEndOfCentralDirectory(bytes);
272
+ if (eocdOffset === null) {
273
+ return { error: "zip end of central directory record not found" };
274
+ }
275
+ const entryCount = bytes.readUInt16LE(eocdOffset + 10);
276
+ const centralDirectorySize = bytes.readUInt32LE(eocdOffset + 12);
277
+ const centralDirectoryOffset = bytes.readUInt32LE(eocdOffset + 16);
278
+ if (entryCount === 0xffff || centralDirectorySize === 0xffffffff || centralDirectoryOffset === 0xffffffff) {
279
+ return { error: "zip64 archives are not supported by local verification" };
280
+ }
281
+ if (centralDirectoryOffset + centralDirectorySize > eocdOffset) {
282
+ return { error: "zip central directory extends past its end record" };
283
+ }
284
+ const entries = [];
285
+ let offset = centralDirectoryOffset;
286
+ for (let index = 0; index < entryCount; index += 1) {
287
+ if (offset + 46 > eocdOffset || bytes.readUInt32LE(offset) !== ZIP_CENTRAL_HEADER_SIG) {
288
+ return { error: "zip central directory is truncated or malformed" };
289
+ }
290
+ const nameLength = bytes.readUInt16LE(offset + 28);
291
+ const extraLength = bytes.readUInt16LE(offset + 30);
292
+ const commentLength = bytes.readUInt16LE(offset + 32);
293
+ if (offset + 46 + nameLength + extraLength + commentLength > eocdOffset) {
294
+ return { error: "zip central directory is truncated or malformed" };
295
+ }
296
+ entries.push({
297
+ name: bytes.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"),
298
+ flags: bytes.readUInt16LE(offset + 8),
299
+ method: bytes.readUInt16LE(offset + 10),
300
+ compressedSize: bytes.readUInt32LE(offset + 20),
301
+ uncompressedSize: bytes.readUInt32LE(offset + 24),
302
+ localHeaderOffset: bytes.readUInt32LE(offset + 42)
303
+ });
304
+ offset += 46 + nameLength + extraLength + commentLength;
305
+ }
306
+ if (offset !== centralDirectoryOffset + centralDirectorySize) {
307
+ return { error: "zip central directory size disagrees with its entries" };
308
+ }
309
+ return { entries, centralDirectoryOffset };
310
+ }
311
+ function findZipEndOfCentralDirectory(bytes) {
312
+ const searchStart = Math.max(0, bytes.length - ZIP_EOCD_SEARCH_BYTES);
313
+ for (let offset = bytes.length - ZIP_EOCD_MIN_BYTES; offset >= searchStart; offset -= 1) {
314
+ if (bytes.readUInt32LE(offset) === ZIP_EOCD_SIG) {
315
+ const commentLength = bytes.readUInt16LE(offset + 20);
316
+ if (offset + ZIP_EOCD_MIN_BYTES + commentLength === bytes.length) {
317
+ return offset;
318
+ }
319
+ }
320
+ }
321
+ return null;
322
+ }
323
+ function zipLocalHeaderMismatch(bytes, central) {
324
+ const { entries, centralDirectoryOffset } = central;
325
+ const claimed = new Map();
326
+ for (const entry of entries) {
327
+ if (claimed.has(entry.localHeaderOffset)) {
328
+ return "zip central directory lists overlapping local entries";
329
+ }
330
+ claimed.set(entry.localHeaderOffset, entry);
331
+ }
332
+ for (const entry of entries) {
333
+ const offset = entry.localHeaderOffset;
334
+ if (offset + 30 > centralDirectoryOffset || bytes.readUInt32LE(offset) !== ZIP_LOCAL_HEADER_SIG) {
335
+ return "zip central directory points at a missing local file header";
336
+ }
337
+ const flags = bytes.readUInt16LE(offset + 6);
338
+ const compressedSize = bytes.readUInt32LE(offset + 18);
339
+ const nameLength = bytes.readUInt16LE(offset + 26);
340
+ const name = bytes.subarray(offset + 30, offset + 30 + nameLength).toString("utf8");
341
+ if (name !== entry.name) {
342
+ return "zip local file header name disagrees with the central directory";
343
+ }
344
+ if ((flags & 0x8) === 0 && compressedSize !== entry.compressedSize) {
345
+ return "zip local file header size disagrees with the central directory";
346
+ }
347
+ }
348
+ let offset = bytes.length >= 4 && bytes.readUInt32LE(0) === ZIP_LOCAL_HEADER_SIG
349
+ ? 0
350
+ : entries.length > 0
351
+ ? Math.min(...entries.map((entry) => entry.localHeaderOffset))
352
+ : centralDirectoryOffset;
353
+ while (offset + 4 <= centralDirectoryOffset && bytes.readUInt32LE(offset) === ZIP_LOCAL_HEADER_SIG) {
354
+ const entry = claimed.get(offset);
355
+ if (!entry) {
356
+ return "zip has a local file entry the central directory does not list";
357
+ }
358
+ const nameLength = bytes.readUInt16LE(offset + 26);
359
+ const extraLength = bytes.readUInt16LE(offset + 28);
360
+ let next = offset + 30 + nameLength + extraLength + entry.compressedSize;
361
+ if ((entry.flags & 0x8) !== 0) {
362
+ next += next + 4 <= centralDirectoryOffset && bytes.readUInt32LE(next) === ZIP_DATA_DESCRIPTOR_SIG ? 16 : 12;
363
+ }
364
+ if (next > centralDirectoryOffset) {
365
+ return "zip local entry data extends into the central directory";
366
+ }
367
+ offset = next;
368
+ }
369
+ return null;
370
+ }
371
+ function readZipEntryBody(bytes, entry, errors) {
372
+ if ((entry.flags & 0x1) !== 0) {
373
+ return Buffer.alloc(0);
374
+ }
375
+ const headerOffset = entry.localHeaderOffset;
376
+ const nameLength = bytes.readUInt16LE(headerOffset + 26);
377
+ const extraLength = bytes.readUInt16LE(headerOffset + 28);
378
+ const dataStart = headerOffset + 30 + nameLength + extraLength;
379
+ if (dataStart + entry.compressedSize > bytes.length) {
380
+ errors.push(`${entry.name}: zip entry data is truncated`);
381
+ return Buffer.alloc(0);
382
+ }
383
+ return readZipBody(bytes.subarray(dataStart, dataStart + entry.compressedSize), entry.method, entry.name, errors);
384
+ }
229
385
  function readZipBody(data, method, name, errors) {
230
386
  if (method === 0) {
231
387
  return data;
@@ -375,9 +531,51 @@ function tarEntryName(header) {
375
531
  return prefix.length > 0 ? `${prefix}/${name}` : name;
376
532
  }
377
533
  function tarEntrySize(header) {
534
+ if ((header.readUInt8(124) & 0x80) !== 0) {
535
+ return null;
536
+ }
378
537
  const value = readNullTerminated(header, 124, 12).trim();
379
- const parsed = Number.parseInt(value || "0", 8);
380
- return Number.isFinite(parsed) ? parsed : 0;
538
+ if (value.length === 0) {
539
+ return 0;
540
+ }
541
+ if (!/^[0-7]+$/u.test(value)) {
542
+ return null;
543
+ }
544
+ return Number.parseInt(value, 8);
545
+ }
546
+ function parsePaxRecords(body) {
547
+ const records = new Map();
548
+ let offset = 0;
549
+ while (offset < body.length) {
550
+ const space = body.indexOf(0x20, offset);
551
+ if (space === -1 || space === offset) {
552
+ return null;
553
+ }
554
+ const lengthText = body.subarray(offset, space).toString("ascii");
555
+ if (!/^\d+$/u.test(lengthText)) {
556
+ return null;
557
+ }
558
+ const length = Number.parseInt(lengthText, 10);
559
+ const recordEnd = offset + length;
560
+ if (length <= space - offset + 1 || recordEnd > body.length || body.readUInt8(recordEnd - 1) !== 0x0a) {
561
+ return null;
562
+ }
563
+ const text = body.subarray(space + 1, recordEnd - 1).toString("utf8");
564
+ const equals = text.indexOf("=");
565
+ if (equals <= 0) {
566
+ return null;
567
+ }
568
+ records.set(text.slice(0, equals), text.slice(equals + 1));
569
+ offset = recordEnd;
570
+ }
571
+ return records;
572
+ }
573
+ function mergePaxRecords(existing, incoming) {
574
+ const merged = new Map(existing ?? []);
575
+ for (const [key, value] of incoming) {
576
+ merged.set(key, value);
577
+ }
578
+ return merged;
381
579
  }
382
580
  function readNullTerminated(buffer, start, length) {
383
581
  const slice = buffer.subarray(start, start + length);
@@ -2,10 +2,12 @@ import { existsSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { analyzePackages, AnalyzeError } from "../api/analyze.js";
4
4
  import { createTheme } from "../presentation/theme.js";
5
+ import { provenanceLabel, provenanceDowngradeLine } from "../presentation/provenance.js";
6
+ import { packagePageUrl } from "../presentation/package-page.js";
5
7
  import { resolvePresentation } from "../presentation/mode.js";
6
8
  import { isRemotePackageSpec, isSupportedLockfilePath } from "./preflight.js";
7
9
  import { authStatus } from "../auth/store.js";
8
- import { EXIT_TOOL_ERROR, EXIT_UNAVAILABLE } from "../commands/types.js";
10
+ import { EXIT_TOOL_ERROR, EXIT_UNAVAILABLE, EXIT_USAGE_VERDICT } from "../commands/types.js";
9
11
  const REGISTRIES = { npm: "npm", pypi: "pypi" };
10
12
  const DEEP_VERIFY_HINT = "deep verify supports npm and pypi: dg verify npm:<package> or pypi:<package>";
11
13
  function parseSpec(target) {
@@ -37,10 +39,10 @@ function parseSpec(target) {
37
39
  const version = rest.slice(at + separator.length).trim() || null;
38
40
  return { ecosystem, name, version };
39
41
  }
40
- async function resolveLatest(ecosystem, name, fetchImpl) {
42
+ export async function resolveLatest(ecosystem, name, fetchImpl) {
41
43
  try {
42
44
  if (ecosystem === "npm") {
43
- const response = await fetchImpl(`https://registry.npmjs.org/${name.replace("/", "%2F")}`);
45
+ const response = await fetchImpl(`https://registry.npmjs.org/${encodeNpmPackagePath(name)}`);
44
46
  if (!response.ok) {
45
47
  return null;
46
48
  }
@@ -58,14 +60,31 @@ async function resolveLatest(ecosystem, name, fetchImpl) {
58
60
  return null;
59
61
  }
60
62
  }
63
+ function provenanceLines(version, result, theme, withPredicate) {
64
+ if (!result.provenance) {
65
+ return [];
66
+ }
67
+ const label = withPredicate && result.provenance.predicateType
68
+ ? `provenance ${provenanceLabel(result.provenance)} · ${result.provenance.predicateType}`
69
+ : `provenance ${provenanceLabel(result.provenance)}`;
70
+ const lines = [` ${theme.paint("muted", label)}`];
71
+ const downgrade = provenanceDowngradeLine(version, result.provenance);
72
+ if (downgrade) {
73
+ lines.push(` ${theme.paint("warn", `⚠ ${downgrade}`)}`);
74
+ }
75
+ return lines;
76
+ }
77
+ function reasonGlyph(action, theme) {
78
+ return action === "block" ? theme.paint("block", "✘") : action === "warn" ? theme.paint("warn", "⚠") : theme.paint("muted", "·");
79
+ }
61
80
  function renderResult(spec, version, result, theme, verbose) {
62
81
  const action = result.action ?? "pass";
63
82
  const badge = theme.badge(action);
64
83
  const lines = [`${badge} ${result.name}@${version} (${spec.ecosystem}) ${theme.paint("muted", `score ${result.score}`)}`];
84
+ lines.push(...provenanceLines(version, result, theme, verbose));
65
85
  const reasons = verbose ? result.reasons : result.reasons.slice(0, 6);
66
86
  for (const reason of reasons) {
67
- const glyph = action === "block" ? theme.paint("block", "✘") : action === "warn" ? theme.paint("warn", "⚠") : theme.paint("muted", "·");
68
- lines.push(` ${glyph} ${reason}`);
87
+ lines.push(` ${reasonGlyph(action, theme)} ${reason}`);
69
88
  }
70
89
  if (!verbose && result.reasons.length > reasons.length) {
71
90
  lines.push(` ${theme.paint("muted", `… ${result.reasons.length - reasons.length} more — rerun with --verbose`)}`);
@@ -81,8 +100,43 @@ function renderResult(spec, version, result, theme, verbose) {
81
100
  if (result.recommendation) {
82
101
  lines.push(` ${theme.paint("muted", result.recommendation)}`);
83
102
  }
103
+ const page = packagePageUrl(spec.ecosystem, result.name);
104
+ if (page) {
105
+ lines.push(` ${theme.paint("muted", `→ ${page}`)}`);
106
+ }
107
+ return `${lines.join("\n")}\n`;
108
+ }
109
+ const FREE_REASON_CAP = 3;
110
+ const FREE_SCANS_FOOTER_THRESHOLD = 5_000;
111
+ function renderFreeResult(spec, version, result, theme, freeScansRemaining) {
112
+ const action = result.action ?? "pass";
113
+ const lines = [`${theme.badge(action)} ${result.name}@${version} (${spec.ecosystem})`];
114
+ lines.push(...provenanceLines(version, result, theme, false));
115
+ if (action === "pass") {
116
+ lines.push(` ${theme.paint("muted", result.reasons[0] ?? "no risk signals")}`);
117
+ }
118
+ else {
119
+ const reasons = result.reasons.slice(0, FREE_REASON_CAP);
120
+ for (const reason of reasons) {
121
+ lines.push(` ${reasonGlyph(action, theme)} ${reason}`);
122
+ }
123
+ if (result.reasons.length > reasons.length) {
124
+ lines.push(` ${theme.paint("muted", `… ${result.reasons.length - reasons.length} more — sign in to see all:`)} ${theme.paint("accent", "dg login")}`);
125
+ }
126
+ }
127
+ const page = packagePageUrl(spec.ecosystem, result.name);
128
+ if (page) {
129
+ lines.push(` ${theme.paint("muted", `→ ${page}`)}`);
130
+ }
131
+ lines.push(` ${theme.paint("muted", "full findings, license, JSON output:")} ${theme.paint("accent", "dg login")}`);
132
+ if (freeScansRemaining !== undefined && freeScansRemaining < FREE_SCANS_FOOTER_THRESHOLD) {
133
+ lines.push(` ${theme.paint("muted", `${freeScansRemaining.toLocaleString()} free package checks left this month`)}`);
134
+ }
84
135
  return `${lines.join("\n")}\n`;
85
136
  }
137
+ function encodeNpmPackagePath(name) {
138
+ return name.split("/").map((segment) => encodeURIComponent(segment)).join("%2f");
139
+ }
86
140
  function exitCodeFor(action) {
87
141
  if (action === "block") {
88
142
  return 2;
@@ -98,39 +152,41 @@ function exitCodeFor(action) {
98
152
  export async function runPackageCheck(target, io = {}, options = {}) {
99
153
  const fetchImpl = io.fetchImpl ?? fetch;
100
154
  const theme = createTheme(resolvePresentation().color);
101
- if (!authStatus(io.env).authenticated) {
102
- const accent = (text) => theme.paint("accent", text);
103
- const muted = (text) => theme.paint("muted", text);
155
+ const accent = (text) => theme.paint("accent", text);
156
+ const muted = (text) => theme.paint("muted", text);
157
+ const authenticated = authStatus(io.env).authenticated;
158
+ if (!authenticated && (options.format === "json" || options.outputPath)) {
104
159
  return {
105
160
  exitCode: EXIT_UNAVAILABLE,
106
161
  stdout: "",
107
- stderr: `\n ${theme.paint("warn", "⚠")} Checking a package before you install it requires ${accent("sign-in")}.\n` +
162
+ stderr: `\n ${theme.paint("warn", "⚠")} ${muted("--json and --output for registry checks require sign-in.")}\n` +
108
163
  ` ${accent("dg login")}${muted(" · see plans at")} ${accent("westbayberry.com/pricing")}\n\n`
109
164
  };
110
165
  }
111
166
  const parsed = parseSpec(target);
112
167
  if (parsed === null) {
113
168
  return {
114
- exitCode: 2,
169
+ exitCode: EXIT_USAGE_VERDICT,
115
170
  stdout: "",
116
171
  stderr: `dg verify: add a registry, e.g. dg verify npm:${target} or dg verify pypi:${target}\n`
117
172
  };
118
173
  }
119
174
  if ("error" in parsed) {
120
- return { exitCode: 2, stdout: "", stderr: `dg verify: ${parsed.error}\n` };
175
+ return { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg verify: ${parsed.error}\n` };
121
176
  }
122
177
  let version = parsed.version;
123
178
  if (!version) {
124
179
  version = await resolveLatest(parsed.ecosystem, parsed.name, fetchImpl);
125
180
  if (!version) {
126
181
  return {
127
- exitCode: 1,
182
+ exitCode: exitCodeFor("analysis_incomplete"),
128
183
  stdout: "",
129
184
  stderr: `dg verify: could not resolve the latest version of ${parsed.name} on ${parsed.ecosystem}\n`
130
185
  };
131
186
  }
132
187
  }
133
188
  let result;
189
+ let freeScansRemaining;
134
190
  try {
135
191
  const response = await analyzePackages([{ name: parsed.name, version }], {
136
192
  ecosystem: parsed.ecosystem,
@@ -138,13 +194,22 @@ export async function runPackageCheck(target, io = {}, options = {}) {
138
194
  ...(io.env ? { env: io.env } : {})
139
195
  });
140
196
  result = response.packages.find((entry) => entry.name === parsed.name) ?? response.packages[0];
197
+ freeScansRemaining = response.freeScansRemaining;
141
198
  }
142
199
  catch (error) {
200
+ if (error instanceof AnalyzeError && error.code === "quota_exceeded") {
201
+ const loginLine = authenticated ? "" : ` ${muted("Run")} ${accent("dg login")} ${muted("to connect your account.")}\n`;
202
+ return {
203
+ exitCode: exitCodeFor("analysis_incomplete"),
204
+ stdout: "",
205
+ stderr: `dg verify: ${error.message}\n${loginLine} ${muted("see plans at")} ${accent("westbayberry.com/pricing")}\n`
206
+ };
207
+ }
143
208
  const message = error instanceof AnalyzeError ? error.message : error instanceof Error ? error.message : "could not reach the scanner";
144
- return { exitCode: 1, stdout: "", stderr: `dg verify: ${message}\n` };
209
+ return { exitCode: exitCodeFor("analysis_incomplete"), stdout: "", stderr: `dg verify: ${message}\n` };
145
210
  }
146
211
  if (!result) {
147
- return { exitCode: 1, stdout: "", stderr: `dg verify: scanner returned no result for ${parsed.name}\n` };
212
+ return { exitCode: exitCodeFor("analysis_incomplete"), stdout: "", stderr: `dg verify: scanner returned no result for ${parsed.name}\n` };
148
213
  }
149
214
  const action = result.action ?? "pass";
150
215
  const rendered = options.format === "json"
@@ -157,9 +222,12 @@ export async function runPackageCheck(target, io = {}, options = {}) {
157
222
  score: result.score,
158
223
  reasons: result.reasons,
159
224
  findings: result.findings,
225
+ ...(result.provenance ? { provenance: result.provenance } : {}),
160
226
  ...(result.recommendation ? { recommendation: result.recommendation } : {})
161
227
  }, null, 2)}\n`
162
- : renderResult(parsed, version, result, theme, options.verbose ?? false);
228
+ : authenticated
229
+ ? renderResult(parsed, version, result, theme, options.verbose ?? false)
230
+ : renderFreeResult(parsed, version, result, theme, freeScansRemaining);
163
231
  if (options.outputPath) {
164
232
  try {
165
233
  writeFileSync(resolve(options.outputPath), rendered, "utf8");
@@ -236,20 +304,20 @@ export async function maybeVerifyPackage(args) {
236
304
  if (outputFlagMissingPath) {
237
305
  return {
238
306
  handled: true,
239
- result: { exitCode: 2, stdout: "", stderr: `dg verify: ${outputFlagMissingPath} requires a path. Run 'dg verify --help'.\n` }
307
+ result: { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg verify: ${outputFlagMissingPath} requires a path. Run 'dg verify --help'.\n` }
240
308
  };
241
309
  }
242
310
  if (unknownFlag) {
243
311
  return {
244
312
  handled: true,
245
- result: { exitCode: 2, stdout: "", stderr: `dg verify: unknown option '${unknownFlag}'. Run 'dg verify --help'.\n` }
313
+ result: { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg verify: unknown option '${unknownFlag}'. Run 'dg verify --help'.\n` }
246
314
  };
247
315
  }
248
316
  if (sarif) {
249
317
  return {
250
318
  handled: true,
251
319
  result: {
252
- exitCode: 2,
320
+ exitCode: EXIT_USAGE_VERDICT,
253
321
  stdout: "",
254
322
  stderr: "dg verify: --sarif applies to local artifacts and lockfiles; registry package checks support --json.\n"
255
323
  }