asylumwarden 0.0.0 → 0.1.0

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,531 @@
1
+ // Project config + registry loading and machine policy construction.
2
+ // Public semantics: docs/CONTRACT.md §2; pinned signatures:
3
+ // docs/DESIGN-INTERNALS.md §src/config/load.mjs.
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+
8
+ import { fail, violation } from "../kernel/errors.mjs";
9
+ import { sha256File } from "../kernel/canon.mjs";
10
+ import { CAP_CONFIG, expandHome, fileExists, readJsonCapped } from "../kernel/fsutil.mjs";
11
+ import { matchGlob } from "../kernel/glob.mjs";
12
+ import { validateSchema } from "../kernel/schemalite.mjs";
13
+ import { SAFE_ID_RE } from "../kernel/state.mjs";
14
+ import { loadSchema, restoreArrayProtocols } from "./schemas.mjs";
15
+
16
+ const CONFIG_FILENAME = "asylumwarden.json";
17
+
18
+ // Prefix-safe containment via path.relative: '/a/bc' is NOT inside '/a/b'
19
+ // (a raw string startsWith would say it is).
20
+ export function containsPath(outer, inner) {
21
+ if (outer === inner) return true;
22
+ const rel = path.relative(outer, inner);
23
+ return rel !== "" && rel !== ".." && !rel.startsWith("../") && !path.isAbsolute(rel);
24
+ }
25
+
26
+ function normalizeAbs(raw, code, field) {
27
+ if (typeof raw !== "string") {
28
+ fail(code, `${field}: path must be a string`, [violation(field, "path-type", String(raw))]);
29
+ }
30
+ const expanded = expandHome(raw);
31
+ if (!expanded.startsWith("/")) {
32
+ fail(code, `${field}: path must be absolute or ~-anchored: ${raw}`, [violation(field, "path-absolute", raw)]);
33
+ }
34
+ if (expanded.split("/").includes("..")) {
35
+ fail(code, `${field}: ".." segments are rejected: ${raw}`, [violation(field, "path-normalized", raw)]);
36
+ }
37
+ return path.resolve(expanded);
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Duplicate JSON key detection. JSON.parse silently last-wins, so a hidden
42
+ // duplicate key (CONTRACT §2.2 REGISTRY-INVALID) is invisible to the parsed
43
+ // value. The input to this scanner is always already-parsed-valid JSON, so
44
+ // only structural tracking is required.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ // Host-path sanitize policy: escape sequences stay on their own line so no
48
+ // source line carries two backslashes (secretlint Windows-path rule hygiene).
49
+ const WS_CHARS = new Set([
50
+ " ",
51
+ "\t",
52
+ "\n",
53
+ "\r",
54
+ ]);
55
+
56
+ export function findDuplicateKey(text) {
57
+ const stack = [];
58
+ let i = 0;
59
+ let state = "value";
60
+ const n = text.length;
61
+ const ws = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
62
+
63
+ const skipString = () => {
64
+ i += 1; // opening quote
65
+ while (i < n) {
66
+ if (text[i] === "\\") {
67
+ i += 2;
68
+ continue;
69
+ }
70
+ if (text[i] === '"') {
71
+ i += 1;
72
+ return;
73
+ }
74
+ i += 1;
75
+ }
76
+ };
77
+ const readKey = () => {
78
+ i += 1; // opening quote
79
+ let out = "";
80
+ while (i < n) {
81
+ const c = text[i];
82
+ if (c === "\\") {
83
+ out += text[i + 1] ?? "";
84
+ i += 2;
85
+ continue;
86
+ }
87
+ if (c === '"') {
88
+ i += 1;
89
+ return out;
90
+ }
91
+ out += c;
92
+ i += 1;
93
+ }
94
+ return out;
95
+ };
96
+
97
+ while (i < n && state !== "done") {
98
+ const c = text[i];
99
+ if (ws(c)) {
100
+ i += 1;
101
+ continue;
102
+ }
103
+ const frame = stack[stack.length - 1];
104
+ if (state === "value") {
105
+ if (c === "{") {
106
+ i += 1;
107
+ stack.push({ obj: true, keys: new Set() });
108
+ state = "key";
109
+ continue;
110
+ }
111
+ if (c === "[") {
112
+ i += 1;
113
+ stack.push({ obj: false, keys: null });
114
+ state = "value";
115
+ continue;
116
+ }
117
+ if (c === "]") {
118
+ i += 1;
119
+ stack.pop();
120
+ state = stack.length > 0 ? "after" : "done";
121
+ continue;
122
+ }
123
+ if (c === '"') {
124
+ skipString();
125
+ state = stack.length > 0 ? "after" : "done";
126
+ continue;
127
+ }
128
+ // number / true / false / null: run to a structural delimiter
129
+ while (i < n && !ws(text[i]) && text[i] !== "," && text[i] !== "}" && text[i] !== "]") {
130
+ i += 1;
131
+ }
132
+ state = stack.length > 0 ? "after" : "done";
133
+ continue;
134
+ }
135
+ if (state === "key") {
136
+ if (c === "}") {
137
+ i += 1;
138
+ stack.pop();
139
+ state = stack.length > 0 ? "after" : "done";
140
+ continue;
141
+ }
142
+ const key = readKey();
143
+ if (frame.keys.has(key)) return key;
144
+ frame.keys.add(key);
145
+ state = "colon";
146
+ continue;
147
+ }
148
+ if (state === "colon") {
149
+ i += 1; // ':' — input is valid JSON
150
+ state = "value";
151
+ continue;
152
+ }
153
+ // state === "after"
154
+ if (c === ",") {
155
+ i += 1;
156
+ state = frame.obj ? "key" : "value";
157
+ continue;
158
+ }
159
+ i += 1; // '}' or ']'
160
+ stack.pop();
161
+ state = stack.length > 0 ? "after" : "done";
162
+ }
163
+ return null;
164
+ }
165
+
166
+ export async function assertNoDuplicateKeys(filePath, code) {
167
+ const text = await fs.readFile(filePath, "utf8");
168
+ const key = findDuplicateKey(text);
169
+ if (key !== null) {
170
+ fail(code, `duplicate JSON key "${key}" in ${filePath}`, [violation(filePath, "duplicate-key", key)]);
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Project config (CONTRACT §2.1)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ async function discoverConfig(origin) {
179
+ let dir = path.resolve(origin);
180
+ for (;;) {
181
+ const candidate = path.join(dir, CONFIG_FILENAME);
182
+ if (await fileExists(candidate)) return candidate;
183
+ const parent = path.dirname(dir);
184
+ if (parent === dir) {
185
+ fail("CONFIG-MISSING", `no ${CONFIG_FILENAME} found walking up from ${path.resolve(origin)}`, [
186
+ violation("configPath", "discovery", origin),
187
+ ]);
188
+ }
189
+ dir = parent;
190
+ }
191
+ }
192
+
193
+ export async function loadProjectConfig({ configPath, cwd } = {}) {
194
+ const origin = cwd ?? process.cwd();
195
+ let resolvedPath;
196
+ if (configPath === undefined || configPath === null) {
197
+ resolvedPath = await discoverConfig(origin);
198
+ } else {
199
+ if (typeof configPath !== "string") fail("CONFIG-INVALID", "--config must be a path string");
200
+ const expanded = expandHome(configPath);
201
+ if (expanded.split("/").includes("..")) {
202
+ fail("CONFIG-INVALID", `config path has ".." segments: ${configPath}`);
203
+ }
204
+ resolvedPath = path.resolve(origin, expanded);
205
+ }
206
+
207
+ let raw;
208
+ try {
209
+ ({ value: raw } = await readJsonCapped(resolvedPath, CAP_CONFIG, "SCHEMA-VIOLATION"));
210
+ } catch (error) {
211
+ if (error?.code === "ENOENT") {
212
+ fail("CONFIG-MISSING", `config file not found: ${resolvedPath}`, [
213
+ violation("configPath", "missing", resolvedPath),
214
+ ]);
215
+ }
216
+ if (error?.name === "WardenError") throw error;
217
+ fail("CONFIG-INVALID", `cannot read config ${resolvedPath}: ${error?.message}`);
218
+ }
219
+ restoreArrayProtocols(raw);
220
+ const schema = await loadSchema("config");
221
+ const violations = validateSchema(schema, raw, "config");
222
+ if (violations.length > 0) {
223
+ fail("SCHEMA-VIOLATION", `config schema violations in ${resolvedPath}`, violations);
224
+ }
225
+
226
+ // Values the schema cannot express: path normalization of load-time paths
227
+ // (~ is tolerated only in config / registry values, CONTRACT §1).
228
+ const registryPath = normalizeAbs(raw.registry, "CONFIG-INVALID", "registry");
229
+ const stateHome = normalizeAbs(raw.state_home, "CONFIG-INVALID", "state_home");
230
+
231
+ const config = raw;
232
+ if (!Object.hasOwn(config, "kinds")) config.kinds = {};
233
+ if (!Object.hasOwn(config, "anchor")) config.anchor = null;
234
+ const configSha256 = await sha256File(resolvedPath);
235
+
236
+ return { config, configPath: resolvedPath, configSha256, stateHome, registryPath };
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Registry (CONTRACT §2.2)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ function normalizeRegistryLinePaths(decl, line) {
244
+ for (const field of ["roots", "read_roots", "forbidden_absolute"]) {
245
+ for (const p of decl[field] ?? []) {
246
+ if (typeof p !== "string" || !p.startsWith("/") || p.split("/").includes("..")) {
247
+ fail("REGISTRY-INVALID", `line ${line}: bad ${field} path: ${String(p)}`, [
248
+ violation(`lines.${line}.${field}`, "registry-path", String(p)),
249
+ ]);
250
+ }
251
+ }
252
+ }
253
+ // A relative forbidden_surfaces pattern must stay anchored at the line's
254
+ // root #1; traversal toward another line's root fails closed.
255
+ for (const p of decl.forbidden_surfaces) {
256
+ if (p.split("/").includes("..")) {
257
+ fail("GLOB-INVALID", `line ${line}: relative forbidden pattern may not traverse: ${p}`, [
258
+ violation(`lines.${line}.forbidden_surfaces`, "glob-relative", p),
259
+ ]);
260
+ }
261
+ }
262
+ }
263
+
264
+ export async function loadRegistry(ctxLike) {
265
+ const config = ctxLike?.config;
266
+ if (config === undefined || config === null) {
267
+ fail("INTERNAL", "loadRegistry: ctxLike.config is required");
268
+ }
269
+ const registryPath =
270
+ ctxLike.registryPath ?? normalizeAbs(config.registry, "REGISTRY-INVALID", "registry");
271
+
272
+ let raw;
273
+ try {
274
+ ({ value: raw } = await readJsonCapped(registryPath, CAP_CONFIG, "SCHEMA-VIOLATION"));
275
+ } catch (error) {
276
+ if (error?.code === "ENOENT") {
277
+ fail("REGISTRY-MISSING", `registry file not found: ${registryPath}`, [
278
+ violation("registry", "missing", registryPath),
279
+ ]);
280
+ }
281
+ if (error?.name === "WardenError") throw error;
282
+ fail("REGISTRY-INVALID", `cannot read registry ${registryPath}: ${error?.message}`);
283
+ }
284
+ restoreArrayProtocols(raw);
285
+ await assertNoDuplicateKeys(registryPath, "REGISTRY-INVALID");
286
+
287
+ const registrySha256 = await sha256File(registryPath);
288
+ if (registrySha256 !== config.registry_sha256) {
289
+ fail(
290
+ "CONFIG-DRIFT",
291
+ `registry hash drift: ${registryPath} hashes to ${registrySha256}, config declares ${config.registry_sha256}`,
292
+ [violation("registry_sha256", "drift", registryPath)],
293
+ );
294
+ }
295
+
296
+ const schema = await loadSchema("registry");
297
+ const violations = validateSchema(schema, raw, "registry");
298
+ if (violations.length > 0) {
299
+ fail("REGISTRY-INVALID", `registry schema violations in ${registryPath}`, violations);
300
+ }
301
+ for (const line of Object.getOwnPropertyNames(raw.lines)) {
302
+ if (!SAFE_ID_RE.test(line)) {
303
+ fail("REGISTRY-INVALID", `bad line name: ${line}`, [violation(`lines.${line}`, "safe-id", line)]);
304
+ }
305
+ normalizeRegistryLinePaths(raw.lines[line], line);
306
+ }
307
+ await validateRegistrySemantics(raw, config);
308
+
309
+ return { registry: raw, registryPath, registrySha256 };
310
+ }
311
+
312
+ export async function validateRegistrySemantics(registry, config) {
313
+ const all = []; // {line, root} — every normalized root across the registry
314
+ for (const line of Object.getOwnPropertyNames(registry.lines)) {
315
+ const roots = registry.lines[line].roots.map((r) => path.resolve(expandHome(r)));
316
+ for (const root of roots) {
317
+ let stat = null;
318
+ try {
319
+ stat = await fs.stat(root);
320
+ } catch {
321
+ stat = null;
322
+ }
323
+ if (stat === null || !stat.isDirectory()) {
324
+ fail("ROOT-MISSING", `line ${line}: root missing or not a directory: ${root}`, [
325
+ violation(`lines.${line}.roots`, "root-missing", root),
326
+ ]);
327
+ }
328
+ all.push({ line, root });
329
+ }
330
+ }
331
+
332
+ // Equal-or-contains across every pair — within one line and across lines.
333
+ for (let i = 0; i < all.length; i += 1) {
334
+ for (let j = i + 1; j < all.length; j += 1) {
335
+ if (containsPath(all[i].root, all[j].root) || containsPath(all[j].root, all[i].root)) {
336
+ fail(
337
+ "ROOTS-OVERLAP",
338
+ `roots overlap: ${all[i].line}:${all[i].root} vs ${all[j].line}:${all[j].root}`,
339
+ [violation("lines", "roots-overlap", `${all[i].root} / ${all[j].root}`)],
340
+ );
341
+ }
342
+ }
343
+ }
344
+
345
+ if (config?.mode !== "shadow") return;
346
+
347
+ const shadowHome = normalizeAbs(config.shadow_home, "CONFIG-INVALID", "shadow_home");
348
+ const stateHome = normalizeAbs(config.state_home, "CONFIG-INVALID", "state_home");
349
+ if (stateHome !== shadowHome) {
350
+ fail("CONFIG-INVALID", `shadow mode: state_home (${stateHome}) must equal shadow_home (${shadowHome})`, [
351
+ violation("state_home", "shadow-home-match", shadowHome),
352
+ ]);
353
+ }
354
+ const registryPath = normalizeAbs(config.registry, "CONFIG-INVALID", "registry");
355
+ const expected = path.join(shadowHome, "shadow-registry.json");
356
+ if (registryPath !== expected) {
357
+ fail("CONFIG-INVALID", `shadow mode: registry must be ${expected}, got ${registryPath}`, [
358
+ violation("registry", "shadow-registry-path", registryPath),
359
+ ]);
360
+ }
361
+
362
+ // Shadow containment (CONTRACT §2.1 / §2.2 / §10). Protected surfaces are
363
+ // config values (`protect`, possibly empty), never code constants; normal
364
+ // mode forbids the key itself via the config schema (additionalProperties).
365
+ if (!Array.isArray(config.protect)) {
366
+ fail("CONFIG-INVALID", "shadow mode: config.protect is required (array of absolute paths, possibly empty)", [
367
+ violation("protect", "shadow-protect-required", typeof config.protect),
368
+ ]);
369
+ }
370
+ const protectedRoots = config.protect.map((p, i) => normalizeAbs(p, "CONFIG-INVALID", `protect[${i}]`));
371
+ for (const { line, root } of all) {
372
+ for (const prot of protectedRoots) {
373
+ if (containsPath(root, prot)) {
374
+ fail("FORBIDDEN-ROOT", `shadow mode: line ${line} root ${root} equals or contains protected root ${prot}`, [
375
+ violation(`lines.${line}.roots`, "forbidden-root", root),
376
+ ]);
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Machine policy (DESIGN-INTERNALS §src/config: buildPolicy)
384
+ // ---------------------------------------------------------------------------
385
+
386
+ export function buildPolicy({ config, registry }) {
387
+ if (config === undefined || config === null) fail("INTERNAL", "buildPolicy: config is required");
388
+ if (registry === undefined || registry === null) fail("INTERNAL", "buildPolicy: registry is required");
389
+ const shadowMode = config.mode === "shadow";
390
+ const lineCache = new Map();
391
+
392
+ function line(name) {
393
+ if (!Object.hasOwn(registry.lines, name)) {
394
+ fail("REGISTRY-INVALID", `unknown line: ${String(name)}`, [
395
+ violation("line", "unknown-line", String(name)),
396
+ ]);
397
+ }
398
+ let built = lineCache.get(name);
399
+ if (built === undefined) {
400
+ const decl = registry.lines[name];
401
+ built = {
402
+ name,
403
+ roots: decl.roots.map((r) => path.resolve(expandHome(r))),
404
+ forbiddenRel: [...(decl.forbidden_surfaces ?? [])],
405
+ forbiddenAbs: [...(decl.forbidden_absolute ?? [])],
406
+ readRoots: [...(decl.read_roots ?? [])].map((r) => path.resolve(expandHome(r))),
407
+ };
408
+ lineCache.set(name, built);
409
+ }
410
+ return built;
411
+ }
412
+
413
+ function forbiddenHit(absPath, lineName) {
414
+ const l = line(lineName);
415
+ const target = path.resolve(absPath);
416
+ for (const pattern of l.forbiddenRel) {
417
+ if (matchGlob(path.resolve(l.roots[0], pattern), target)) return pattern;
418
+ }
419
+ for (const pattern of l.forbiddenAbs) {
420
+ if (matchGlob(pattern, target)) return pattern;
421
+ }
422
+ return null;
423
+ }
424
+
425
+ // forbidden_absolute protects surfaces "regardless of which line touches
426
+ // them" (CONTRACT §2.2), so foreign lines' absolute patterns gate too.
427
+ function globalForbiddenHit(absPath, exceptLine) {
428
+ const target = path.resolve(absPath);
429
+ for (const name of Object.getOwnPropertyNames(registry.lines)) {
430
+ if (name === exceptLine) continue;
431
+ for (const pattern of registry.lines[name].forbidden_absolute ?? []) {
432
+ if (matchGlob(pattern, target)) return pattern;
433
+ }
434
+ }
435
+ return null;
436
+ }
437
+
438
+ function writableRootOf(lineName, absRoot) {
439
+ const l = line(lineName);
440
+ const root = path.resolve(absRoot);
441
+ if (!l.roots.some((outer) => containsPath(outer, root))) {
442
+ fail("PATH-ESCAPE", `writable root ${root} is outside line ${lineName} roots`, [
443
+ violation("allowed_roots", "path-escape", root),
444
+ ]);
445
+ }
446
+ const hit = forbiddenHit(root, lineName) ?? globalForbiddenHit(root, lineName);
447
+ if (hit !== null) {
448
+ fail("FORBIDDEN-ROOT", `writable root ${root} matches forbidden pattern ${hit}`, [
449
+ violation("allowed_roots", "forbidden-root", hit),
450
+ ]);
451
+ }
452
+ return root;
453
+ }
454
+
455
+ // Containment must follow symlinks (escape via a symlinked directory must
456
+ // fail). For a not-yet-existing tail, resolve the deepest existing ancestor.
457
+ // A path component that exists to lstat() but cannot be realpath()'d (dangling
458
+ // or looping symlink) is unprovable and therefore an escape.
459
+ async function realpathDeepest(absPath) {
460
+ const target = path.resolve(absPath);
461
+ let current = target;
462
+ for (;;) {
463
+ try {
464
+ const real = await fs.realpath(current);
465
+ // mapped: the deepest-existing-ancestor resolution re-anchored on the
466
+ // original tail, so callers still see the full logical path.
467
+ const rest = current === target ? "" : path.relative(current, target);
468
+ return { real, mapped: rest === "" ? real : path.join(real, rest) };
469
+ } catch (error) {
470
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
471
+ let dangling = null;
472
+ try {
473
+ dangling = await fs.lstat(current);
474
+ } catch {
475
+ dangling = null;
476
+ }
477
+ if (dangling !== null) {
478
+ fail("PATH-ESCAPE", `unresolvable symlink in path: ${current}`, [
479
+ violation("path", "symlink-escape", current),
480
+ ]);
481
+ }
482
+ const parent = path.dirname(current);
483
+ if (parent === current) {
484
+ fail("PATH-ESCAPE", `cannot resolve any existing ancestor of ${target}`, [
485
+ violation("path", "path-escape", target),
486
+ ]);
487
+ }
488
+ current = parent;
489
+ continue;
490
+ }
491
+ fail("PATH-ESCAPE", `cannot resolve ${current}: ${error?.message ?? "fs error"}`, [
492
+ violation("path", "path-escape", current),
493
+ ]);
494
+ }
495
+ }
496
+ }
497
+
498
+ async function artifactOk(absPath, cell) {
499
+ const { real, mapped } = await realpathDeepest(absPath);
500
+ const hit = forbiddenHit(real, cell.line) ?? globalForbiddenHit(real, cell.line);
501
+ if (hit !== null) {
502
+ fail("FORBIDDEN-SURFACE", `artifact ${real} matches forbidden pattern ${hit}`, [
503
+ violation("artifact", "forbidden-surface", hit),
504
+ ]);
505
+ }
506
+ const roots = cell.allowed_roots ?? [];
507
+ if (!roots.some((root) => containsPath(path.resolve(root), real))) {
508
+ fail("PATH-ESCAPE", `artifact ${real} is not inside any allowed_root of cell ${String(cell.id)}`, [
509
+ violation("artifact", "path-escape", real),
510
+ ]);
511
+ }
512
+ return mapped;
513
+ }
514
+
515
+ async function readOk(absPath, cell, lineName) {
516
+ const { real } = await realpathDeepest(absPath);
517
+ const l = line(lineName);
518
+ const zones = [...(cell.allowed_roots ?? []), ...(cell.read_roots ?? []), ...l.readRoots];
519
+ return zones.some((zone) => containsPath(path.resolve(zone), real));
520
+ }
521
+
522
+ return {
523
+ mode: config.mode ?? null,
524
+ shadowHome: shadowMode ? path.resolve(expandHome(config.shadow_home)) : null,
525
+ line,
526
+ forbiddenHit,
527
+ writableRootOf,
528
+ artifactOk,
529
+ readOk,
530
+ };
531
+ }
@@ -0,0 +1,58 @@
1
+ // Schema loading for the five contract schemas — docs/DESIGN-INTERNALS.md
2
+ // §src/config/schemas.mjs. Path resolved relative to src root via
3
+ // import.meta.url; results cached; cap + parse failures are INTERNAL.
4
+
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { fail } from "../kernel/errors.mjs";
9
+ import { CAP_CONFIG, readJsonCapped } from "../kernel/fsutil.mjs";
10
+
11
+ // Kernel's deepNullProto nulls the prototype of arrays too, which strips
12
+ // Symbol.iterator and array methods — even schemalite's own `for (const ... of
13
+ // allOf)` then throws. Parsed artifacts must regain Array.prototype on arrays
14
+ // (objects stay null-prototype) before consumption. Prototype-pollution defense
15
+ // is unaffected: it lives in fsutil's guardKeys, which rejects the dangerous
16
+ // keys at parse time.
17
+ export function restoreArrayProtocols(node) {
18
+ if (Array.isArray(node)) {
19
+ Object.setPrototypeOf(node, Array.prototype);
20
+ for (let i = 0; i < node.length; i += 1) restoreArrayProtocols(node[i]);
21
+ return node;
22
+ }
23
+ if (node !== null && typeof node === "object") {
24
+ for (const key of Object.getOwnPropertyNames(node)) restoreArrayProtocols(node[key]);
25
+ }
26
+ return node;
27
+ }
28
+
29
+ const SCHEMAS_DIR = path.resolve(
30
+ path.dirname(fileURLToPath(import.meta.url)),
31
+ "..",
32
+ "..",
33
+ "schemas",
34
+ );
35
+
36
+ const KNOWN = new Set(["config", "registry", "manifest", "receipt", "ledger"]);
37
+ const cache = new Map();
38
+
39
+ export async function loadSchema(name) {
40
+ if (!KNOWN.has(name)) {
41
+ fail("INTERNAL", `loadSchema: unknown schema "${String(name)}"`);
42
+ }
43
+ const cached = cache.get(name);
44
+ if (cached !== undefined) return cached;
45
+ const filePath = path.join(SCHEMAS_DIR, `${name}.schema.json`);
46
+ let parsed;
47
+ try {
48
+ ({ value: parsed } = await readJsonCapped(filePath, CAP_CONFIG, "INTERNAL"));
49
+ } catch (error) {
50
+ if (error?.code === "ENOENT") {
51
+ fail("INTERNAL", `loadSchema: schema file missing: ${filePath}`);
52
+ }
53
+ throw error;
54
+ }
55
+ restoreArrayProtocols(parsed);
56
+ cache.set(name, parsed);
57
+ return parsed;
58
+ }
@@ -0,0 +1,50 @@
1
+ // Context assembly — the only place that wires modules together.
2
+ // Layering: context may import every core module; modules never import this.
3
+
4
+ import path from "node:path";
5
+
6
+ import { fail } from "./kernel/errors.mjs";
7
+ import { expandHome } from "./kernel/fsutil.mjs";
8
+ import { layout } from "./kernel/state.mjs";
9
+ import { loadProjectConfig, loadRegistry, validateRegistrySemantics, buildPolicy } from "./config/load.mjs";
10
+ import { openLedger } from "./ledger/chain.mjs";
11
+ import { maybeAnchor } from "./ledger/anchor.mjs";
12
+
13
+ export async function makeContext({ configPath, cwd = process.cwd(), stateHome } = {}) {
14
+ const loaded = await loadProjectConfig({ configPath, cwd });
15
+ const registry = await loadRegistry(loaded);
16
+ await validateRegistrySemantics(registry.registry, loaded.config);
17
+ const policy = buildPolicy({ config: loaded.config, registry: registry.registry });
18
+ const sl = layout(stateHome ?? loaded.stateHome);
19
+ const ledger = openLedger({ sl, config: loaded.config });
20
+
21
+ const ctx = {
22
+ config: loaded.config,
23
+ configPath: loaded.configPath,
24
+ configSha256: loaded.configSha256,
25
+ registry: registry.registry,
26
+ registryPath: registry.registryPath,
27
+ registrySha256: registry.registrySha256,
28
+ policy,
29
+ sl,
30
+ mode: loaded.config.mode,
31
+ stateHome: sl.stateHome,
32
+ ledger,
33
+ // Append + opportunistic anchor in one call; every state mutation uses it.
34
+ async appendEvent(type, fields) {
35
+ const entry = await ledger.append(type, fields);
36
+ let degraded = null;
37
+ try {
38
+ degraded = (await maybeAnchor(ctx)).degraded ?? null;
39
+ } catch {
40
+ degraded = "ANCHOR-DEGRADED"; // anchor bookkeeping must never mask the real event
41
+ }
42
+ return { entry, degraded };
43
+ },
44
+ };
45
+ return ctx;
46
+ }
47
+
48
+ export function expandPaths(list) {
49
+ return list.map((p) => path.normalize(expandHome(p)));
50
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,12 @@
1
+ // Core public entry for Node consumers. The CLI (bin/asylumwarden.mjs) is the
2
+ // language-neutral entry; this module is the zero-dependency library surface.
3
+ // Adapters import this. Host SDKs must never appear behind it.
4
+
5
+ export { makeContext } from "./context.mjs";
6
+ export { planWave } from "./manifest/plan.mjs";
7
+ export { validateManifest } from "./manifest/manifest.mjs";
8
+ export { claim, renew, release, requeue } from "./lease/lease.mjs";
9
+ export { doneWithReceipt, failCell } from "./receipt/receipt.mjs";
10
+ export { readyCells, statusReport, verifyAll } from "./views/views.mjs";
11
+ export { runCli } from "./cli/main.mjs";
12
+ export { WardenError } from "./kernel/errors.mjs";