@xagents_in/unmask 0.1.2 → 0.1.3

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,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
+ }
@@ -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
+ }