@xagents_in/unmask 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,310 @@
1
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ getRegistryRelease,
5
+ fetchBlob,
6
+ parsePackageSpec,
7
+ AcquisitionError,
8
+ } from "./acquire.mjs";
9
+ import { resolveSource } from "./resolve.mjs";
10
+ import {
11
+ analyzeLocal,
12
+ LocalInputError,
13
+ effectiveLimits,
14
+ preserveInputBytes,
15
+ } from "./pipeline.mjs";
16
+ import { createReport, validateReport } from "./report.mjs";
17
+ import { addManifestObservations } from "./manifests.mjs";
18
+
19
+ function integrity(raw, actual) {
20
+ const values = [];
21
+ let malformed = raw !== null && raw !== undefined && typeof raw !== "string";
22
+ const recognized = { sha1: 20, sha256: 32, sha384: 48, sha512: 64 };
23
+ const unsupported = new Set();
24
+ for (const token of typeof raw === "string"
25
+ ? raw.trim().split(/\s+/).filter(Boolean)
26
+ : []) {
27
+ const expression = token.split("?")[0];
28
+ const dash = expression.indexOf("-");
29
+ const algorithm = dash === -1 ? expression : expression.slice(0, dash);
30
+ const encoded = dash === -1 ? "" : expression.slice(dash + 1);
31
+ if (!Object.hasOwn(recognized, algorithm)) {
32
+ unsupported.add(algorithm);
33
+ continue;
34
+ }
35
+ const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
36
+ const bytes = Buffer.from(normalized, "base64");
37
+ const canonical = bytes.toString("base64");
38
+ if (
39
+ !/^[A-Za-z0-9+/_-]+={0,2}$/.test(encoded) ||
40
+ bytes.length !== recognized[algorithm] ||
41
+ canonical.replace(/=+$/, "") !== normalized.replace(/=+$/, "") ||
42
+ (encoded.includes("=") && canonical !== normalized)
43
+ ) {
44
+ malformed = true;
45
+ continue;
46
+ }
47
+ if (algorithm === "sha256" || algorithm === "sha512")
48
+ values.push({ algorithm, digest: bytes.toString("hex") });
49
+ else unsupported.add(algorithm);
50
+ }
51
+ const algorithm = values.some((value) => value.algorithm === "sha512")
52
+ ? "sha512"
53
+ : "sha256";
54
+ const candidates = values
55
+ .filter((value) => value.algorithm === algorithm)
56
+ .sort((a, b) => (a.digest < b.digest ? -1 : a.digest > b.digest ? 1 : 0));
57
+ const selected =
58
+ candidates.find((value) => value.digest === actual[algorithm]) ||
59
+ candidates[0];
60
+ const expected = selected
61
+ ? {
62
+ ...selected,
63
+ status: selected.digest === actual[algorithm] ? "match" : "mismatch",
64
+ ...(unsupported.size
65
+ ? { unsupportedAlgorithms: [...unsupported].sort() }
66
+ : {}),
67
+ }
68
+ : {
69
+ status: "unavailable",
70
+ reason: unsupported.size
71
+ ? "unsupported-algorithm"
72
+ : "missing-or-invalid",
73
+ };
74
+ return {
75
+ expected,
76
+ failures: malformed
77
+ ? [
78
+ {
79
+ code: "invalid-registry-integrity",
80
+ message: "Recognized registry integrity metadata is malformed",
81
+ },
82
+ ]
83
+ : [],
84
+ };
85
+ }
86
+ function rebuild(report, updates) {
87
+ const next = createReport({
88
+ inputs: updates.inputs || report.inputs,
89
+ source: updates.source || report.source,
90
+ comparison: {
91
+ files: report.files,
92
+ coverage: report.coverage,
93
+ evidence: report.evidence,
94
+ },
95
+ manifests: updates.manifests || report.manifests,
96
+ failures: updates.failures || report.failures,
97
+ run: updates.run || report.run,
98
+ analyzerVersion: report.analyzerVersion,
99
+ limitsVersion: report.limitsVersion,
100
+ });
101
+ next.cues = report.cues;
102
+ next.annotations = report.annotations;
103
+ next.model = report.model;
104
+ return validateReport(next);
105
+ }
106
+ export async function addConfiguredModel(report, model) {
107
+ if (model === undefined) return report;
108
+ if (typeof model !== "function")
109
+ return validateReport({
110
+ ...report,
111
+ model: { status: "unavailable", reason: "not-configured" },
112
+ });
113
+ try {
114
+ const section = await model(structuredClone(report));
115
+ return validateReport({ ...report, model: section });
116
+ } catch {
117
+ return validateReport({
118
+ ...report,
119
+ model: { status: "unavailable", reason: "model-request-failed" },
120
+ });
121
+ }
122
+ }
123
+
124
+ export async function analyzeRegistry(
125
+ spec,
126
+ {
127
+ workDir,
128
+ fetchImpl,
129
+ signal,
130
+ limits: overrides,
131
+ model,
132
+ persistCache = true,
133
+ } = {},
134
+ ) {
135
+ let identity;
136
+ try {
137
+ identity = parsePackageSpec(spec);
138
+ } catch (error) {
139
+ if (error instanceof AcquisitionError)
140
+ throw new LocalInputError(error.code, error.message);
141
+ throw error;
142
+ }
143
+ if (typeof workDir !== "string" || !workDir)
144
+ throw new LocalInputError(
145
+ "work-dir-required",
146
+ "Registry analysis requires an explicit private work directory",
147
+ );
148
+ if (typeof persistCache !== "boolean")
149
+ throw new LocalInputError(
150
+ "invalid-cache-policy",
151
+ "persistCache must be an explicit boolean",
152
+ );
153
+ const limits = effectiveLimits(overrides);
154
+ await mkdir(workDir, { recursive: true, mode: 0o700 });
155
+ const jobDir = await mkdtemp(path.join(workDir, "job-"));
156
+ const observations = [];
157
+ try {
158
+ let release, artifact;
159
+ try {
160
+ release = await getRegistryRelease(identity, { fetchImpl, signal });
161
+ observations.push(release.observation);
162
+ await writeFile(
163
+ path.join(jobDir, "registry.json"),
164
+ release.metadataBytes,
165
+ { flag: "wx", mode: 0o600 },
166
+ );
167
+ if (persistCache)
168
+ await preserveInputBytes(
169
+ workDir,
170
+ { digest: release.metadataDigest },
171
+ release.metadataBytes,
172
+ observations,
173
+ );
174
+ artifact = await fetchBlob(release.tarballUrl, {
175
+ fetchImpl,
176
+ signal,
177
+ maxBytes: limits.compressedBytes,
178
+ });
179
+ observations.push(artifact.observation);
180
+ } catch (error) {
181
+ if (error instanceof AcquisitionError)
182
+ throw new LocalInputError(error.code, error.message);
183
+ throw error;
184
+ }
185
+ const tarballPath = path.join(jobDir, "artifact.tgz");
186
+ await writeFile(tarballPath, artifact.bytes, { flag: "wx", mode: 0o600 });
187
+ const resolution = resolveSource({
188
+ claim: identity,
189
+ registryDoc: release.metadata,
190
+ });
191
+ const candidate = resolution.candidate;
192
+ const sourceOptions = {};
193
+ const failures = [];
194
+ let sourceFetchFailed = false;
195
+ if (candidate) {
196
+ try {
197
+ const source = await fetchBlob(candidate.archiveUrl, {
198
+ fetchImpl,
199
+ signal,
200
+ maxBytes: limits.compressedBytes,
201
+ });
202
+ observations.push(source.observation);
203
+ sourceOptions.sourceArchivePath = path.join(jobDir, "source.tgz");
204
+ sourceOptions.commit = candidate.commit;
205
+ sourceOptions.sourceRoot = candidate.sourceRoot;
206
+ if (
207
+ typeof release.metadata.repository === "object" &&
208
+ release.metadata.repository !== null &&
209
+ Object.hasOwn(release.metadata.repository, "directory")
210
+ )
211
+ sourceOptions.sourceDir = candidate.directory;
212
+ await writeFile(sourceOptions.sourceArchivePath, source.bytes, {
213
+ flag: "wx",
214
+ mode: 0o600,
215
+ });
216
+ } catch (error) {
217
+ if (!(error instanceof AcquisitionError)) throw error;
218
+ if (error.observation) observations.push(error.observation);
219
+ sourceFetchFailed = true;
220
+ failures.push({
221
+ code: "source-acquisition-failed",
222
+ message: "Selected publisher source archive could not be acquired",
223
+ reason: error.code,
224
+ });
225
+ }
226
+ }
227
+ const report = await analyzeLocal({
228
+ tarballPath,
229
+ ...sourceOptions,
230
+ claim: identity,
231
+ limits: overrides,
232
+ cacheDir: persistCache ? workDir : undefined,
233
+ });
234
+ const checked = integrity(
235
+ release.expectedIntegrity,
236
+ report.inputs.artifact,
237
+ );
238
+ if (
239
+ release.metadata.dist?.integrity !== undefined &&
240
+ typeof release.metadata.dist.integrity !== "string"
241
+ )
242
+ checked.failures.push({
243
+ code: "invalid-registry-integrity",
244
+ message: "Registry integrity must be a string",
245
+ });
246
+ const source = candidate
247
+ ? {
248
+ ...report.source,
249
+ method: candidate.method,
250
+ repository: candidate.repository,
251
+ commit: candidate.commit,
252
+ alternatives: resolution.alternatives,
253
+ ...(sourceFetchFailed
254
+ ? {
255
+ status: "unavailable",
256
+ complete: false,
257
+ reason: "source-acquisition-failed",
258
+ }
259
+ : {}),
260
+ }
261
+ : {
262
+ ...report.source,
263
+ method: resolution.method,
264
+ status: resolution.status,
265
+ alternatives: resolution.alternatives,
266
+ reason: resolution.reason || "no-source-candidate",
267
+ };
268
+ const inputs = {
269
+ ...report.inputs,
270
+ artifact: {
271
+ ...report.inputs.artifact,
272
+ kind: "registry",
273
+ expectedIntegrity: checked.expected,
274
+ provenance: { status: "not-assessed" },
275
+ },
276
+ registryDocument: { ...release.metadataDigest, kind: "registry" },
277
+ limits: {
278
+ ...report.inputs.limits,
279
+ acquisition: {
280
+ metadataBytes: 1048576,
281
+ timeoutMs: 30000,
282
+ redirects: "forbidden",
283
+ },
284
+ },
285
+ ...(report.inputs.sourceArchive
286
+ ? { sourceArchive: { ...report.inputs.sourceArchive, kind: "remote" } }
287
+ : {}),
288
+ };
289
+ const next = rebuild(report, {
290
+ inputs,
291
+ source,
292
+ manifests: {
293
+ ...report.manifests,
294
+ registry: {
295
+ status: "usable",
296
+ value: release.metadata,
297
+ evidenceIds: [],
298
+ },
299
+ },
300
+ failures: [...report.failures, ...failures, ...checked.failures],
301
+ run: {
302
+ ...report.run,
303
+ observations: [...report.run.observations, ...observations],
304
+ },
305
+ });
306
+ return await addConfiguredModel(addManifestObservations(next), model);
307
+ } finally {
308
+ await rm(jobDir, { recursive: true, force: true });
309
+ }
310
+ }
@@ -0,0 +1,345 @@
1
+ import { constants } from "node:fs";
2
+ import { open, mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { readArchive, DEFAULT_LIMITS } from "./archive.mjs";
5
+ import { selectSourcePackage } from "./source.mjs";
6
+ import { compareInventories } from "./compare.mjs";
7
+ import { createReport } from "./report.mjs";
8
+ import { inspectJavaScript, addSyntaxInspection } from "./syntax.mjs";
9
+ import { addManifestObservations } from "./manifests.mjs";
10
+
11
+ export class LocalInputError extends Error {
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.name = "LocalInputError";
15
+ this.code = code;
16
+ this.exitCode = 2;
17
+ }
18
+ }
19
+ function limitsFor(overrides = {}) {
20
+ if (!overrides || typeof overrides !== "object" || Array.isArray(overrides))
21
+ throw new LocalInputError("invalid-limits", "Limits must be an object");
22
+ const defaults = { ...DEFAULT_LIMITS, retentionBudgetBytes: 134217728 };
23
+ for (const [key, value] of Object.entries(overrides))
24
+ if (
25
+ !(key in defaults) ||
26
+ !Number.isSafeInteger(value) ||
27
+ value < 0 ||
28
+ value > defaults[key]
29
+ )
30
+ throw new LocalInputError(
31
+ "invalid-limits",
32
+ "Local limits may only lower finite defaults",
33
+ );
34
+ return { ...defaults, ...overrides };
35
+ }
36
+ function safeDirectory(value, allowEmpty = false) {
37
+ return (
38
+ typeof value === "string" &&
39
+ ((allowEmpty && value === "") ||
40
+ (value.length > 0 &&
41
+ !/^[a-z]:/i.test(value) &&
42
+ !/[\0\\]/.test(value) &&
43
+ !value.startsWith("/") &&
44
+ value
45
+ .split("/")
46
+ .every((part) => part && part !== "." && part !== "..")))
47
+ );
48
+ }
49
+ function validateOptions(options) {
50
+ if (typeof options.tarballPath !== "string" || !options.tarballPath)
51
+ throw new LocalInputError(
52
+ "artifact-required",
53
+ "A local artifact path is required",
54
+ );
55
+ if (options.sourceArchivePath !== undefined) {
56
+ if (
57
+ typeof options.sourceArchivePath !== "string" ||
58
+ !options.sourceArchivePath ||
59
+ !/^[a-f0-9]{40}$/.test(options.commit || "") ||
60
+ !safeDirectory(options.sourceRoot)
61
+ )
62
+ throw new LocalInputError(
63
+ "invalid-source-claim",
64
+ "Selected source requires a local archive, full lowercase commit SHA and explicit safe root",
65
+ );
66
+ } else if (
67
+ options.commit !== undefined ||
68
+ options.sourceRoot !== undefined ||
69
+ options.sourceDir !== undefined
70
+ )
71
+ throw new LocalInputError(
72
+ "source-required",
73
+ "Source options require an explicit local source archive",
74
+ );
75
+ if (
76
+ options.sourceDir !== undefined &&
77
+ !safeDirectory(options.sourceDir, true)
78
+ )
79
+ throw new LocalInputError(
80
+ "invalid-source-directory",
81
+ "Source directory must be a safe relative path",
82
+ );
83
+ if (
84
+ options.claim !== undefined &&
85
+ (!options.claim ||
86
+ typeof options.claim !== "object" ||
87
+ typeof options.claim.name !== "string" ||
88
+ typeof options.claim.version !== "string")
89
+ )
90
+ throw new LocalInputError(
91
+ "invalid-claim",
92
+ "Identity claim requires an exact name and version",
93
+ );
94
+ if (
95
+ options.cacheDir !== undefined &&
96
+ (typeof options.cacheDir !== "string" || !options.cacheDir)
97
+ )
98
+ throw new LocalInputError(
99
+ "invalid-cache",
100
+ "Cache directory must be an explicit path",
101
+ );
102
+ }
103
+ async function boundedRead(filename, cap, side) {
104
+ let handle;
105
+ try {
106
+ handle = await open(filename, constants.O_RDONLY | constants.O_NONBLOCK);
107
+ const before = await handle.stat({ bigint: true });
108
+ if (!before.isFile())
109
+ throw new LocalInputError(
110
+ `${side}-read-failed`,
111
+ "Input must be a regular local file",
112
+ );
113
+ if (before.size > BigInt(cap))
114
+ throw new LocalInputError(
115
+ `${side}-compressed-cap`,
116
+ "Compressed input exceeds the configured byte cap",
117
+ );
118
+ const bytes = Buffer.allocUnsafe(Number(before.size));
119
+ let offset = 0;
120
+ while (offset < bytes.length) {
121
+ const { bytesRead } = await handle.read(
122
+ bytes,
123
+ offset,
124
+ Math.min(bytes.length - offset, 65536),
125
+ offset,
126
+ );
127
+ if (bytesRead === 0)
128
+ throw new LocalInputError(
129
+ `${side}-read-changed`,
130
+ "Local file changed during bounded read",
131
+ );
132
+ offset += bytesRead;
133
+ }
134
+ const extra = await handle.read(Buffer.alloc(1), 0, 1, offset);
135
+ const after = await handle.stat({ bigint: true });
136
+ if (
137
+ extra.bytesRead ||
138
+ before.size !== after.size ||
139
+ before.mtimeNs !== after.mtimeNs ||
140
+ before.ctimeNs !== after.ctimeNs
141
+ )
142
+ throw new LocalInputError(
143
+ `${side}-read-changed`,
144
+ "Local file changed during bounded read",
145
+ );
146
+ return bytes;
147
+ } catch (error) {
148
+ if (error instanceof LocalInputError) throw error;
149
+ if (typeof error.code === "string" && /^E[A-Z]+$/.test(error.code))
150
+ throw new LocalInputError(
151
+ `${side}-read-failed`,
152
+ "Unable to read the selected local input",
153
+ );
154
+ throw error;
155
+ } finally {
156
+ if (handle) await handle.close();
157
+ }
158
+ }
159
+ async function cacheBytes(cacheDir, inventory, bytes, observations) {
160
+ if (cacheDir === undefined) return;
161
+ const directory = path.join(cacheDir, "blobs", "sha256");
162
+ const destination = path.join(directory, inventory.digest.sha256);
163
+ try {
164
+ await mkdir(directory, { recursive: true, mode: 0o700 });
165
+ try {
166
+ await writeFile(destination, bytes, { flag: "wx", mode: 0o600 });
167
+ } catch (error) {
168
+ if (error.code !== "EEXIST") throw error;
169
+ const existing = await boundedRead(destination, bytes.length, "cache");
170
+ if (!existing.equals(bytes)) throw new Error("Cache bytes differ");
171
+ }
172
+ } catch {
173
+ observations.push({
174
+ code: "cache-write-failed",
175
+ message:
176
+ "Original input was analyzed, but its private cache copy could not be verified",
177
+ });
178
+ }
179
+ }
180
+ function manifestFor(inventory, comparison, side) {
181
+ const entry = inventory?.entries.find(
182
+ (item) => item.type === "file" && item.path === "package.json",
183
+ );
184
+ const evidenceIds = comparison.evidence
185
+ .filter((e) => e.side === side && e.path === "package.json")
186
+ .map((e) => e.id);
187
+ if (!entry || !Buffer.isBuffer(entry.content))
188
+ return {
189
+ status: "unavailable",
190
+ reason: entry?.inspection || "missing-or-nonregular",
191
+ };
192
+ try {
193
+ const value = JSON.parse(
194
+ new TextDecoder("utf-8", { fatal: true }).decode(entry.content),
195
+ );
196
+ if (!value || typeof value !== "object" || Array.isArray(value))
197
+ return { status: "invalid", reason: "not-an-object" };
198
+ return { status: "usable", value, evidenceIds };
199
+ } catch {
200
+ return { status: "invalid", reason: "invalid-json-or-encoding" };
201
+ }
202
+ }
203
+ function artifactName(inventory) {
204
+ const entry = inventory.entries.find(
205
+ (item) => item.type === "file" && item.path === "package.json",
206
+ );
207
+ if (!Buffer.isBuffer(entry?.content)) return undefined;
208
+ try {
209
+ return JSON.parse(
210
+ new TextDecoder("utf-8", { fatal: true }).decode(entry.content),
211
+ )?.name;
212
+ } catch {
213
+ return undefined;
214
+ }
215
+ }
216
+
217
+ export async function analyzeLocal(options) {
218
+ validateOptions(options);
219
+ const limits = limitsFor(options.limits);
220
+ const { retentionBudgetBytes, ...archiveLimits } = limits;
221
+ const retentionBudget = { limitBytes: retentionBudgetBytes, usedBytes: 0 };
222
+ const observations = [];
223
+ const failures = [];
224
+ const artifactBytes = await boundedRead(
225
+ options.tarballPath,
226
+ limits.compressedBytes,
227
+ "artifact",
228
+ );
229
+ const artifact = await readArchive(artifactBytes, {
230
+ kind: "artifact",
231
+ limits: archiveLimits,
232
+ retentionBudget,
233
+ });
234
+ failures.push(...artifact.failures.map((f) => ({ ...f, side: "artifact" })));
235
+ await cacheBytes(options.cacheDir, artifact, artifactBytes, observations);
236
+ let source = {
237
+ status: "unresolved",
238
+ method: "unresolved",
239
+ complete: false,
240
+ alternatives: [],
241
+ };
242
+ let sourceArchive = null;
243
+ let selected = null;
244
+ if (options.sourceArchivePath !== undefined) {
245
+ source = {
246
+ status: "unavailable",
247
+ method: "user-full-SHA",
248
+ commit: options.commit,
249
+ complete: false,
250
+ alternatives: [],
251
+ };
252
+ try {
253
+ const sourceBytes = await boundedRead(
254
+ options.sourceArchivePath,
255
+ limits.compressedBytes,
256
+ "source",
257
+ );
258
+ sourceArchive = await readArchive(sourceBytes, {
259
+ kind: "source",
260
+ sourceRoot: options.sourceRoot,
261
+ limits: archiveLimits,
262
+ retentionBudget,
263
+ });
264
+ await cacheBytes(
265
+ options.cacheDir,
266
+ sourceArchive,
267
+ sourceBytes,
268
+ observations,
269
+ );
270
+ failures.push(
271
+ ...sourceArchive.failures.map((f) => ({ ...f, side: "source" })),
272
+ );
273
+ const selection = selectSourcePackage(sourceArchive, {
274
+ ...(options.sourceDir === undefined
275
+ ? {}
276
+ : { directory: options.sourceDir }),
277
+ packageName: artifactName(artifact),
278
+ });
279
+ source.status = selection.status;
280
+ source.reason =
281
+ selection.reason ||
282
+ (sourceArchive.complete
283
+ ? "explicit-local-source"
284
+ : "incomplete-source-inventory");
285
+ if (selection.directory !== null) source.directory = selection.directory;
286
+ if (selection.status === "selected") {
287
+ selected = { ...sourceArchive, entries: selection.entries };
288
+ source.complete = sourceArchive.complete;
289
+ } else
290
+ failures.push({
291
+ code: "source-selection-unresolved",
292
+ message:
293
+ "Selected source package could not be resolved unambiguously",
294
+ reason: selection.reason,
295
+ });
296
+ } catch (error) {
297
+ if (!(error instanceof LocalInputError)) throw error;
298
+ failures.push({
299
+ code: error.code,
300
+ message: error.message,
301
+ side: "source",
302
+ });
303
+ }
304
+ }
305
+ const comparison = compareInventories(artifact, selected);
306
+ const manifests = {
307
+ artifact: manifestFor(artifact, comparison, "artifact"),
308
+ source: manifestFor(selected, comparison, "source"),
309
+ registry: { status: "unavailable", reason: "offline" },
310
+ };
311
+ for (const side of ["artifact", "source"])
312
+ if (manifests[side].status === "invalid")
313
+ failures.push({
314
+ code: `${side}-manifest-invalid`,
315
+ message: "Package manifest is not a valid UTF-8 JSON object",
316
+ side,
317
+ });
318
+ const inputs = {
319
+ artifact: {
320
+ ...artifact.digest,
321
+ kind: "local",
322
+ expectedIntegrity: { status: "unavailable" },
323
+ },
324
+ limits,
325
+ ...(sourceArchive
326
+ ? { sourceArchive: { ...sourceArchive.digest, kind: "local" } }
327
+ : {}),
328
+ ...(options.claim ? { claim: options.claim } : {}),
329
+ };
330
+ const report = createReport({
331
+ inputs,
332
+ source,
333
+ comparison,
334
+ manifests,
335
+ failures,
336
+ run: { surface: "cli", observations },
337
+ });
338
+ const inspected = addSyntaxInspection(
339
+ report,
340
+ inspectJavaScript(artifact, selected),
341
+ );
342
+ return addManifestObservations(inspected, { artifact, source: selected });
343
+ }
344
+
345
+ export { limitsFor as effectiveLimits, cacheBytes as preserveInputBytes };