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.
- package/README.md +10 -5
- package/adapters/opencode/index.js +282 -0
- package/bin/asylumwarden.mjs +5 -0
- package/package.json +14 -1
- package/schemas/config.schema.json +93 -0
- package/schemas/ledger.schema.json +21 -0
- package/schemas/manifest.schema.json +49 -0
- package/schemas/receipt.schema.json +102 -0
- package/schemas/registry.schema.json +43 -0
- package/src/cli/main.mjs +342 -0
- package/src/config/bootstrap.mjs +36 -0
- package/src/config/load.mjs +531 -0
- package/src/config/schemas.mjs +58 -0
- package/src/context.mjs +50 -0
- package/src/index.mjs +12 -0
- package/src/kernel/canon.mjs +59 -0
- package/src/kernel/errors.mjs +80 -0
- package/src/kernel/fsutil.mjs +147 -0
- package/src/kernel/glob.mjs +70 -0
- package/src/kernel/lock.mjs +104 -0
- package/src/kernel/schemalite.mjs +153 -0
- package/src/kernel/state.mjs +56 -0
- package/src/lease/lease.mjs +551 -0
- package/src/ledger/anchor.mjs +395 -0
- package/src/ledger/chain.mjs +468 -0
- package/src/manifest/manifest.mjs +199 -0
- package/src/manifest/plan.mjs +142 -0
- package/src/receipt/receipt.mjs +555 -0
- package/src/views/views.mjs +148 -0
|
@@ -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
|
+
}
|
package/src/context.mjs
ADDED
|
@@ -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";
|