@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,388 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { LocalInputError } from "../core/pipeline.mjs";
3
+ import { checkHosted } from "./hosted.mjs";
4
+ import { screenPlan } from "./install-guard.mjs";
5
+ import { nameFromKey, parseLockfile, unresolvedEdges } from "./lockfile.mjs";
6
+
7
+ export const PREFLIGHT_SCHEMA = "unmask-preflight-1";
8
+ const MAX_LOCKFILE_BYTES = 32 * 1024 * 1024;
9
+ const DEFAULT_MAX_CHECKS = 6;
10
+ const LIMIT_MAX_CHECKS = 100;
11
+ const failure = (code, message) => new LocalInputError(code, message);
12
+
13
+ export function lockIdentities(text) {
14
+ const lock = parseLockfile(text);
15
+ const identities = new Map();
16
+ const unsupported = [];
17
+ for (const entry of lock.entries.values()) {
18
+ if (entry.edgesValid === false)
19
+ unsupported.push({
20
+ path: entry.key,
21
+ reason: "invalid-dependencies",
22
+ version: entry.version,
23
+ resolved: entry.resolved,
24
+ });
25
+ if (entry.kind === "link" && !lock.entries.has(entry.resolved ?? ""))
26
+ unsupported.push({
27
+ path: entry.key,
28
+ reason: "dangling-link",
29
+ version: entry.version,
30
+ resolved: entry.resolved,
31
+ });
32
+ const claimsArtifact =
33
+ entry.kind === "workspace" && (entry.resolved || entry.integrity);
34
+ if (["root", "workspace", "link"].includes(entry.kind) && !claimsArtifact)
35
+ continue;
36
+ if (entry.kind !== "registry") {
37
+ unsupported.push({
38
+ path: entry.key,
39
+ reason: claimsArtifact ? "workspace-artifact-claim" : entry.reason,
40
+ version: entry.version,
41
+ resolved: entry.resolved,
42
+ });
43
+ continue;
44
+ }
45
+ const key = `${entry.name}@${entry.version}`;
46
+ const existing = identities.get(key) ?? {
47
+ name: entry.name,
48
+ version: entry.version,
49
+ paths: [],
50
+ sources: new Set(),
51
+ integrities: new Set(),
52
+ bound: true,
53
+ };
54
+ existing.paths.push(entry.key);
55
+ existing.sources.add(JSON.stringify([entry.resolved, entry.integrity]));
56
+ existing.integrities.add(entry.integrity);
57
+ existing.integrity ??= entry.integrity;
58
+ existing.bound &&= entry.bound === true && existing.integrities.size === 1;
59
+ identities.set(key, existing);
60
+ }
61
+ for (const edge of unresolvedEdges(lock))
62
+ unsupported.push({
63
+ path: `${edge.name} (required by ${edge.from || "root"})`,
64
+ reason: "unresolved-dependency",
65
+ version: edge.type,
66
+ resolved: null,
67
+ });
68
+ for (const item of identities.values()) delete item.integrities;
69
+ return { identities, unsupported };
70
+ }
71
+
72
+ export function lockDiff(baselineText, lockfileText) {
73
+ const before = lockIdentities(baselineText);
74
+ const after = lockIdentities(lockfileText);
75
+ const added = [];
76
+ for (const [key, item] of after.identities) {
77
+ const prior = before.identities.get(key);
78
+ const { sources, ...identity } = item;
79
+ if (!prior) added.push({ ...identity, reason: "new-identity" });
80
+ else if ([...sources].some((value) => !prior.sources.has(value)))
81
+ added.push({ ...identity, reason: "source-changed" });
82
+ }
83
+ const removed = [...before.identities.values()]
84
+ .filter((item) => !after.identities.has(`${item.name}@${item.version}`))
85
+ .map(({ name, version }) => ({ name, version }));
86
+ const seen = new Set(before.unsupported.map((item) => JSON.stringify(item)));
87
+ const unsupported = after.unsupported
88
+ .filter((item) => !seen.has(JSON.stringify(item)))
89
+ .map(({ path, reason }) => ({ path, reason }));
90
+ return {
91
+ baselineIdentities: before.identities.size,
92
+ lockfileIdentities: after.identities.size,
93
+ added,
94
+ removed,
95
+ unsupported,
96
+ };
97
+ }
98
+
99
+ async function checkOne(item, checkPackage, signal) {
100
+ const spec = `${item.name}@${item.version}`;
101
+ try {
102
+ const report = await checkPackage(spec, { signal });
103
+ return {
104
+ name: item.name,
105
+ version: item.version,
106
+ status: "completed",
107
+ bound: item.bound,
108
+ exitCode: report.policy.exitCode,
109
+ findings: report.policy.findings.map((finding) => finding.ruleId),
110
+ coverageComplete: report.coverage.complete === true,
111
+ };
112
+ } catch (error) {
113
+ if (signal?.aborted)
114
+ return {
115
+ name: item.name,
116
+ version: item.version,
117
+ status: "cancelled",
118
+ };
119
+ return {
120
+ name: item.name,
121
+ version: item.version,
122
+ status: "unavailable",
123
+ reason:
124
+ error instanceof LocalInputError ? error.code : "internal-check-error",
125
+ };
126
+ }
127
+ }
128
+
129
+ export async function preflight({
130
+ baselineText,
131
+ lockfileText,
132
+ check = true,
133
+ maxChecks = DEFAULT_MAX_CHECKS,
134
+ checkPackage,
135
+ screenAdvisories,
136
+ signal,
137
+ onCheck = () => {},
138
+ }) {
139
+ const diff = lockDiff(baselineText, lockfileText);
140
+ const screening = check && typeof screenAdvisories === "function";
141
+ const checks = [];
142
+ for (const [index, item] of diff.added.entries()) {
143
+ if (!check || index >= maxChecks || signal?.aborted) {
144
+ checks.push({
145
+ name: item.name,
146
+ version: item.version,
147
+ status: signal?.aborted ? "cancelled" : "not-checked",
148
+ ...(signal?.aborted
149
+ ? {}
150
+ : { reason: check ? "max-checks" : "checks-disabled" }),
151
+ });
152
+ continue;
153
+ }
154
+ const result = await checkOne(item, checkPackage, signal);
155
+ checks.push(result);
156
+ onCheck(result);
157
+ }
158
+ const screenable = diff.added.filter((item) => item.bound);
159
+ if (screening && screenable.length && !signal?.aborted) {
160
+ let decisions = new Map();
161
+ try {
162
+ decisions = await screenAdvisories(screenable, { signal });
163
+ } catch {}
164
+ for (const [index, item] of diff.added.entries()) {
165
+ if (!item.bound) continue;
166
+ const found = decisions.get(`${item.name}@${item.version}`);
167
+ checks[index].advisory = found
168
+ ? {
169
+ decision: found.decision,
170
+ reasons: found.reasons,
171
+ advisories: found.advisories,
172
+ }
173
+ : {
174
+ decision: signal?.aborted ? "cancelled" : "unknown",
175
+ reasons: ["advisory-screening-unavailable"],
176
+ advisories: [],
177
+ };
178
+ }
179
+ }
180
+ const advisorySettled = (item) =>
181
+ !screening || ["allow", "warn", "block"].includes(item.advisory?.decision);
182
+ const advisoryScreened = checks.filter(
183
+ (item) => item.advisory && advisorySettled(item),
184
+ ).length;
185
+ const checked = checks.filter(
186
+ (item) =>
187
+ item.status === "completed" &&
188
+ item.exitCode !== 2 &&
189
+ item.bound &&
190
+ advisorySettled(item),
191
+ ).length;
192
+ const unbound = diff.added.filter((item) => !item.bound).length;
193
+ const complete =
194
+ diff.unsupported.length === 0 && checked === diff.added.length;
195
+ const cancelled = checks.some((item) => item.status === "cancelled");
196
+ const findings = checks.some(
197
+ (item) => item.exitCode === 3 || item.advisory?.decision === "block",
198
+ );
199
+ const status = findings
200
+ ? "findings"
201
+ : cancelled
202
+ ? "cancelled"
203
+ : complete
204
+ ? "pass"
205
+ : "incomplete";
206
+ return {
207
+ schema: PREFLIGHT_SCHEMA,
208
+ status,
209
+ exitCode: findings ? 3 : complete && !cancelled ? 0 : 2,
210
+ cancelled,
211
+ baseline: { identities: diff.baselineIdentities },
212
+ lockfile: { identities: diff.lockfileIdentities },
213
+ changes: { added: diff.added, removed: diff.removed },
214
+ checks,
215
+ coverage: {
216
+ complete,
217
+ required: diff.added.length,
218
+ checked,
219
+ unbound,
220
+ advisoryScreening: screening ? "strict-guard-policy" : "not-requested",
221
+ advisoryScreened,
222
+ unsupported: diff.unsupported,
223
+ },
224
+ note: "pass means no default-policy finding or advisory block in checked changes; it does not mean safe.",
225
+ };
226
+ }
227
+
228
+ export async function readLockText(filename) {
229
+ let info;
230
+ try {
231
+ info = await stat(filename);
232
+ } catch {
233
+ throw failure("lockfile-unreadable", `Cannot read lockfile ${filename}.`);
234
+ }
235
+ if (!info.isFile() || info.size > MAX_LOCKFILE_BYTES)
236
+ throw failure(
237
+ "lockfile-unreadable",
238
+ `Lockfile ${filename} must be a regular file of at most 32 MiB.`,
239
+ );
240
+ const bytes = await readFile(filename);
241
+ if (bytes.length > MAX_LOCKFILE_BYTES)
242
+ throw failure("lockfile-unreadable", `Lockfile ${filename} is too large.`);
243
+ return bytes.toString("utf8");
244
+ }
245
+
246
+ export function parseCount(value, name) {
247
+ if (!/^[1-9]\d{0,2}$/.test(value ?? "") || Number(value) > LIMIT_MAX_CHECKS)
248
+ throw failure("invalid-value", `${name} must be an integer from 1 to 100.`);
249
+ return Number(value);
250
+ }
251
+
252
+ export function hostedChecker(runtime) {
253
+ return (spec, { signal } = {}) =>
254
+ checkHosted(spec, {
255
+ serverUrl: (runtime.env ?? process.env).UNMASK_SERVER_URL,
256
+ fetchImpl: runtime.fetchImpl,
257
+ signal: signal ?? runtime.signal,
258
+ limits: runtime.hostedLimits,
259
+ });
260
+ }
261
+
262
+ export function hostedScreener(runtime) {
263
+ return async (items, { signal } = {}) => {
264
+ const packages = { "": { name: "unmask-preflight", version: "0.0.0" } };
265
+ // Every identity keeps its own lockfile locations; alias-only identities get a private nested slot.
266
+ for (const [index, item] of items.entries()) {
267
+ const entry = {
268
+ version: item.version,
269
+ resolved: `https://registry.npmjs.org/${item.name}/-/${item.name.split("/").pop()}-${item.version}.tgz`,
270
+ integrity: item.integrity,
271
+ };
272
+ const keys = item.paths.filter((key) => nameFromKey(key) === item.name);
273
+ if (!keys.length)
274
+ keys.push(
275
+ `node_modules/unmask-preflight-slot-${index}/node_modules/${item.name}`,
276
+ );
277
+ for (const key of keys) packages[key] = entry;
278
+ }
279
+ const result = await screenPlan(
280
+ {
281
+ packages: items.map((item) => ({
282
+ id: `${item.name}@${item.version}`,
283
+ name: item.name,
284
+ version: item.version,
285
+ integrity: item.integrity,
286
+ binding: "registry-integrity",
287
+ paths: item.paths,
288
+ })),
289
+ lockBytes: Buffer.from(
290
+ JSON.stringify({ lockfileVersion: 3, packages }),
291
+ ),
292
+ },
293
+ {
294
+ source: "hosted",
295
+ policy: "strict",
296
+ fetchImpl: runtime.fetchImpl,
297
+ signal: signal ?? runtime.signal,
298
+ serverUrl: (runtime.env ?? process.env).UNMASK_SERVER_URL,
299
+ },
300
+ );
301
+ return new Map(result.decisions.map((decision) => [decision.id, decision]));
302
+ };
303
+ }
304
+
305
+ export function formatPreflight(result) {
306
+ const lines = [
307
+ `preflight ${result.status}: ${result.changes.added.length} changed, ${result.changes.removed.length} removed, ${result.coverage.checked}/${result.coverage.required} checked`,
308
+ ];
309
+ for (const item of result.checks) {
310
+ const detail =
311
+ item.status === "completed"
312
+ ? `exit ${item.exitCode}${item.findings.length ? ` (${item.findings.join(", ")})` : ""}${item.bound ? "" : "; unbound: lockfile resolved/integrity not verified"}`
313
+ : item.status + (item.reason ? ` (${item.reason})` : "");
314
+ const advisory = item.advisory
315
+ ? `; advisory ${item.advisory.decision}${item.advisory.reasons.length ? ` (${[...item.advisory.reasons, ...item.advisory.advisories].join(", ")})` : ""}`
316
+ : "";
317
+ lines.push(` ${item.name}@${item.version}: ${detail}${advisory}`);
318
+ }
319
+ for (const item of result.coverage.unsupported)
320
+ lines.push(` ${item.path}: unsupported (${item.reason})`);
321
+ lines.push(result.note);
322
+ return lines.join("\n") + "\n";
323
+ }
324
+
325
+ function parseArgs(argv) {
326
+ const options = { lockfile: "package-lock.json", check: true, json: false };
327
+ const seen = new Set();
328
+ for (let index = 0; index < argv.length; index++) {
329
+ const key = argv[index];
330
+ if (seen.has(key))
331
+ throw failure(
332
+ "duplicate-option",
333
+ "Each option may be supplied only once",
334
+ );
335
+ seen.add(key);
336
+ if (key === "--json") options.json = true;
337
+ else if (key === "--no-check") options.check = false;
338
+ else if (["--lockfile", "--baseline", "--max-checks"].includes(key)) {
339
+ const value = argv[++index];
340
+ if (!value || value.startsWith("--"))
341
+ throw failure("missing-value", "An option is missing its value");
342
+ options[key.slice(2)] = value;
343
+ } else
344
+ throw failure(
345
+ "unknown-option",
346
+ "Only the documented preflight options are supported",
347
+ );
348
+ }
349
+ if (!options.baseline)
350
+ throw failure("usage", "preflight requires --baseline FILE");
351
+ options.maxChecks =
352
+ options["max-checks"] === undefined
353
+ ? DEFAULT_MAX_CHECKS
354
+ : parseCount(options["max-checks"], "--max-checks");
355
+ return options;
356
+ }
357
+
358
+ export async function preflightMain(argv, runtime = {}) {
359
+ try {
360
+ const options = parseArgs(argv);
361
+ const [baselineText, lockfileText] = await Promise.all([
362
+ readLockText(options.baseline),
363
+ readLockText(options.lockfile),
364
+ ]);
365
+ const result = await preflight({
366
+ baselineText,
367
+ lockfileText,
368
+ check: options.check,
369
+ maxChecks: options.maxChecks,
370
+ checkPackage: runtime.checkPackage ?? hostedChecker(runtime),
371
+ screenAdvisories: runtime.screenAdvisories ?? hostedScreener(runtime),
372
+ signal: runtime.signal,
373
+ });
374
+ process.stdout.write(
375
+ options.json
376
+ ? JSON.stringify(result, null, 2) + "\n"
377
+ : formatPreflight(result),
378
+ );
379
+ return result.exitCode;
380
+ } catch (error) {
381
+ if (error instanceof LocalInputError) {
382
+ process.stderr.write(`${error.code}: ${error.message}\n`);
383
+ return 2;
384
+ }
385
+ process.stderr.write("Internal preflight error; no result is claimed.\n");
386
+ return 1;
387
+ }
388
+ }
@@ -0,0 +1,100 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { LocalInputError } from "../core/pipeline.mjs";
5
+ import { toSarif } from "../core/sarif.mjs";
6
+
7
+ const MAX_INPUT_BYTES = 64 * 1024 * 1024;
8
+ const failure = (code, message) => new LocalInputError(code, message);
9
+
10
+ export function parseExportOptions(argv, allowed) {
11
+ const options = {};
12
+ for (let index = 0; index < argv.length; index++) {
13
+ const key = argv[index];
14
+ if (!Object.hasOwn(allowed, key))
15
+ throw failure(
16
+ "unknown-option",
17
+ `Only ${Object.keys(allowed).join(", ")} are supported`,
18
+ );
19
+ if (Object.hasOwn(options, allowed[key]))
20
+ throw failure(
21
+ "duplicate-option",
22
+ "Each option may be supplied only once",
23
+ );
24
+ const value = argv[++index];
25
+ if (!value || value.startsWith("--"))
26
+ throw failure("missing-value", "An option is missing its value");
27
+ options[allowed[key]] = value;
28
+ }
29
+ if (!options.report)
30
+ throw failure(
31
+ "usage",
32
+ "Supply an existing UNMASK report with --report FILE",
33
+ );
34
+ return options;
35
+ }
36
+
37
+ export async function readJsonFile(filename, label) {
38
+ let handle;
39
+ try {
40
+ handle = await open(filename, constants.O_RDONLY | constants.O_NONBLOCK);
41
+ const stat = await handle.stat();
42
+ if (!stat.isFile() || stat.size > MAX_INPUT_BYTES)
43
+ throw failure(
44
+ "input-read-failed",
45
+ `The ${label} must be a regular file of at most ${MAX_INPUT_BYTES} bytes`,
46
+ );
47
+ const bytes = await handle.readFile();
48
+ if (bytes.byteLength > MAX_INPUT_BYTES)
49
+ throw failure("input-read-failed", `The ${label} exceeds the size limit`);
50
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
51
+ } catch (error) {
52
+ if (error instanceof LocalInputError) throw error;
53
+ throw failure(
54
+ "input-read-failed",
55
+ `The ${label} could not be read as UTF-8 JSON`,
56
+ );
57
+ } finally {
58
+ await handle?.close();
59
+ }
60
+ }
61
+
62
+ export async function readExportInputs(options) {
63
+ const report = await readJsonFile(options.report, "report");
64
+ const details =
65
+ options.details === undefined
66
+ ? undefined
67
+ : await readJsonFile(options.details, "details document");
68
+ return { report, details };
69
+ }
70
+
71
+ export async function writeExclusive(filename, contents) {
72
+ let handle;
73
+ try {
74
+ handle = await open(filename, "wx", 0o600);
75
+ await handle.writeFile(contents);
76
+ } catch {
77
+ throw failure(
78
+ "output-write-failed",
79
+ "Output requires a writable path that does not already exist",
80
+ );
81
+ } finally {
82
+ await handle?.close();
83
+ }
84
+ }
85
+
86
+ export async function sarifCommand(argv, stdout = process.stdout) {
87
+ const options = parseExportOptions(argv, {
88
+ "--report": "report",
89
+ "--details": "details",
90
+ "--out": "out",
91
+ });
92
+ const { report, details } = await readExportInputs(options);
93
+ const json = JSON.stringify(toSarif(report, details), null, 2) + "\n";
94
+ if (options.out === undefined) stdout.write(json);
95
+ else {
96
+ await writeExclusive(options.out, json);
97
+ stdout.write(`SARIF written to ${path.resolve(options.out)}\n`);
98
+ }
99
+ return 0;
100
+ }
@@ -0,0 +1,128 @@
1
+ import {
2
+ AcquisitionError,
3
+ fetchBlob,
4
+ parsePackageSpec,
5
+ } from "../core/acquire.mjs";
6
+ import { LocalInputError } from "../core/pipeline.mjs";
7
+ import { checkHosted } from "./hosted.mjs";
8
+
9
+ const failure = (code, message) => new LocalInputError(code, message);
10
+ const terminalValue = (value) =>
11
+ JSON.stringify(value).replace(
12
+ /[\u007f-\u009f\u2028\u2029]/gu,
13
+ (character) =>
14
+ `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
15
+ );
16
+
17
+ export async function resolveScanSpec(spec, runtime = {}) {
18
+ if (typeof spec !== "string" || spec.length > 512)
19
+ throw failure(
20
+ "invalid-package-spec",
21
+ "Use a package name, exact version or dist-tag.",
22
+ );
23
+ try {
24
+ const identity = parsePackageSpec(spec);
25
+ return {
26
+ ...identity,
27
+ selector: identity.version,
28
+ spec,
29
+ registryUrl: null,
30
+ observedAt: null,
31
+ };
32
+ } catch (error) {
33
+ if (!(error instanceof AcquisitionError)) throw error;
34
+ }
35
+ const at = spec.lastIndexOf("@");
36
+ const name = at > 0 ? spec.slice(0, at) : spec;
37
+ const selector = at > 0 ? spec.slice(at + 1) : "latest";
38
+ try {
39
+ parsePackageSpec(`${name}@0.0.0`);
40
+ } catch {
41
+ throw failure("invalid-package-spec", "Use a public npm package name.");
42
+ }
43
+ if (!/^[a-zA-Z][a-zA-Z0-9._-]{0,127}$/.test(selector))
44
+ throw failure(
45
+ "unsupported-selector",
46
+ "Semver ranges are unsupported; use an exact version or named dist-tag such as latest.",
47
+ );
48
+ const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(selector)}`;
49
+ try {
50
+ const blob = await fetchBlob(registryUrl, {
51
+ fetchImpl: runtime.fetchImpl,
52
+ signal: runtime.signal,
53
+ maxBytes: 1024 * 1024,
54
+ timeoutMs: 15000,
55
+ });
56
+ let metadata;
57
+ try {
58
+ metadata = JSON.parse(
59
+ new TextDecoder("utf-8", { fatal: true }).decode(blob.bytes),
60
+ );
61
+ } catch {
62
+ throw failure(
63
+ "invalid-registry-document",
64
+ "Registry resolution must return valid UTF-8 JSON.",
65
+ );
66
+ }
67
+ if (
68
+ !metadata ||
69
+ metadata.name !== name ||
70
+ typeof metadata.version !== "string"
71
+ )
72
+ throw failure(
73
+ "registry-identity-mismatch",
74
+ "Registry resolution returned an unexpected package identity.",
75
+ );
76
+ let identity;
77
+ try {
78
+ identity = parsePackageSpec(`${name}@${metadata.version}`);
79
+ } catch {
80
+ throw failure(
81
+ "invalid-registry-document",
82
+ "Registry resolution did not return an exact version.",
83
+ );
84
+ }
85
+ return {
86
+ ...identity,
87
+ selector,
88
+ spec: `${name}@${identity.version}`,
89
+ registryUrl,
90
+ observedAt: blob.observation.observedAt,
91
+ };
92
+ } catch (error) {
93
+ if (error instanceof AcquisitionError)
94
+ throw failure(error.code, error.message);
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ export async function runScan(argv, runtime = {}) {
100
+ if (
101
+ argv.length < 1 ||
102
+ argv.length > 2 ||
103
+ (argv.length === 2 && argv[1] !== "--json")
104
+ )
105
+ throw failure(
106
+ "usage",
107
+ "Use scan package[@version-or-tag] [--json]. Semver ranges are unsupported.",
108
+ );
109
+ const json = argv[1] === "--json";
110
+ const stdout = runtime.stdout ?? process.stdout;
111
+ const resolution = await resolveScanSpec(argv[0], runtime);
112
+ if (!json) stdout.write(`Pinned ${argv[0]} -> ${resolution.spec}\n`);
113
+ const report = await checkHosted(resolution.spec, {
114
+ serverUrl: (runtime.env ?? process.env).UNMASK_SERVER_URL,
115
+ fetchImpl: runtime.fetchImpl,
116
+ signal: runtime.signal,
117
+ limits: runtime.hostedLimits,
118
+ });
119
+ if (json)
120
+ stdout.write(
121
+ JSON.stringify({ requested: argv[0], resolution, report }) + "\n",
122
+ );
123
+ else
124
+ stdout.write(
125
+ `Completed ${resolution.spec}: policy exit ${report.policy.exitCode}; AI status ${terminalValue(report.model?.status ?? "unavailable")}.\nNo findings do not establish safety.\n`,
126
+ );
127
+ return report.policy.exitCode;
128
+ }