@xagents_in/unmask 0.1.3 → 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 (50) hide show
  1. package/README.md +86 -4
  2. package/dist/cli/annotations.mjs +145 -0
  3. package/dist/cli/audit.mjs +223 -0
  4. package/dist/cli/cache.mjs +488 -0
  5. package/dist/cli/compare.mjs +261 -0
  6. package/dist/cli/config.mjs +141 -0
  7. package/dist/cli/discovery.mjs +372 -0
  8. package/dist/cli/doctor.mjs +354 -0
  9. package/dist/cli/exceptions.mjs +170 -0
  10. package/dist/cli/file.mjs +271 -0
  11. package/dist/cli/files.mjs +141 -0
  12. package/dist/cli/history.mjs +232 -0
  13. package/dist/cli/hosted.mjs +251 -43
  14. package/dist/cli/info.mjs +125 -0
  15. package/dist/cli/init.mjs +57 -0
  16. package/dist/cli/install-guard.mjs +896 -0
  17. package/dist/cli/lockfile.mjs +330 -0
  18. package/dist/cli/main.mjs +97 -14
  19. package/dist/cli/preflight.mjs +388 -0
  20. package/dist/cli/sarif.mjs +100 -0
  21. package/dist/cli/shell-integration.mjs +122 -0
  22. package/dist/cli/terminal.mjs +396 -0
  23. package/dist/cli/watch.mjs +233 -0
  24. package/dist/cli/why.mjs +256 -0
  25. package/dist/core/advisories.mjs +101 -24
  26. package/dist/core/advisory-cache.mjs +295 -0
  27. package/dist/core/behavior-signals.mjs +1088 -0
  28. package/dist/core/dependency-graph.mjs +332 -0
  29. package/dist/core/evidence-exports.mjs +469 -0
  30. package/dist/core/guard-config.mjs +463 -0
  31. package/dist/core/guard-policy.mjs +308 -0
  32. package/dist/core/package-details.mjs +42 -6
  33. package/dist/core/report.mjs +7 -0
  34. package/dist/core/sarif.mjs +556 -0
  35. package/dist/core/syntax.mjs +296 -15
  36. package/dist/model/client.mjs +204 -56
  37. package/dist/model/diff-evidence.mjs +177 -0
  38. package/dist/model/summary.mjs +183 -0
  39. package/dist/server/advisory-result.mjs +137 -0
  40. package/dist/server/app.mjs +60 -2
  41. package/dist/server/dependency-screening.mjs +386 -0
  42. package/dist/server/exports.mjs +63 -0
  43. package/dist/server/guard-decision.mjs +37 -0
  44. package/dist/server/main.mjs +25 -7
  45. package/dist/server/queue.mjs +95 -53
  46. package/dist/server/store.mjs +21 -1
  47. package/dist/version.json +1 -1
  48. package/dist/viewer/render.mjs +10 -2
  49. package/dist/viewer/shell.mjs +1 -1
  50. package/package.json +1 -1
@@ -0,0 +1,463 @@
1
+ import { link, lstat, open, realpath, rename, unlink } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { randomBytes } from "node:crypto";
4
+ import path from "node:path";
5
+ import { AcquisitionError, parsePackageSpec } from "./acquire.mjs";
6
+
7
+ export const GUARD_CONFIG_SCHEMA = 1;
8
+ export const GUARD_CONFIG_FILE = ".unmask.json";
9
+ export const DEFAULT_PROFILE = "strict";
10
+ export const PROFILES = Object.freeze({
11
+ strict: Object.freeze({
12
+ unknownDecision: "block",
13
+ blockSeverity: "medium",
14
+ knownMalware: "block",
15
+ aiSignals: "nonbinding",
16
+ enforcement: "enforce",
17
+ }),
18
+ balanced: Object.freeze({
19
+ unknownDecision: "unknown",
20
+ blockSeverity: "high",
21
+ knownMalware: "block",
22
+ aiSignals: "nonbinding",
23
+ enforcement: "enforce",
24
+ }),
25
+ audit: Object.freeze({
26
+ unknownDecision: "unknown",
27
+ blockSeverity: "critical",
28
+ knownMalware: "block",
29
+ aiSignals: "nonbinding",
30
+ enforcement: "audit",
31
+ }),
32
+ });
33
+ export const GUARD_SEMANTICS = Object.freeze([
34
+ "Profile names and settings mirror the S07 guard evaluator; there are no per-setting overrides.",
35
+ "strict blocks incomplete evidence and advisories of medium (moderate) severity or higher.",
36
+ "balanced blocks high or critical advisories; incomplete evidence stays unknown and is not permitted.",
37
+ "audit is nonenforcing: decisions are reported but nothing is blocked; a project-stored audit profile takes effect only when the invoking command explicitly opts in.",
38
+ "Known malware is always a block decision, and AI signals are nonbinding.",
39
+ ]);
40
+ export const SETTINGS = Object.freeze({ profile: Object.keys(PROFILES) });
41
+ const MAX_CONFIG_BYTES = 64 * 1024;
42
+ export const MAX_EXCEPTIONS = 200;
43
+ export const MAX_EXCEPTION_DAYS = 90;
44
+ const DAY_MS = 24 * 60 * 60 * 1000;
45
+ const CLOCK_SKEW_MS = 5 * 60 * 1000;
46
+ const SECRET_LIKE =
47
+ /(npm_[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|-----BEGIN|xox[abpr]-[A-Za-z0-9-]{10,})/u;
48
+
49
+ export class GuardConfigError extends Error {
50
+ constructor(code, message) {
51
+ super(message);
52
+ this.name = "GuardConfigError";
53
+ this.code = code;
54
+ this.exitCode = 2;
55
+ }
56
+ }
57
+ const fail = (code, message) => {
58
+ throw new GuardConfigError(code, message);
59
+ };
60
+ const plainObject = (value) =>
61
+ value !== null &&
62
+ typeof value === "object" &&
63
+ !Array.isArray(value) &&
64
+ Object.getPrototypeOf(value) === Object.prototype;
65
+
66
+ export function validateSetting(key, value) {
67
+ if (!Object.hasOwn(SETTINGS, key))
68
+ fail(
69
+ "invalid-setting",
70
+ `Unknown guard setting; expected one of ${Object.keys(SETTINGS).join(", ")}`,
71
+ );
72
+ if (typeof value !== "string" || !SETTINGS[key].includes(value))
73
+ fail(
74
+ "invalid-value",
75
+ `Invalid ${key}; expected one of ${SETTINGS[key].join(", ")}`,
76
+ );
77
+ return value;
78
+ }
79
+
80
+ export function defaultGuardConfig() {
81
+ return { schemaVersion: GUARD_CONFIG_SCHEMA, profile: DEFAULT_PROFILE };
82
+ }
83
+
84
+ export function validateGuardConfig(value, { now = new Date() } = {}) {
85
+ if (!plainObject(value))
86
+ fail("invalid-config", "Guard configuration must be a JSON object");
87
+ for (const key of Object.keys(value))
88
+ if (!["schemaVersion", "profile", "exceptions"].includes(key))
89
+ fail("invalid-config", "Guard configuration contains an unknown field");
90
+ if (value.schemaVersion !== GUARD_CONFIG_SCHEMA)
91
+ fail("invalid-config", "Unsupported guard configuration schemaVersion");
92
+ validateSetting("profile", value.profile);
93
+ const config = { schemaVersion: GUARD_CONFIG_SCHEMA, profile: value.profile };
94
+ if (value.exceptions !== undefined) {
95
+ if (
96
+ !Array.isArray(value.exceptions) ||
97
+ value.exceptions.length > MAX_EXCEPTIONS
98
+ )
99
+ fail(
100
+ "invalid-config",
101
+ `Guard exceptions must be an array of at most ${MAX_EXCEPTIONS}`,
102
+ );
103
+ const seen = new Set();
104
+ const exceptions = value.exceptions.map((entry) =>
105
+ validateException(entry, now),
106
+ );
107
+ for (const entry of exceptions) {
108
+ const key = `${entry.name}@${entry.version}`;
109
+ if (seen.has(key))
110
+ fail("duplicate-exception", `Duplicate exception for ${key}`);
111
+ seen.add(key);
112
+ }
113
+ if (exceptions.length) config.exceptions = exceptions;
114
+ }
115
+ return config;
116
+ }
117
+
118
+ export function exactPackage(spec) {
119
+ try {
120
+ return parsePackageSpec(spec);
121
+ } catch (error) {
122
+ if (error instanceof AcquisitionError)
123
+ fail(
124
+ "invalid-package-spec",
125
+ "Exceptions require one exact name@version; ranges, tags and wildcards are refused",
126
+ );
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ function timestamp(value, field) {
132
+ if (
133
+ typeof value !== "string" ||
134
+ Number.isNaN(Date.parse(value)) ||
135
+ new Date(value).toISOString() !== value
136
+ )
137
+ fail("invalid-config", `Exception ${field} must be an ISO UTC timestamp`);
138
+ return Date.parse(value);
139
+ }
140
+
141
+ export function validateReason(reason) {
142
+ if (
143
+ typeof reason !== "string" ||
144
+ reason.trim().length < 3 ||
145
+ reason.length > 200 ||
146
+ /[\0-\x1f\x7f]/u.test(reason)
147
+ )
148
+ fail(
149
+ "invalid-reason",
150
+ "Exception reason must be 3-200 printable characters",
151
+ );
152
+ if (SECRET_LIKE.test(reason))
153
+ fail("invalid-reason", "Exception reason looks like a credential");
154
+ return reason;
155
+ }
156
+
157
+ export function validateException(value, now = new Date()) {
158
+ if (!plainObject(value))
159
+ fail("invalid-config", "Each guard exception must be an object");
160
+ for (const key of Object.keys(value))
161
+ if (!["name", "version", "reason", "expires", "created"].includes(key))
162
+ fail("invalid-config", "Guard exception contains an unknown field");
163
+ if (typeof value.name !== "string" || typeof value.version !== "string")
164
+ fail("invalid-package-spec", "Exception requires exact name and version");
165
+ const { name, version } = exactPackage(`${value.name}@${value.version}`);
166
+ if (name !== value.name || version !== value.version)
167
+ fail("invalid-package-spec", "Exception identity must be exact");
168
+ const created = timestamp(value.created, "created");
169
+ const expires = timestamp(value.expires, "expires");
170
+ if (
171
+ expires <= created ||
172
+ expires - created > MAX_EXCEPTION_DAYS * DAY_MS ||
173
+ created > now.getTime() + CLOCK_SKEW_MS ||
174
+ expires - now.getTime() > MAX_EXCEPTION_DAYS * DAY_MS
175
+ )
176
+ fail(
177
+ "invalid-expiry",
178
+ `Exception must be created no later than now and expire within ${MAX_EXCEPTION_DAYS} days of creation and of now`,
179
+ );
180
+ return {
181
+ name,
182
+ version,
183
+ reason: validateReason(value.reason),
184
+ expires: value.expires,
185
+ created: value.created,
186
+ };
187
+ }
188
+
189
+ export function newException(spec, reason, expires, now = new Date()) {
190
+ const { name, version } = exactPackage(spec);
191
+ const match =
192
+ typeof expires === "string" &&
193
+ /^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2}:\d{2})(\.\d{3})?Z)?$/u.exec(expires);
194
+ const day = match
195
+ ? `${match[1]}T${match[2] ?? "00:00:00"}${match[3] ?? ".000"}Z`
196
+ : "";
197
+ const expiry = Date.parse(day);
198
+ if (!match || Number.isNaN(expiry) || new Date(expiry).toISOString() !== day)
199
+ fail(
200
+ "invalid-expiry",
201
+ "Exception requires --expires as a real calendar date YYYY-MM-DD or a UTC timestamp YYYY-MM-DDTHH:MM:SSZ",
202
+ );
203
+ if (expiry <= now.getTime())
204
+ fail("expired-exception", "Exception expiry must be in the future");
205
+ if (expiry - now.getTime() > MAX_EXCEPTION_DAYS * DAY_MS)
206
+ fail(
207
+ "invalid-expiry",
208
+ `Exception expiry must be within ${MAX_EXCEPTION_DAYS} days`,
209
+ );
210
+ return validateException(
211
+ {
212
+ name,
213
+ version,
214
+ reason,
215
+ expires: new Date(expiry).toISOString(),
216
+ created: now.toISOString(),
217
+ },
218
+ now,
219
+ );
220
+ }
221
+
222
+ export function exceptionStatuses(config, now = new Date()) {
223
+ return (config.exceptions ?? []).map((entry) => ({
224
+ ...entry,
225
+ status: Date.parse(entry.expires) > now.getTime() ? "active" : "expired",
226
+ }));
227
+ }
228
+
229
+ export function activeExceptionFor(
230
+ config,
231
+ identity,
232
+ { now = new Date(), knownMalware = false } = {},
233
+ ) {
234
+ if (knownMalware) return null;
235
+ return (
236
+ exceptionStatuses(config, now).find(
237
+ (entry) =>
238
+ entry.status === "active" &&
239
+ entry.name === identity?.name &&
240
+ entry.version === identity?.version,
241
+ ) ?? null
242
+ );
243
+ }
244
+
245
+ export function effectiveGuardSettings(config) {
246
+ const settings = { profile: config.profile, ...PROFILES[config.profile] };
247
+ const warnings =
248
+ settings.enforcement === "audit"
249
+ ? [
250
+ "The audit profile is nonenforcing: nothing is blocked, and it applies only when the invoking command explicitly opts in.",
251
+ ]
252
+ : [];
253
+ return { settings, warnings };
254
+ }
255
+
256
+ export async function guardConfigLocation(projectDir) {
257
+ if (
258
+ typeof projectDir !== "string" ||
259
+ !projectDir ||
260
+ projectDir.includes("\0")
261
+ )
262
+ fail("invalid-project", "Project directory must be a non-empty path");
263
+ let root;
264
+ try {
265
+ root = await realpath(path.resolve(projectDir));
266
+ } catch {
267
+ fail("invalid-project", "Project directory does not exist");
268
+ }
269
+ const stat = await lstat(root).catch(() => null);
270
+ if (!stat?.isDirectory())
271
+ fail("invalid-project", "Project path must be a directory");
272
+ return { root, file: path.join(root, GUARD_CONFIG_FILE) };
273
+ }
274
+
275
+ const unreadable = () =>
276
+ fail("unreadable-config", "Guard configuration file cannot be read");
277
+
278
+ async function readBounded(handle) {
279
+ const buffer = Buffer.alloc(MAX_CONFIG_BYTES + 1);
280
+ let total = 0;
281
+ while (total < buffer.length) {
282
+ const { bytesRead } = await handle.read(
283
+ buffer,
284
+ total,
285
+ buffer.length - total,
286
+ null,
287
+ );
288
+ if (!bytesRead) break;
289
+ total += bytesRead;
290
+ }
291
+ if (total > MAX_CONFIG_BYTES)
292
+ fail("invalid-config", "Guard configuration file is too large");
293
+ try {
294
+ return new TextDecoder("utf-8", { fatal: true }).decode(
295
+ buffer.subarray(0, total),
296
+ );
297
+ } catch {
298
+ fail("invalid-config", "Guard configuration is not valid UTF-8");
299
+ }
300
+ }
301
+
302
+ export async function readGuardConfig(projectDir, { now = new Date() } = {}) {
303
+ const location = await guardConfigLocation(projectDir);
304
+ let before;
305
+ try {
306
+ before = await lstat(location.file);
307
+ } catch (error) {
308
+ if (error.code === "ENOENT")
309
+ return { ...location, source: "default", config: defaultGuardConfig() };
310
+ unreadable();
311
+ }
312
+ if (before.isSymbolicLink())
313
+ fail("path-escape", "Guard configuration file must not be a symlink");
314
+ if (!before.isFile())
315
+ fail("invalid-config", "Guard configuration must be a regular file");
316
+ let handle;
317
+ try {
318
+ handle = await open(
319
+ location.file,
320
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
321
+ );
322
+ } catch (error) {
323
+ if (error.code === "ELOOP")
324
+ fail("path-escape", "Guard configuration file must not be a symlink");
325
+ unreadable();
326
+ }
327
+ try {
328
+ const stat = await handle.stat();
329
+ if (!stat.isFile() || stat.ino !== before.ino || stat.dev !== before.dev)
330
+ fail("invalid-config", "Guard configuration must be a regular file");
331
+ if (stat.mode & 0o022)
332
+ fail(
333
+ "unsafe-permissions",
334
+ "Guard configuration file is writable by other users",
335
+ );
336
+ if (stat.size > MAX_CONFIG_BYTES)
337
+ fail("invalid-config", "Guard configuration file is too large");
338
+ let text;
339
+ try {
340
+ text = await readBounded(handle);
341
+ } catch (error) {
342
+ if (error instanceof GuardConfigError) throw error;
343
+ unreadable();
344
+ }
345
+ let parsed;
346
+ try {
347
+ parsed = JSON.parse(text);
348
+ } catch {
349
+ fail("invalid-config", "Guard configuration is not valid JSON");
350
+ }
351
+ return {
352
+ ...location,
353
+ source: "file",
354
+ config: validateGuardConfig(parsed, { now }),
355
+ };
356
+ } finally {
357
+ await handle.close();
358
+ }
359
+ }
360
+
361
+ async function syncDirectory(directory) {
362
+ let handle;
363
+ try {
364
+ handle = await open(directory, constants.O_RDONLY);
365
+ await handle.sync();
366
+ } catch (error) {
367
+ if (!["EINVAL", "EISDIR", "EPERM", "ENOTSUP"].includes(error.code))
368
+ throw error;
369
+ } finally {
370
+ await handle?.close();
371
+ }
372
+ }
373
+
374
+ export async function writeGuardConfig(
375
+ projectDir,
376
+ config,
377
+ { now = new Date() } = {},
378
+ ) {
379
+ const valid = validateGuardConfig(config, { now });
380
+ const location = await guardConfigLocation(projectDir);
381
+ const existing = await lstat(location.file).catch((error) => {
382
+ if (error.code === "ENOENT") return null;
383
+ throw error;
384
+ });
385
+ if (existing?.isSymbolicLink() || (existing && !existing.isFile()))
386
+ fail("path-escape", "Guard configuration file must be a regular file");
387
+ const temporary = path.join(
388
+ location.root,
389
+ `${GUARD_CONFIG_FILE}.${randomBytes(8).toString("hex")}.tmp`,
390
+ );
391
+ const handle = await open(temporary, "wx", 0o600);
392
+ try {
393
+ await handle.writeFile(JSON.stringify(valid, null, 2) + "\n");
394
+ await handle.sync();
395
+ await handle.close();
396
+ await rename(temporary, location.file);
397
+ await syncDirectory(location.root);
398
+ } catch (error) {
399
+ await handle.close().catch(() => {});
400
+ await unlink(temporary).catch(() => {});
401
+ throw error;
402
+ }
403
+ return { ...location, source: "file", config: valid };
404
+ }
405
+
406
+ export async function createGuardConfig(
407
+ projectDir,
408
+ config,
409
+ { now = new Date() } = {},
410
+ ) {
411
+ const valid = validateGuardConfig(config, { now });
412
+ const location = await guardConfigLocation(projectDir);
413
+ const temporary = path.join(
414
+ location.root,
415
+ `${GUARD_CONFIG_FILE}.${randomBytes(8).toString("hex")}.tmp`,
416
+ );
417
+ const handle = await open(temporary, "wx", 0o600);
418
+ try {
419
+ await handle.writeFile(JSON.stringify(valid, null, 2) + "\n");
420
+ await handle.sync();
421
+ await handle.close();
422
+ // link() refuses any existing entry, including dangling symlinks, so creation is exclusive.
423
+ await link(temporary, location.file);
424
+ await unlink(temporary);
425
+ await syncDirectory(location.root);
426
+ } catch (error) {
427
+ await handle.close().catch(() => {});
428
+ if (error.code === "EEXIST")
429
+ fail(
430
+ "config-exists",
431
+ "A .unmask.json file, link or other entry already exists; it was left unchanged",
432
+ );
433
+ throw error;
434
+ } finally {
435
+ await unlink(temporary).catch(() => {});
436
+ }
437
+ return { ...location, source: "file", config: valid };
438
+ }
439
+
440
+ export async function loadGuardSettings(projectDir, options = {}) {
441
+ const loaded = await readGuardConfig(projectDir, options);
442
+ return { ...loaded, ...effectiveGuardSettings(loaded.config) };
443
+ }
444
+
445
+ export async function inspectGuardConfig(projectDir, options = {}) {
446
+ try {
447
+ const loaded = await loadGuardSettings(projectDir, options);
448
+ return {
449
+ status: loaded.source === "file" ? "ok" : "default",
450
+ file: loaded.file,
451
+ settings: loaded.settings,
452
+ warnings: loaded.warnings,
453
+ };
454
+ } catch (error) {
455
+ return error instanceof GuardConfigError
456
+ ? { status: "invalid", code: error.code, message: error.message }
457
+ : {
458
+ status: "invalid",
459
+ code: "unreadable-config",
460
+ message: "Guard configuration could not be inspected",
461
+ };
462
+ }
463
+ }