@xagents_in/unmask 0.1.2 → 0.1.4

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 (56) hide show
  1. package/README.md +301 -85
  2. package/dist/cli/annotations.mjs +145 -0
  3. package/dist/cli/audit.mjs +223 -0
  4. package/dist/cli/batch.mjs +218 -0
  5. package/dist/cli/cache.mjs +488 -0
  6. package/dist/cli/compare.mjs +261 -0
  7. package/dist/cli/config.mjs +141 -0
  8. package/dist/cli/discovery.mjs +372 -0
  9. package/dist/cli/doctor.mjs +354 -0
  10. package/dist/cli/exceptions.mjs +170 -0
  11. package/dist/cli/file.mjs +271 -0
  12. package/dist/cli/files.mjs +141 -0
  13. package/dist/cli/history.mjs +232 -0
  14. package/dist/cli/hosted.mjs +251 -43
  15. package/dist/cli/info.mjs +125 -0
  16. package/dist/cli/init.mjs +57 -0
  17. package/dist/cli/install-guard.mjs +896 -0
  18. package/dist/cli/lockfile.mjs +330 -0
  19. package/dist/cli/main.mjs +101 -14
  20. package/dist/cli/preflight.mjs +388 -0
  21. package/dist/cli/sarif.mjs +100 -0
  22. package/dist/cli/scan.mjs +128 -0
  23. package/dist/cli/shell-integration.mjs +122 -0
  24. package/dist/cli/terminal.mjs +396 -0
  25. package/dist/cli/watch.mjs +233 -0
  26. package/dist/cli/why.mjs +256 -0
  27. package/dist/core/advisories.mjs +101 -24
  28. package/dist/core/advisory-cache.mjs +295 -0
  29. package/dist/core/behavior-signals.mjs +1088 -0
  30. package/dist/core/dependency-graph.mjs +332 -0
  31. package/dist/core/evidence-exports.mjs +469 -0
  32. package/dist/core/guard-config.mjs +463 -0
  33. package/dist/core/guard-policy.mjs +308 -0
  34. package/dist/core/package-details.mjs +42 -6
  35. package/dist/core/registry-facts.mjs +342 -0
  36. package/dist/core/report-exploration.mjs +328 -0
  37. package/dist/core/report.mjs +7 -0
  38. package/dist/core/sarif.mjs +556 -0
  39. package/dist/core/syntax.mjs +296 -15
  40. package/dist/model/client.mjs +204 -56
  41. package/dist/model/diff-evidence.mjs +177 -0
  42. package/dist/model/summary.mjs +183 -0
  43. package/dist/server/advisory-result.mjs +137 -0
  44. package/dist/server/app.mjs +66 -2
  45. package/dist/server/dependency-screening.mjs +386 -0
  46. package/dist/server/exports.mjs +63 -0
  47. package/dist/server/guard-decision.mjs +37 -0
  48. package/dist/server/main.mjs +25 -7
  49. package/dist/server/queue.mjs +95 -53
  50. package/dist/server/registry-facts.mjs +118 -0
  51. package/dist/server/report-exploration.mjs +87 -0
  52. package/dist/server/store.mjs +21 -1
  53. package/dist/version.json +1 -1
  54. package/dist/viewer/render.mjs +10 -2
  55. package/dist/viewer/shell.mjs +1 -1
  56. package/package.json +1 -1
@@ -0,0 +1,342 @@
1
+ import { AcquisitionError, fetchBlob, parsePackageSpec } from "./acquire.mjs";
2
+
3
+ export const REGISTRY_FACTS_LIMITS = Object.freeze({
4
+ maxBytes: 1024 * 1024,
5
+ timeoutMs: 10000,
6
+ maxValueBytes: 8192,
7
+ maxItems: 100,
8
+ });
9
+ const object = (value) =>
10
+ value !== null && typeof value === "object" && !Array.isArray(value);
11
+ const text = (value) => typeof value === "string";
12
+ const stringMap = (value) => object(value) && Object.values(value).every(text);
13
+ const record = (value, keys) =>
14
+ Object.fromEntries(
15
+ keys
16
+ .filter((key) => Object.hasOwn(value, key))
17
+ .map((key) => [key, value[key]]),
18
+ );
19
+ const person = (value) => object(value) && text(value.name);
20
+
21
+ export function registryIdentity(spec) {
22
+ const identity = parsePackageSpec(spec);
23
+ if (
24
+ identity.version
25
+ .split(/[.+-]/)
26
+ .slice(0, 3)
27
+ .some((part) => !Number.isSafeInteger(Number(part)))
28
+ )
29
+ throw new AcquisitionError(
30
+ "invalid-package-spec",
31
+ "Expected exact package name@version",
32
+ );
33
+ return identity;
34
+ }
35
+
36
+ export async function readRegistryDocument(name, version, options = {}) {
37
+ registryIdentity(`${name}@${version ?? "0.0.0"}`);
38
+ const maxBytes = options.maxBytes ?? REGISTRY_FACTS_LIMITS.maxBytes;
39
+ const timeoutMs = options.timeoutMs ?? REGISTRY_FACTS_LIMITS.timeoutMs;
40
+ if (
41
+ !Number.isSafeInteger(maxBytes) ||
42
+ maxBytes < 1 ||
43
+ maxBytes > REGISTRY_FACTS_LIMITS.maxBytes ||
44
+ !Number.isSafeInteger(timeoutMs) ||
45
+ timeoutMs < 1 ||
46
+ timeoutMs > REGISTRY_FACTS_LIMITS.timeoutMs
47
+ )
48
+ throw new TypeError("Invalid registry facts limits");
49
+ const url = `https://registry.npmjs.org/${encodeURIComponent(name)}${version === undefined ? "" : `/${encodeURIComponent(version)}`}`;
50
+ const blob = await fetchBlob(url, {
51
+ fetchImpl: options.fetchImpl,
52
+ signal: options.signal,
53
+ maxBytes,
54
+ timeoutMs,
55
+ });
56
+ let document;
57
+ try {
58
+ document = JSON.parse(blob.bytes.toString("utf8"));
59
+ } catch {
60
+ throw new AcquisitionError(
61
+ "invalid-registry-document",
62
+ "Registry metadata is not JSON",
63
+ );
64
+ }
65
+ if (
66
+ !object(document) ||
67
+ document.name !== name ||
68
+ (version !== undefined && document.version !== version)
69
+ )
70
+ throw new AcquisitionError(
71
+ "registry-identity-mismatch",
72
+ "Registry identity differs from request",
73
+ );
74
+ return {
75
+ document,
76
+ source: {
77
+ url,
78
+ retrievedAt: new Date().toISOString(),
79
+ sha256: blob.digest.sha256,
80
+ bytes: blob.observation.bytes,
81
+ },
82
+ };
83
+ }
84
+
85
+ function claim(document, key, valid, project = (value) => value) {
86
+ const result = { pointer: `/${key}`, status: "not-recorded" };
87
+ if (!Object.hasOwn(document, key)) return result;
88
+ const value = document[key];
89
+ if (
90
+ Buffer.byteLength(JSON.stringify(value)) >
91
+ REGISTRY_FACTS_LIMITS.maxValueBytes ||
92
+ (Array.isArray(value) && value.length > REGISTRY_FACTS_LIMITS.maxItems)
93
+ )
94
+ return { ...result, status: "omitted-limit" };
95
+ if (!valid(value)) return { ...result, status: "invalid-type" };
96
+ return {
97
+ ...result,
98
+ status: "registry-reported-unverified",
99
+ value: project(value),
100
+ };
101
+ }
102
+
103
+ export function projectRegistryClaims(document) {
104
+ return {
105
+ maintainers: claim(
106
+ document,
107
+ "maintainers",
108
+ (value) =>
109
+ Array.isArray(value) &&
110
+ value.length <= REGISTRY_FACTS_LIMITS.maxItems &&
111
+ value.every(person),
112
+ (value) => value.map((item) => ({ name: item.name })),
113
+ ),
114
+ publisher: claim(document, "_npmUser", person, (value) => ({
115
+ name: value.name,
116
+ })),
117
+ deprecated: claim(document, "deprecated", text),
118
+ license: claim(
119
+ document,
120
+ "license",
121
+ (value) =>
122
+ text(value) ||
123
+ (object(value) &&
124
+ text(value.type) &&
125
+ (value.url === undefined || text(value.url))),
126
+ (value) => (text(value) ? value : record(value, ["type", "url"])),
127
+ ),
128
+ engines: claim(document, "engines", stringMap),
129
+ repository: claim(
130
+ document,
131
+ "repository",
132
+ (value) =>
133
+ text(value) ||
134
+ (object(value) &&
135
+ text(value.url) &&
136
+ ["type", "directory"].every(
137
+ (key) => value[key] === undefined || text(value[key]),
138
+ )),
139
+ (value) =>
140
+ text(value) ? value : record(value, ["type", "url", "directory"]),
141
+ ),
142
+ };
143
+ }
144
+
145
+ export async function getRegistryFacts(spec, options = {}) {
146
+ const identity = registryIdentity(spec);
147
+ const { document, source } = await readRegistryDocument(
148
+ identity.name,
149
+ identity.version,
150
+ options,
151
+ );
152
+ return {
153
+ schemaVersion: "1",
154
+ identity,
155
+ source,
156
+ verifiedObservations: {
157
+ documentIdentity: "matches-exact-request",
158
+ documentDigest: "sha256-of-retrieved-bytes",
159
+ },
160
+ unverifiedClaims: projectRegistryClaims(document),
161
+ scope: {
162
+ method: "bounded-public-npm-metadata",
163
+ safetyVerdict: "not-provided",
164
+ modelAssessment: "not-performed-metadata-only",
165
+ artifactInspection: "not-performed",
166
+ limitation:
167
+ "Registry declarations only. Maintainers and publisher names are recorded metadata, not verified identities or ownership history. Repository, license and engine declarations are not independently verified. Missing deprecation does not establish support or safety. No popularity-based safety inference.",
168
+ },
169
+ };
170
+ }
171
+
172
+ const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
173
+ const metadataFields = ["deprecated", "license", "engines", "repository"];
174
+ const recorded = (claim) => claim.status === "registry-reported-unverified";
175
+ function stable(value) {
176
+ return JSON.stringify(
177
+ object(value)
178
+ ? Object.fromEntries(
179
+ Object.keys(value)
180
+ .sort(order)
181
+ .map((key) => [key, value[key]]),
182
+ )
183
+ : value,
184
+ );
185
+ }
186
+ function timestamp(value) {
187
+ if (
188
+ typeof value !== "string" ||
189
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
190
+ )
191
+ return null;
192
+ const canonical = value.includes(".") ? value : value.replace("Z", ".000Z");
193
+ const ms = Date.parse(value);
194
+ return Number.isFinite(ms) && new Date(ms).toISOString() === canonical
195
+ ? canonical
196
+ : null;
197
+ }
198
+ function compareRelease(current, previous, ambiguousTime) {
199
+ if (ambiguousTime) return { status: "not-assessed-ambiguous-time" };
200
+ if (!previous) return { status: "not-assessed-no-predecessor" };
201
+ const left = previous.claims;
202
+ const right = current.claims;
203
+ const publisher =
204
+ recorded(left.publisher) && recorded(right.publisher)
205
+ ? {
206
+ status:
207
+ left.publisher.value.name === right.publisher.value.name
208
+ ? "unchanged"
209
+ : "changed",
210
+ before: left.publisher.value.name,
211
+ after: right.publisher.value.name,
212
+ }
213
+ : { status: "not-assessed" };
214
+ const unassessedFields = metadataFields.filter(
215
+ (field) => !recorded(left[field]) || !recorded(right[field]),
216
+ );
217
+ const metadataChanges = metadataFields
218
+ .filter(
219
+ (field) =>
220
+ recorded(left[field]) &&
221
+ recorded(right[field]) &&
222
+ stable(left[field].value) !== stable(right[field].value),
223
+ )
224
+ .map((field) => ({
225
+ field,
226
+ before: left[field].value,
227
+ after: right[field].value,
228
+ }));
229
+ return {
230
+ status:
231
+ unassessedFields.length || publisher.status === "not-assessed"
232
+ ? "partial"
233
+ : "compared",
234
+ againstVersion: previous.version,
235
+ publisher,
236
+ metadataChanges,
237
+ unassessedFields,
238
+ };
239
+ }
240
+
241
+ export async function getRegistryHistory(name, options = {}) {
242
+ registryIdentity(`${name}@0.0.0`);
243
+ const limit = options.limit ?? 20;
244
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
245
+ throw new TypeError("Invalid registry history limit");
246
+ const { document, source } = await readRegistryDocument(
247
+ name,
248
+ undefined,
249
+ options,
250
+ );
251
+ const times = object(document.time) ? document.time : {};
252
+ const versions = object(document.versions) ? document.versions : {};
253
+ const keys = [
254
+ ...new Set([
255
+ ...Object.keys(times).filter(
256
+ (key) => !["created", "modified"].includes(key),
257
+ ),
258
+ ...Object.keys(versions),
259
+ ]),
260
+ ];
261
+ if (keys.length > 5000)
262
+ throw new AcquisitionError(
263
+ "registry-history-cap",
264
+ "Registry version history exceeds record limit",
265
+ );
266
+ let invalidVersionKeys = 0,
267
+ undatedVersions = 0;
268
+ const timeline = [];
269
+ for (const version of keys) {
270
+ try {
271
+ registryIdentity(`${name}@${version}`);
272
+ } catch {
273
+ invalidVersionKeys++;
274
+ continue;
275
+ }
276
+ const releasedAt = timestamp(times[version]);
277
+ if (!releasedAt) {
278
+ undatedVersions++;
279
+ continue;
280
+ }
281
+ const entry = versions[version];
282
+ const metadataStatus = !Object.hasOwn(versions, version)
283
+ ? "not-recorded"
284
+ : !object(entry)
285
+ ? "invalid-type"
286
+ : entry.name !== name || entry.version !== version
287
+ ? "identity-mismatch"
288
+ : "registry-reported-unverified";
289
+ const claims = projectRegistryClaims(
290
+ metadataStatus === "registry-reported-unverified" ? entry : {},
291
+ );
292
+ for (const claim of Object.values(claims))
293
+ claim.pointer = `/versions/${version}${claim.pointer}`;
294
+ timeline.push({
295
+ version,
296
+ releasedAt,
297
+ timePointer: `/time/${version}`,
298
+ metadataStatus,
299
+ claims,
300
+ });
301
+ }
302
+ timeline.sort(
303
+ (a, b) => order(a.releasedAt, b.releasedAt) || order(a.version, b.version),
304
+ );
305
+ const counts = new Map();
306
+ for (const entry of timeline)
307
+ counts.set(entry.releasedAt, (counts.get(entry.releasedAt) ?? 0) + 1);
308
+ for (let i = 0; i < timeline.length; i++) {
309
+ const entry = timeline[i];
310
+ entry.comparison = compareRelease(
311
+ entry,
312
+ timeline[i - 1],
313
+ counts.get(entry.releasedAt) > 1 ||
314
+ (i > 0 && counts.get(timeline[i - 1].releasedAt) > 1),
315
+ );
316
+ }
317
+ return {
318
+ schemaVersion: "1",
319
+ status: timeline.length ? "recorded-history" : "history-unavailable",
320
+ identity: { name },
321
+ source,
322
+ timeline: timeline.slice(-limit).reverse(),
323
+ coverage: {
324
+ recordedVersions: keys.length - invalidVersionKeys,
325
+ datedVersions: timeline.length,
326
+ undatedVersions,
327
+ invalidVersionKeys,
328
+ returned: Math.min(limit, timeline.length),
329
+ omitted: Math.max(0, timeline.length - limit),
330
+ recordLimit: 5000,
331
+ },
332
+ scope: {
333
+ method: "adjacent-dated-version-records-in-current-registry-snapshot",
334
+ historicalEdits: "not-assessed-single-snapshot",
335
+ ownershipTransfers: "not-assessed",
336
+ safetyVerdict: "not-provided",
337
+ modelAssessment: "not-performed-metadata-only",
338
+ limitation:
339
+ "Publish times and declarations are registry-reported and unverified. Comparisons describe differences between adjacent dated records in this snapshot, not edits over time or proven ownership transfers. Missing, malformed or omitted fields remain unassessed. Removed releases may retain dates without metadata. Equal timestamps do not establish ordering. Changes, unchanged metadata and popularity do not establish safety.",
340
+ },
341
+ };
342
+ }
@@ -0,0 +1,328 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const states = new Map([
4
+ ["changed", "changed"],
5
+ ["artifact-only", "added"],
6
+ ["byte-identical", "identical"],
7
+ ["not-compared", "not-compared"],
8
+ ]);
9
+ const digest = (value) => (/^[a-f0-9]{64}$/.test(value ?? "") ? value : null);
10
+ const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
11
+ export const EXPLORATION_BYTE_LIMIT = 131072;
12
+ export const explorationLimitations = [
13
+ "States compare artifact and selected source, not releases or version history.",
14
+ "Source-only paths are recorded observations; their hashes, file types and excerpts may be unavailable.",
15
+ "Incomplete inventories and unassessed files do not establish safety or absence.",
16
+ ];
17
+
18
+ export function safeExplorationPath(value, { prefix = false } = {}) {
19
+ if (
20
+ typeof value !== "string" ||
21
+ value.length > 4096 ||
22
+ /[\x00-\x1f\x7f\\%:]/.test(value)
23
+ )
24
+ return false;
25
+ if (prefix && value === "") return true;
26
+ const parts = (
27
+ prefix && value.endsWith("/") ? value.slice(0, -1) : value
28
+ ).split("/");
29
+ return parts.every((part) => part && part !== "." && part !== "..");
30
+ }
31
+
32
+ export function explorationJson(value) {
33
+ return JSON.stringify(value).replace(
34
+ /[<>&\u2028\u2029]/g,
35
+ (character) =>
36
+ `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
37
+ );
38
+ }
39
+
40
+ export function reportFileRows(report, { reportId } = {}) {
41
+ if (reportId !== undefined && !/^[a-f0-9]{32}$/.test(reportId))
42
+ throw new TypeError("Invalid report identity");
43
+ const rows = [];
44
+ const paths = new Set();
45
+ let omitted = 0;
46
+ for (const file of report.files ?? []) {
47
+ if (
48
+ !states.has(file.state) ||
49
+ !safeExplorationPath(file.originalPath) ||
50
+ (file.path !== null && !safeExplorationPath(file.path))
51
+ ) {
52
+ omitted++;
53
+ continue;
54
+ }
55
+ if (file.path !== null) paths.add(file.path);
56
+ rows.push({
57
+ path: file.path,
58
+ originalPath: file.originalPath,
59
+ state: states.get(file.state),
60
+ comparisonState: file.state,
61
+ size:
62
+ Number.isSafeInteger(file.size) && file.size >= 0 ? file.size : null,
63
+ artifactSha256: digest(file.artifactSha256),
64
+ sourceSha256: digest(file.sourceSha256),
65
+ reason:
66
+ typeof file.reason === "string" ? file.reason.slice(0, 128) : null,
67
+ });
68
+ }
69
+ for (const path of new Set(report.coverage?.sourceOnly ?? [])) {
70
+ if (!safeExplorationPath(path)) {
71
+ omitted++;
72
+ continue;
73
+ }
74
+ if (paths.has(path)) continue;
75
+ rows.push({
76
+ path,
77
+ originalPath: null,
78
+ state: "removed",
79
+ comparisonState: "source-only",
80
+ size: null,
81
+ artifactSha256: null,
82
+ sourceSha256: null,
83
+ reason: "recorded-source-only",
84
+ });
85
+ }
86
+ rows.sort(
87
+ (a, b) =>
88
+ order(a.path ?? a.originalPath, b.path ?? b.originalPath) ||
89
+ order(a.originalPath ?? "", b.originalPath ?? ""),
90
+ );
91
+ if (reportId !== undefined)
92
+ for (const file of rows) {
93
+ file.fileId = `f-${createHash("sha256")
94
+ .update(
95
+ JSON.stringify([
96
+ "unmask-file-id-v1",
97
+ reportId,
98
+ file.path,
99
+ file.originalPath,
100
+ ]),
101
+ )
102
+ .digest("base64url")}`;
103
+ }
104
+ return { rows, omitted };
105
+ }
106
+
107
+ export function buildReportFileInventory(
108
+ report,
109
+ { state = "all", prefix = "", offset = 0, limit = 50 } = {},
110
+ context = {},
111
+ ) {
112
+ if (
113
+ !["all", "removed", ...states.values()].includes(state) ||
114
+ !safeExplorationPath(prefix, { prefix: true }) ||
115
+ !Number.isSafeInteger(offset) ||
116
+ offset < 0 ||
117
+ !Number.isSafeInteger(limit) ||
118
+ limit < 1 ||
119
+ limit > 100
120
+ )
121
+ throw new TypeError("Invalid inventory query");
122
+ const { rows, omitted } = reportFileRows(report, context);
123
+ const filtered = rows.filter(
124
+ (file) =>
125
+ (state === "all" || file.state === state) &&
126
+ (file.path ?? file.originalPath).startsWith(prefix),
127
+ );
128
+ const files = [];
129
+ let bytes = 0;
130
+ for (const file of filtered.slice(offset, offset + limit)) {
131
+ const size = Buffer.byteLength(explorationJson(file)) + 1;
132
+ if (
133
+ bytes + size >
134
+ EXPLORATION_BYTE_LIMIT -
135
+ Buffer.byteLength(explorationJson({ prefix })) -
136
+ 8192
137
+ )
138
+ break;
139
+ bytes += size;
140
+ files.push(file);
141
+ }
142
+ return {
143
+ schemaVersion: "1",
144
+ scope: "artifact-source",
145
+ state,
146
+ prefix,
147
+ offset,
148
+ limit,
149
+ total: filtered.length,
150
+ files,
151
+ nextOffset:
152
+ offset + files.length < filtered.length ? offset + files.length : null,
153
+ omittedUnsafePaths: omitted,
154
+ inventoryComplete: report.coverage?.inventory?.complete === true,
155
+ sourceComplete: report.coverage?.sourceComplete === true,
156
+ limitations: explorationLimitations,
157
+ };
158
+ }
159
+
160
+ function validRange(value, size) {
161
+ return (
162
+ Array.isArray(value) &&
163
+ value.length === 2 &&
164
+ value.every((number) => Number.isSafeInteger(number) && number >= 0) &&
165
+ value[0] <= value[1] &&
166
+ value[1] <= size
167
+ );
168
+ }
169
+
170
+ function recordedExcerpt(item) {
171
+ const unavailable = {
172
+ excerpt: null,
173
+ excerptRange: null,
174
+ recordedExcerptRange: null,
175
+ excerptTruncated: false,
176
+ excerptStatus: "not-recorded",
177
+ };
178
+ if (item.excerpt === undefined) return unavailable;
179
+ if (
180
+ typeof item.excerpt !== "string" ||
181
+ !validRange(item.excerptRange, item.size) ||
182
+ item.excerptRange[0] !== item.range[0] ||
183
+ item.excerptRange[1] > item.range[1] ||
184
+ Buffer.byteLength(item.excerpt) !==
185
+ item.excerptRange[1] - item.excerptRange[0]
186
+ )
187
+ return { ...unavailable, excerptStatus: "invalid-recorded-range" };
188
+ let excerpt = "",
189
+ bytes = 0;
190
+ for (const character of item.excerpt) {
191
+ const size = Buffer.byteLength(character);
192
+ if (bytes + size > 2048) break;
193
+ bytes += size;
194
+ excerpt += character;
195
+ }
196
+ return {
197
+ excerpt,
198
+ excerptRange: [item.excerptRange[0], item.excerptRange[0] + bytes],
199
+ recordedExcerptRange: item.excerptRange,
200
+ excerptTruncated: bytes < Buffer.byteLength(item.excerpt),
201
+ excerptStatus: "recorded",
202
+ };
203
+ }
204
+
205
+ export function buildReportFileEvidence(report, selection, context = {}) {
206
+ const keys = Object.keys(selection ?? {});
207
+ if (
208
+ keys.length !== 1 ||
209
+ !["path", "originalPath", "fileId"].includes(keys[0]) ||
210
+ (keys[0] === "fileId"
211
+ ? !/^[a-f0-9]{32}$/.test(context.reportId ?? "") ||
212
+ !/^f-[A-Za-z0-9_-]{43}$/.test(selection.fileId)
213
+ : !safeExplorationPath(selection[keys[0]]))
214
+ )
215
+ throw new TypeError("Invalid file selection");
216
+ const { rows } = reportFileRows(report, context);
217
+ const matches = rows.filter((file) =>
218
+ keys[0] === "fileId"
219
+ ? file.fileId === selection.fileId
220
+ : keys[0] === "path"
221
+ ? file.path === selection.path
222
+ : file.path === null && file.originalPath === selection.originalPath,
223
+ );
224
+ if (matches.length !== 1) return null;
225
+ const file = matches[0];
226
+ const rawFile = (report.files ?? []).find(
227
+ (item) =>
228
+ item.path === file.path && item.originalPath === file.originalPath,
229
+ );
230
+ const samePathEvidence = (report.evidence ?? []).filter((item) =>
231
+ file.path === null
232
+ ? item.side === "artifact" &&
233
+ item.path === null &&
234
+ item.originalPath === file.originalPath
235
+ : item.path === file.path &&
236
+ (file.state !== "removed" || item.side === "source"),
237
+ );
238
+ if (file.state === "removed") {
239
+ const hashes = new Set(
240
+ samePathEvidence
241
+ .filter(
242
+ (item) =>
243
+ digest(item.fileSha256) &&
244
+ digest(item.blobDigest) &&
245
+ item.blobDigest === report.inputs?.sourceArchive?.sha256,
246
+ )
247
+ .map((item) => item.fileSha256),
248
+ );
249
+ if (hashes.size === 1) file.sourceSha256 = [...hashes][0];
250
+ }
251
+ const candidateIds = new Set([
252
+ ...(rawFile?.evidenceIds ?? []),
253
+ ...samePathEvidence.map((item) => item.id),
254
+ ]);
255
+ const result = {
256
+ schemaVersion: "1",
257
+ scope: "artifact-source",
258
+ file,
259
+ recordedOnly: true,
260
+ evidence: [],
261
+ evidenceStatus: "unavailable",
262
+ evidenceOmitted: 0,
263
+ invalidEvidenceOmitted: 0,
264
+ limitations: [
265
+ ...explorationLimitations,
266
+ "Only previously recorded excerpts are returned; omitted text is not fetched or reconstructed. Hashes are recorded claims, not new artifact verification. Render excerpts as text, never HTML.",
267
+ ],
268
+ };
269
+ const evidenceById = new Map();
270
+ for (const item of report.evidence ?? [])
271
+ evidenceById.set(item.id, evidenceById.has(item.id) ? null : item);
272
+ let bytes = Buffer.byteLength(explorationJson(result));
273
+ for (const id of candidateIds) {
274
+ const item = evidenceById.get(id);
275
+ const expectedHash =
276
+ item?.side === "artifact" ? file.artifactSha256 : file.sourceSha256;
277
+ const input =
278
+ item?.side === "artifact"
279
+ ? report.inputs?.artifact
280
+ : report.inputs?.sourceArchive;
281
+ if (
282
+ !item ||
283
+ !/^ev-[a-f0-9]{64}$/.test(id) ||
284
+ !["artifact", "source"].includes(item.side) ||
285
+ item.path !== file.path ||
286
+ !safeExplorationPath(item.originalPath) ||
287
+ (item.side === "artifact" &&
288
+ (item.originalPath !== file.originalPath || item.size !== file.size)) ||
289
+ !expectedHash ||
290
+ item.fileSha256 !== expectedHash ||
291
+ !digest(item.blobDigest) ||
292
+ item.blobDigest !== input?.sha256 ||
293
+ !Number.isSafeInteger(item.size) ||
294
+ item.size < 0 ||
295
+ !validRange(item.range, item.size)
296
+ ) {
297
+ result.invalidEvidenceOmitted++;
298
+ continue;
299
+ }
300
+ const evidence = {
301
+ id,
302
+ side: item.side,
303
+ path: item.path,
304
+ originalPath: item.originalPath,
305
+ fileSha256: item.fileSha256,
306
+ blobDigest: item.blobDigest,
307
+ size: item.size,
308
+ range: item.range,
309
+ ...recordedExcerpt(item),
310
+ };
311
+ const size = Buffer.byteLength(explorationJson(evidence)) + 1;
312
+ if (
313
+ result.evidence.length >= 32 ||
314
+ bytes + size > EXPLORATION_BYTE_LIMIT - 2048
315
+ ) {
316
+ result.evidenceOmitted++;
317
+ continue;
318
+ }
319
+ bytes += size;
320
+ result.evidence.push(evidence);
321
+ }
322
+ if (result.evidence.length)
323
+ result.evidenceStatus =
324
+ result.evidenceOmitted || result.invalidEvidenceOmitted
325
+ ? "partial"
326
+ : "recorded";
327
+ return result;
328
+ }
@@ -225,6 +225,13 @@ export function validateReport(report) {
225
225
  typeof report.run === "object",
226
226
  "Invalid model or run section",
227
227
  );
228
+ requireValue(
229
+ report.model.assessment === undefined ||
230
+ (report.model.status === "complete" &&
231
+ report.model.assessment?.source === "model" &&
232
+ typeof report.model.summary === "string"),
233
+ "Model assessment requires a completed model result",
234
+ );
228
235
  const coverage = report.coverage;
229
236
  requireValue(
230
237
  coverage &&