@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,488 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open, opendir, unlink } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import path from "node:path";
5
+ import { LocalInputError } from "../core/pipeline.mjs";
6
+ import { validateReport } from "../core/report.mjs";
7
+ import { parsePackageSpec } from "../core/acquire.mjs";
8
+
9
+ const OWNER = "unmask-local-report-cache";
10
+ const DIRECTORY = "unmask-reports-v1";
11
+ const MARKER = ".unmask-owner.json";
12
+ const LOCK = ".unmask-write.lock";
13
+ const MAX_REPORT_BYTES = 16 * 1024 * 1024;
14
+ const MAX_SCAN_BYTES = 64 * 1024 * 1024;
15
+ const MAX_ENTRIES = 500;
16
+ const ownedMarker = JSON.stringify({ owner: OWNER, schemaVersion: "1" }) + "\n";
17
+ const failure = (
18
+ code = "cache-invalid",
19
+ message = "Cache operation refused: use a private UNMASK-owned cache with regular, unlinked, valid report files.",
20
+ ) => new LocalInputError(code, message);
21
+ const usage = () =>
22
+ failure(
23
+ "cache-usage",
24
+ "Use cache import --report FILE, cache inspect, or cache prune --older-than-days N --limit N [--apply]; optional --cache-dir DIR and --json.",
25
+ );
26
+ const digest = (text) => createHash("sha256").update(text).digest("hex");
27
+ const terminalJson = (value, space) =>
28
+ JSON.stringify(value, null, space).replace(
29
+ /[\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu,
30
+ (character) =>
31
+ `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
32
+ );
33
+ function parse(argv) {
34
+ const action = argv[0];
35
+ if (!["import", "inspect", "prune"].includes(action)) throw usage();
36
+ const options = { action };
37
+ const accepted = [
38
+ "--cache-dir",
39
+ "--json",
40
+ ...(action === "import" ? ["--report"] : []),
41
+ ...(action === "prune" ? ["--older-than-days", "--limit", "--apply"] : []),
42
+ ];
43
+ for (let index = 1; index < argv.length; index++) {
44
+ const key = argv[index];
45
+ if (!accepted.includes(key) || Object.hasOwn(options, key)) throw usage();
46
+ if (["--json", "--apply"].includes(key)) options[key] = true;
47
+ else {
48
+ const value = argv[++index];
49
+ if (!value || value.startsWith("--") || value.includes("\0"))
50
+ throw usage();
51
+ options[key] = value;
52
+ }
53
+ }
54
+ if (action === "import" && !options["--report"]) throw usage();
55
+ if (action === "prune") {
56
+ for (const key of ["--older-than-days", "--limit"]) {
57
+ if (!/^(0|[1-9]\d*)$/.test(options[key] ?? "")) throw usage();
58
+ options[key] = Number(options[key]);
59
+ if (!Number.isSafeInteger(options[key])) throw usage();
60
+ }
61
+ if (
62
+ options["--older-than-days"] > 36500 ||
63
+ options["--limit"] < 1 ||
64
+ options["--limit"] > 100
65
+ )
66
+ throw usage();
67
+ }
68
+ return options;
69
+ }
70
+ async function directoryChain(filename, create = false) {
71
+ const absolute = path.resolve(filename);
72
+ const { root } = path.parse(absolute);
73
+ let current = root;
74
+ for (const part of absolute
75
+ .slice(root.length)
76
+ .split(path.sep)
77
+ .filter(Boolean)) {
78
+ current = path.join(current, part);
79
+ let stat;
80
+ try {
81
+ stat = await lstat(current);
82
+ } catch (error) {
83
+ if (error.code !== "ENOENT") throw error;
84
+ if (!create) return null;
85
+ try {
86
+ await mkdir(current, { mode: 0o700 });
87
+ } catch (mkdirError) {
88
+ if (mkdirError.code !== "EEXIST") throw mkdirError;
89
+ }
90
+ stat = await lstat(current);
91
+ }
92
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw failure();
93
+ }
94
+ return lstat(absolute);
95
+ }
96
+ function sameFile(first, second) {
97
+ return first.dev === second.dev && first.ino === second.ino;
98
+ }
99
+ async function readRegular(filename, maxBytes) {
100
+ const stat = await lstat(filename);
101
+ if (
102
+ !stat.isFile() ||
103
+ stat.isSymbolicLink() ||
104
+ stat.nlink !== 1 ||
105
+ stat.size > maxBytes
106
+ )
107
+ throw failure();
108
+ const handle = await open(
109
+ filename,
110
+ constants.O_RDONLY | constants.O_NOFOLLOW,
111
+ );
112
+ try {
113
+ const opened = await handle.stat();
114
+ if (
115
+ !sameFile(stat, opened) ||
116
+ !opened.isFile() ||
117
+ opened.nlink !== 1 ||
118
+ opened.size > maxBytes
119
+ )
120
+ throw failure();
121
+ const buffer = Buffer.alloc(Math.min(opened.size + 1, maxBytes + 1));
122
+ let size = 0;
123
+ while (size < buffer.length) {
124
+ const { bytesRead } = await handle.read(
125
+ buffer,
126
+ size,
127
+ buffer.length - size,
128
+ size,
129
+ );
130
+ if (!bytesRead) break;
131
+ size += bytesRead;
132
+ }
133
+ if (size > maxBytes || size !== opened.size) throw failure();
134
+ const end = await handle.stat();
135
+ if (
136
+ end.size !== opened.size ||
137
+ end.mtimeMs !== opened.mtimeMs ||
138
+ end.ctimeMs !== opened.ctimeMs
139
+ )
140
+ throw failure();
141
+ return {
142
+ text: new TextDecoder("utf-8", { fatal: true }).decode(
143
+ buffer.subarray(0, size),
144
+ ),
145
+ stat: end,
146
+ };
147
+ } finally {
148
+ await handle.close();
149
+ }
150
+ }
151
+ async function assertRoot(cache) {
152
+ const stat = await directoryChain(cache.root);
153
+ if (
154
+ !stat ||
155
+ !sameFile(cache.stat, stat) ||
156
+ (stat.mode & 0o077) !== 0 ||
157
+ (typeof process.getuid === "function" && stat.uid !== process.getuid())
158
+ )
159
+ throw failure();
160
+ const marker = await readRegular(path.join(cache.root, MARKER), 256);
161
+ if (marker.text !== ownedMarker) throw failure();
162
+ }
163
+ async function createExclusive(filename, text) {
164
+ const handle = await open(
165
+ filename,
166
+ constants.O_WRONLY |
167
+ constants.O_CREAT |
168
+ constants.O_EXCL |
169
+ constants.O_NOFOLLOW,
170
+ 0o600,
171
+ );
172
+ try {
173
+ await handle.writeFile(text);
174
+ } finally {
175
+ await handle.close();
176
+ }
177
+ }
178
+ async function ownedRoot(base, create) {
179
+ const root = path.join(base, DIRECTORY);
180
+ const parentStat = await directoryChain(base, create);
181
+ if (!parentStat) return { root, exists: false };
182
+ let stat;
183
+ try {
184
+ stat = await lstat(root);
185
+ } catch (error) {
186
+ if (error.code !== "ENOENT") throw error;
187
+ if (!create) return { root, exists: false };
188
+ try {
189
+ await mkdir(root, { mode: 0o700 });
190
+ await createExclusive(path.join(root, MARKER), ownedMarker);
191
+ } catch (createError) {
192
+ if (createError.code !== "EEXIST") throw createError;
193
+ }
194
+ stat = await lstat(root);
195
+ }
196
+ const cache = { root, stat, exists: true };
197
+ await assertRoot(cache);
198
+ return cache;
199
+ }
200
+ function decodeReport(text) {
201
+ let report;
202
+ try {
203
+ report = JSON.parse(text);
204
+ validateReport(report);
205
+ } catch {
206
+ throw failure(
207
+ "cache-report-invalid",
208
+ "Input must be a bounded, valid UNMASK report JSON file; no result was imported or deleted.",
209
+ );
210
+ }
211
+ return report;
212
+ }
213
+ function scopeFor(report) {
214
+ const scope = { artifactSha256: report.inputs.artifact.sha256 };
215
+ try {
216
+ const claim = report.inputs.claim;
217
+ const spec = `${claim?.name}@${claim?.version}`;
218
+ parsePackageSpec(spec);
219
+ scope.package = spec;
220
+ } catch {}
221
+ return scope;
222
+ }
223
+ async function readCachedEntry(cache, name, now) {
224
+ if (!/^[a-f0-9]{64}\.json$/.test(name)) throw failure();
225
+ const { text, stat } = await readRegular(
226
+ path.join(cache.root, name),
227
+ MAX_REPORT_BYTES,
228
+ );
229
+ let value;
230
+ try {
231
+ value = JSON.parse(text);
232
+ } catch {
233
+ throw failure();
234
+ }
235
+ if (
236
+ !value ||
237
+ Array.isArray(value) ||
238
+ Object.keys(value).sort().join(",") !==
239
+ "cachedAt,owner,report,schemaVersion" ||
240
+ value.owner !== OWNER ||
241
+ value.schemaVersion !== "1" ||
242
+ !Number.isSafeInteger(value.cachedAt) ||
243
+ value.cachedAt < 0 ||
244
+ value.cachedAt > now ||
245
+ digest(JSON.stringify(value.report)) + ".json" !== name
246
+ )
247
+ throw failure();
248
+ const report = decodeReport(JSON.stringify(value.report));
249
+ return {
250
+ id: name.slice(0, -5),
251
+ filename: name,
252
+ cachedAt: new Date(value.cachedAt).toISOString(),
253
+ ageSeconds: Math.floor((now - value.cachedAt) / 1000),
254
+ sizeBytes: stat.size,
255
+ scope: scopeFor(report),
256
+ modelStatus: report.model.status,
257
+ stat,
258
+ };
259
+ }
260
+ async function cachedEntry(cache, name, now) {
261
+ if (!/^[a-f0-9]{64}\.json$/.test(name))
262
+ throw failure(
263
+ "cache-entry-invalid",
264
+ "Unrecognized entry in the managed cache root; inspect it locally. No deletion was started.",
265
+ );
266
+ try {
267
+ return await readCachedEntry(cache, name, now);
268
+ } catch {
269
+ throw failure(
270
+ "cache-entry-invalid",
271
+ `Cached report ${name} is invalid, linked, oversized or future-dated. Inspect this entry locally and check the clock before retrying; no deletion was started.`,
272
+ );
273
+ }
274
+ }
275
+ async function rejectBusyReader(cache) {
276
+ let lock;
277
+ try {
278
+ lock = await lstat(path.join(cache.root, LOCK));
279
+ } catch (error) {
280
+ if (error.code === "ENOENT") return;
281
+ throw error;
282
+ }
283
+ if (!lock.isFile() || lock.isSymbolicLink() || lock.nlink !== 1)
284
+ throw failure();
285
+ throw failure(
286
+ "cache-busy",
287
+ "A cache writer lock is present. Retry after the writer finishes; inspect a stale lock locally rather than removing an active lock.",
288
+ );
289
+ }
290
+ async function inspect(cache, now, ownsLock = false) {
291
+ if (!cache.exists) return [];
292
+ await assertRoot(cache);
293
+ if (!ownsLock) await rejectBusyReader(cache);
294
+ const directory = await opendir(cache.root);
295
+ const entries = [];
296
+ let size = 0;
297
+ for await (const item of directory) {
298
+ if (item.name === MARKER) continue;
299
+ if (item.name === LOCK) {
300
+ if (!ownsLock) await rejectBusyReader(cache);
301
+ const lock = await readRegular(path.join(cache.root, LOCK), 64);
302
+ if (lock.text !== "unmask-cache-write\n") throw failure();
303
+ continue;
304
+ }
305
+ if (entries.length >= MAX_ENTRIES)
306
+ throw failure(
307
+ "cache-limit",
308
+ "Cache scan exceeded 500 reports; no deletion was started.",
309
+ );
310
+ let entry;
311
+ try {
312
+ if (!ownsLock) await rejectBusyReader(cache);
313
+ entry = await cachedEntry(cache, item.name, now);
314
+ } catch (error) {
315
+ if (!ownsLock) await rejectBusyReader(cache);
316
+ throw error;
317
+ }
318
+ size += entry.sizeBytes;
319
+ if (size > MAX_SCAN_BYTES)
320
+ throw failure(
321
+ "cache-limit",
322
+ "Cache scan exceeded 64 MiB; no deletion was started.",
323
+ );
324
+ entries.push(entry);
325
+ }
326
+ await assertRoot(cache);
327
+ if (!ownsLock) await rejectBusyReader(cache);
328
+ return entries.sort(
329
+ (a, b) => a.cachedAt.localeCompare(b.cachedAt) || a.id.localeCompare(b.id),
330
+ );
331
+ }
332
+ const publicEntry = ({ stat, filename, ...entry }) => entry;
333
+ async function withLock(cache, operation) {
334
+ await assertRoot(cache);
335
+ const filename = path.join(cache.root, LOCK);
336
+ let stat;
337
+ try {
338
+ await createExclusive(filename, "unmask-cache-write\n");
339
+ stat = await lstat(filename);
340
+ } catch {
341
+ throw failure(
342
+ "cache-busy",
343
+ "Cache is busy or a write lock could not be created. Do not remove another process's active lock.",
344
+ );
345
+ }
346
+ try {
347
+ return await operation();
348
+ } finally {
349
+ await assertRoot(cache);
350
+ const current = await lstat(filename);
351
+ if (
352
+ !sameFile(current, stat) ||
353
+ !current.isFile() ||
354
+ current.isSymbolicLink()
355
+ )
356
+ throw failure();
357
+ await unlink(filename);
358
+ }
359
+ }
360
+ async function importReport(cache, report, now) {
361
+ return withLock(cache, async () => {
362
+ const entries = await inspect(cache, now, true);
363
+ const reportJson = JSON.stringify(report);
364
+ const id = digest(reportJson);
365
+ const text =
366
+ JSON.stringify({
367
+ owner: OWNER,
368
+ schemaVersion: "1",
369
+ cachedAt: now,
370
+ report,
371
+ }) + "\n";
372
+ if (Buffer.byteLength(text) > MAX_REPORT_BYTES)
373
+ throw failure(
374
+ "cache-limit",
375
+ "Report exceeds the 16 MiB cache entry limit.",
376
+ );
377
+ const existing = entries.find((entry) => entry.id === id);
378
+ if (
379
+ !existing &&
380
+ (entries.length >= MAX_ENTRIES ||
381
+ entries.reduce((total, entry) => total + entry.sizeBytes, 0) +
382
+ Buffer.byteLength(text) >
383
+ MAX_SCAN_BYTES)
384
+ )
385
+ throw failure(
386
+ "cache-limit",
387
+ "Cache capacity is bounded to 500 reports and 64 MiB; prune old reports explicitly before importing more.",
388
+ );
389
+ let imported = true;
390
+ try {
391
+ await assertRoot(cache);
392
+ await createExclusive(path.join(cache.root, id + ".json"), text);
393
+ } catch (error) {
394
+ if (error.code !== "EEXIST") throw error;
395
+ await cachedEntry(cache, id + ".json", now);
396
+ imported = false;
397
+ }
398
+ return {
399
+ imported,
400
+ entry: publicEntry(await cachedEntry(cache, id + ".json", now)),
401
+ };
402
+ });
403
+ }
404
+ export async function cacheCommand(argv, runtime = {}) {
405
+ const options = parse(argv);
406
+ const cwd = runtime.cwd ?? process.cwd();
407
+ const base = path.resolve(cwd, options["--cache-dir"] ?? ".unmask-cache");
408
+ const now = (runtime.now ?? Date.now)();
409
+ if (!Number.isSafeInteger(now) || now < 0 || now > 8640000000000000)
410
+ throw usage();
411
+ try {
412
+ let report;
413
+ if (options.action === "import") {
414
+ const input = path.resolve(cwd, options["--report"]);
415
+ if (!(await directoryChain(path.dirname(input)))) throw failure();
416
+ report = decodeReport((await readRegular(input, MAX_REPORT_BYTES)).text);
417
+ }
418
+ const cache = await ownedRoot(base, options.action === "import");
419
+ const result = {
420
+ schemaVersion: "1",
421
+ root: cache.root,
422
+ exists: cache.exists,
423
+ limitations: [
424
+ "Only explicitly imported local report snapshots are managed; input blobs and hosted caches are untouched.",
425
+ "Cached reports can be stale and are not a new analysis or safety guarantee.",
426
+ ],
427
+ };
428
+ if (report) Object.assign(result, await importReport(cache, report, now));
429
+ else if (options.action === "inspect")
430
+ result.entries = (await inspect(cache, now)).map(publicEntry);
431
+ else {
432
+ const prune = async () => {
433
+ const entries = await inspect(cache, now, !!options["--apply"]);
434
+ const candidates = entries
435
+ .filter(
436
+ (entry) =>
437
+ now - Date.parse(entry.cachedAt) >=
438
+ options["--older-than-days"] * 86400000,
439
+ )
440
+ .slice(0, options["--limit"]);
441
+ result.dryRun = !options["--apply"];
442
+ result.candidates = candidates.map(publicEntry);
443
+ result.deleted = 0;
444
+ for (const entry of options["--apply"] ? candidates : []) {
445
+ await assertRoot(cache);
446
+ const current = await lstat(path.join(cache.root, entry.filename));
447
+ if (
448
+ !current.isFile() ||
449
+ current.isSymbolicLink() ||
450
+ current.nlink !== 1 ||
451
+ !sameFile(current, entry.stat) ||
452
+ current.size !== entry.stat.size ||
453
+ current.mtimeMs !== entry.stat.mtimeMs ||
454
+ current.ctimeMs !== entry.stat.ctimeMs
455
+ )
456
+ throw failure();
457
+ await unlink(path.join(cache.root, entry.filename));
458
+ result.deleted++;
459
+ }
460
+ };
461
+ if (options["--apply"] && cache.exists) await withLock(cache, prune);
462
+ else await prune();
463
+ }
464
+ const stdout = runtime.stdout ?? process.stdout;
465
+ stdout.write(
466
+ options["--json"]
467
+ ? terminalJson(result, 2) + "\n"
468
+ : `UNMASK report cache ${terminalJson(result.root)}\n` +
469
+ (result.entry
470
+ ? `${result.imported ? "Imported" : "Already cached"}: ${terminalJson(result.entry.scope)}\n`
471
+ : (result.entries ?? result.candidates)
472
+ .map(
473
+ (entry) =>
474
+ `${entry.id} ${entry.ageSeconds}s ${entry.sizeBytes} bytes ${terminalJson(entry.scope)} model=${terminalJson(entry.modelStatus)}`,
475
+ )
476
+ .join("\n") + "\n") +
477
+ (options.action === "prune"
478
+ ? `${result.dryRun ? "Dry run" : "Applied"}: ${result.candidates.length} selected, ${result.deleted} deleted.\n`
479
+ : "") +
480
+ result.limitations.join("\n") +
481
+ "\n",
482
+ );
483
+ return 0;
484
+ } catch (error) {
485
+ if (error instanceof LocalInputError) throw error;
486
+ throw failure();
487
+ }
488
+ }