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,395 @@
|
|
|
1
|
+
// External anchoring — CONTRACT §2.3 + §5.4, DESIGN-INTERNALS §src/ledger.
|
|
2
|
+
// All push failures are RETURNED as {ok:false,error}, never thrown. The only
|
|
3
|
+
// throw is initAnchor's ANCHOR-FAIL (a named fail-closed outcome per §5.4).
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
|
|
8
|
+
import { fail } from "../kernel/errors.mjs";
|
|
9
|
+
import { nowIso } from "../kernel/fsutil.mjs";
|
|
10
|
+
import { readStateJson, writeStateJson } from "../kernel/state.mjs";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ANCHOR_TIMEOUT_MS = 10_000;
|
|
13
|
+
const DEFAULT_ANCHOR_INTERVAL_MS = 600_000; // §5.4: 600 s
|
|
14
|
+
const DEFAULT_ANCHOR_SEQ_DELTA = 50; // §5.4: 50 entries
|
|
15
|
+
const EXTERNAL_TYPES = new Set(["command", "remote", "human-checkpoint"]);
|
|
16
|
+
|
|
17
|
+
// audit_closure by configured anchor type (CONTRACT §9). "file" is a local
|
|
18
|
+
// checkpoint — mechanically allowed, never external (§2.3). Staleness is a
|
|
19
|
+
// separate field; the CLI maps stale && external to ANCHOR-STALE.
|
|
20
|
+
export function closureForAnchor(anchor) {
|
|
21
|
+
if (anchor === null || anchor === undefined) return "none";
|
|
22
|
+
if (anchor.type === "file") return "local";
|
|
23
|
+
if (EXTERNAL_TYPES.has(anchor.type)) return "external";
|
|
24
|
+
return "none";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function summarize(target) {
|
|
28
|
+
const s = String(target);
|
|
29
|
+
return s.length > 120 ? `${s.slice(0, 117)}...` : s;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function anchorLine(headEntry) {
|
|
33
|
+
return JSON.stringify({
|
|
34
|
+
head_seq: headEntry.seq,
|
|
35
|
+
head_hash: headEntry.hash,
|
|
36
|
+
chain_head_ts: headEntry.ts,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// target: shell-quoted string, or a JSON array of argv strings. Tokenization
|
|
41
|
+
// is quote-aware (single/double); no expansions — spawn runs with shell:false.
|
|
42
|
+
function splitCommand(target) {
|
|
43
|
+
const raw = String(target);
|
|
44
|
+
if (raw.startsWith("[")) {
|
|
45
|
+
let argv;
|
|
46
|
+
try {
|
|
47
|
+
argv = JSON.parse(raw);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error("command target looks like a JSON array but does not parse");
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
!Array.isArray(argv) ||
|
|
53
|
+
argv.length === 0 ||
|
|
54
|
+
!argv.every((a) => typeof a === "string" && a.length > 0)
|
|
55
|
+
) {
|
|
56
|
+
throw new Error("command target JSON array must be a non-empty array of non-empty strings");
|
|
57
|
+
}
|
|
58
|
+
return argv;
|
|
59
|
+
}
|
|
60
|
+
const argv = [];
|
|
61
|
+
let cur = "";
|
|
62
|
+
let quote = null;
|
|
63
|
+
let started = false;
|
|
64
|
+
for (const ch of raw) {
|
|
65
|
+
if (quote !== null) {
|
|
66
|
+
if (ch === quote) quote = null;
|
|
67
|
+
else cur += ch;
|
|
68
|
+
} else if (ch === '"' || ch === "'") {
|
|
69
|
+
quote = ch;
|
|
70
|
+
started = true;
|
|
71
|
+
} else if (/\s/.test(ch)) {
|
|
72
|
+
if (started) {
|
|
73
|
+
argv.push(cur);
|
|
74
|
+
cur = "";
|
|
75
|
+
started = false;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
cur += ch;
|
|
79
|
+
started = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (quote !== null) throw new Error("command target has an unterminated quote");
|
|
83
|
+
if (started) argv.push(cur);
|
|
84
|
+
if (argv.length === 0) throw new Error("command target is empty");
|
|
85
|
+
return argv;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pushCommand(target, line, timeoutMs) {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
let argv;
|
|
91
|
+
try {
|
|
92
|
+
argv = splitCommand(target);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
resolve({ ok: false, error: `invalid command target: ${err.message}` });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let child;
|
|
98
|
+
try {
|
|
99
|
+
child = spawn(argv[0], argv.slice(1), {
|
|
100
|
+
shell: false,
|
|
101
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
102
|
+
});
|
|
103
|
+
} catch (err) {
|
|
104
|
+
resolve({ ok: false, error: `spawn failed: ${err.message}` });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let settled = false;
|
|
108
|
+
const done = (result) => {
|
|
109
|
+
if (settled) return;
|
|
110
|
+
settled = true;
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
resolve(result);
|
|
113
|
+
};
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
try {
|
|
116
|
+
child.kill("SIGKILL");
|
|
117
|
+
} catch {
|
|
118
|
+
/* already gone */
|
|
119
|
+
}
|
|
120
|
+
done({ ok: false, error: "timeout" });
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
child.stdin.on("error", () => {
|
|
123
|
+
/* EPIPE when the target exits before reading stdin */
|
|
124
|
+
});
|
|
125
|
+
child.stdin.write(`${line}\n`, "utf8");
|
|
126
|
+
child.stdin.end();
|
|
127
|
+
child.on("error", (err) => done({ ok: false, error: `spawn failed: ${err.message}` }));
|
|
128
|
+
child.on("close", (code) => {
|
|
129
|
+
if (code === 0) done({ ok: true });
|
|
130
|
+
else done({ ok: false, error: `exit ${String(code)}` });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function pushRemote(target, line, timeoutMs) {
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(target, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "content-type": "application/json" },
|
|
140
|
+
body: line,
|
|
141
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
142
|
+
});
|
|
143
|
+
if (!res.ok) return { ok: false, error: `http ${String(res.status)}` };
|
|
144
|
+
return { ok: true };
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return { ok: false, error: `fetch failed: ${err.message}` };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function pushFile(target, line) {
|
|
151
|
+
let fh;
|
|
152
|
+
try {
|
|
153
|
+
fh = await fs.open(target, "a", 0o600);
|
|
154
|
+
await fh.writeFile(`${line}\n`, "utf8");
|
|
155
|
+
await fh.sync();
|
|
156
|
+
return { ok: true };
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return { ok: false, error: `file anchor write failed: ${err.message}` };
|
|
159
|
+
} finally {
|
|
160
|
+
await fh?.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function pushHumanCheckpoint(target, headHash) {
|
|
165
|
+
let text;
|
|
166
|
+
try {
|
|
167
|
+
text = await fs.readFile(target, "utf8");
|
|
168
|
+
} catch (err) {
|
|
169
|
+
return { ok: false, error: `checkpoint file unreadable: ${err.message}` };
|
|
170
|
+
}
|
|
171
|
+
const signed = text.split("\n").some((l) => l.includes(headHash));
|
|
172
|
+
if (!signed) {
|
|
173
|
+
return { ok: false, error: "checkpoint record does not contain the chain head hash" };
|
|
174
|
+
}
|
|
175
|
+
return { ok: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Push the current head to the configured anchor. Failures are returned,
|
|
180
|
+
* never thrown. ctx.config.anchor drives type selection.
|
|
181
|
+
*/
|
|
182
|
+
export async function pushAnchor(ctx, headEntry) {
|
|
183
|
+
try {
|
|
184
|
+
const anchor = ctx.config?.anchor ?? null;
|
|
185
|
+
if (anchor === null || anchor === undefined) {
|
|
186
|
+
return { ok: false, error: "anchor not configured" };
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
headEntry === null ||
|
|
190
|
+
headEntry === undefined ||
|
|
191
|
+
typeof headEntry.hash !== "string" ||
|
|
192
|
+
!Number.isInteger(headEntry.seq)
|
|
193
|
+
) {
|
|
194
|
+
return { ok: false, error: "no head entry to anchor" };
|
|
195
|
+
}
|
|
196
|
+
const timeoutMs = ctx.anchorTimeoutMs ?? DEFAULT_ANCHOR_TIMEOUT_MS;
|
|
197
|
+
const line = anchorLine(headEntry);
|
|
198
|
+
switch (anchor.type) {
|
|
199
|
+
case "command":
|
|
200
|
+
return await pushCommand(anchor.target, line, timeoutMs);
|
|
201
|
+
case "remote":
|
|
202
|
+
return await pushRemote(anchor.target, line, timeoutMs);
|
|
203
|
+
case "file":
|
|
204
|
+
return await pushFile(anchor.target, line);
|
|
205
|
+
case "human-checkpoint":
|
|
206
|
+
return await pushHumanCheckpoint(anchor.target, headEntry.hash);
|
|
207
|
+
default:
|
|
208
|
+
return { ok: false, error: `unknown anchor type: ${String(anchor.type)}` };
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
return { ok: false, error: `anchor push error: ${err?.message ?? String(err)}` };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function readAnchorState(sl) {
|
|
216
|
+
try {
|
|
217
|
+
const value = await readStateJson(sl.anchorState);
|
|
218
|
+
if (value === null || typeof value !== "object") return null;
|
|
219
|
+
return { ...value }; // plain proto for mutation + rewrite
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (err?.code === "ENOENT") return null;
|
|
222
|
+
throw err; // STATE-CORRUPT etc. stays fail-closed
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function staleOf(headEntry, state) {
|
|
227
|
+
if (headEntry === null) return false;
|
|
228
|
+
const lastSeq = state?.last_anchor_seq ?? null;
|
|
229
|
+
if (lastSeq === null || lastSeq === undefined) return true; // entries exist, nothing anchored
|
|
230
|
+
return headEntry.seq > lastSeq;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function headEntryOf(ctx) {
|
|
234
|
+
const { entries } = await ctx.ledger.readAll();
|
|
235
|
+
return entries.length > 0 ? entries[entries.length - 1] : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* init-time anchor (after the genesis entry exists). anchor null → record a
|
|
240
|
+
* fresh anchor-state with audit_closure "none". Otherwise push the genesis
|
|
241
|
+
* head; a failed push fails init with ANCHOR-FAIL and leaves state
|
|
242
|
+
* unmodified (§5.4): if the ledger holds only the genesis entry just written
|
|
243
|
+
* and no anchor-state exists yet, the genesis file is removed — the
|
|
244
|
+
* rollback choice documented in DESIGN-INTERNALS.
|
|
245
|
+
*/
|
|
246
|
+
export async function initAnchor(ctx) {
|
|
247
|
+
const sl = ctx.sl;
|
|
248
|
+
const anchor = ctx.config?.anchor ?? null;
|
|
249
|
+
if (anchor === null || anchor === undefined) {
|
|
250
|
+
await writeStateJson(sl.anchorState, {
|
|
251
|
+
last_anchor_ts: null,
|
|
252
|
+
last_anchor_seq: null,
|
|
253
|
+
last_anchor_hash: null,
|
|
254
|
+
failures: 0,
|
|
255
|
+
audit_closure: "none",
|
|
256
|
+
});
|
|
257
|
+
return { ok: true, closure: "none" };
|
|
258
|
+
}
|
|
259
|
+
const headEntry = await headEntryOf(ctx);
|
|
260
|
+
if (headEntry === null) {
|
|
261
|
+
fail("INTERNAL", "initAnchor: no ledger entries (genesis not written yet)");
|
|
262
|
+
}
|
|
263
|
+
const result = await pushAnchor(ctx, headEntry);
|
|
264
|
+
if (!result.ok) {
|
|
265
|
+
await rollbackGenesisIfSole(ctx, headEntry);
|
|
266
|
+
fail("ANCHOR-FAIL", `anchor push failed at init: ${result.error}`);
|
|
267
|
+
}
|
|
268
|
+
const closure = closureForAnchor(anchor);
|
|
269
|
+
const entry = await ctx.ledger.append("anchor", {
|
|
270
|
+
payload: {
|
|
271
|
+
anchor_type: anchor.type,
|
|
272
|
+
target_summary: summarize(anchor.target),
|
|
273
|
+
head_seq: headEntry.seq,
|
|
274
|
+
head_hash: headEntry.hash,
|
|
275
|
+
ok: true,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
await writeStateJson(sl.anchorState, {
|
|
279
|
+
last_anchor_ts: entry.ts,
|
|
280
|
+
last_anchor_seq: entry.seq,
|
|
281
|
+
last_anchor_hash: headEntry.hash,
|
|
282
|
+
failures: 0,
|
|
283
|
+
audit_closure: closure,
|
|
284
|
+
});
|
|
285
|
+
return { ok: true, closure, entry };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function rollbackGenesisIfSole(ctx, headEntry) {
|
|
289
|
+
try {
|
|
290
|
+
if (headEntry.seq !== 1 || headEntry.type !== "genesis") return;
|
|
291
|
+
if (await fileExistsAsync(ctx.sl.anchorState)) return;
|
|
292
|
+
const { entries, torn } = await ctx.ledger.readAll();
|
|
293
|
+
if (entries.length === 1 && entries[0].hash === headEntry.hash && torn === null) {
|
|
294
|
+
await fs.rm(ctx.sl.ledger, { force: true });
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// Rollback is best-effort; the ANCHOR-FAIL throw still stands.
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function fileExistsAsync(filePath) {
|
|
302
|
+
try {
|
|
303
|
+
await fs.stat(filePath);
|
|
304
|
+
return true;
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Per-invocation anchor trigger check (§5.4, no timers). Pushes when due:
|
|
312
|
+
* never anchored, or now - last_anchor_ts > interval, or head seq delta
|
|
313
|
+
* since the last anchor >= threshold. On success appends the `anchor` entry
|
|
314
|
+
* (which lands AFTER the anchored window — documented limitation) and
|
|
315
|
+
* updates anchor-state; on failure increments failures and reports
|
|
316
|
+
* degraded: "ANCHOR-DEGRADED".
|
|
317
|
+
*/
|
|
318
|
+
export async function maybeAnchor(ctx) {
|
|
319
|
+
const sl = ctx.sl;
|
|
320
|
+
const anchor = ctx.config?.anchor ?? null;
|
|
321
|
+
const closure = closureForAnchor(anchor);
|
|
322
|
+
if (anchor === null || anchor === undefined) {
|
|
323
|
+
return { degraded: null, closure };
|
|
324
|
+
}
|
|
325
|
+
const state = await readAnchorState(sl);
|
|
326
|
+
const headEntry = await headEntryOf(ctx);
|
|
327
|
+
if (headEntry === null) {
|
|
328
|
+
return { degraded: null, closure, stale: false };
|
|
329
|
+
}
|
|
330
|
+
const nowFn = ctx.nowFn ?? nowIso;
|
|
331
|
+
const intervalMs = ctx.anchorIntervalMs ?? DEFAULT_ANCHOR_INTERVAL_MS;
|
|
332
|
+
const seqDelta = ctx.anchorSeqDelta ?? DEFAULT_ANCHOR_SEQ_DELTA;
|
|
333
|
+
const lastTs = state?.last_anchor_ts ?? null;
|
|
334
|
+
const nowMs = Date.parse(nowFn());
|
|
335
|
+
const lastMs = lastTs === null ? null : Date.parse(lastTs);
|
|
336
|
+
const due =
|
|
337
|
+
lastMs === null ||
|
|
338
|
+
!Number.isFinite(lastMs) ||
|
|
339
|
+
nowMs - lastMs > intervalMs ||
|
|
340
|
+
headEntry.seq - (state?.last_anchor_seq ?? 0) >= seqDelta;
|
|
341
|
+
if (!due) {
|
|
342
|
+
return { degraded: null, closure, stale: staleOf(headEntry, state) };
|
|
343
|
+
}
|
|
344
|
+
const result = await pushAnchor(ctx, headEntry);
|
|
345
|
+
if (result.ok) {
|
|
346
|
+
const entry = await ctx.ledger.append("anchor", {
|
|
347
|
+
payload: {
|
|
348
|
+
anchor_type: anchor.type,
|
|
349
|
+
target_summary: summarize(anchor.target),
|
|
350
|
+
head_seq: headEntry.seq,
|
|
351
|
+
head_hash: headEntry.hash,
|
|
352
|
+
ok: true,
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
await writeStateJson(sl.anchorState, {
|
|
356
|
+
last_anchor_ts: entry.ts,
|
|
357
|
+
last_anchor_seq: entry.seq,
|
|
358
|
+
last_anchor_hash: headEntry.hash,
|
|
359
|
+
failures: state?.failures ?? 0,
|
|
360
|
+
audit_closure: closure,
|
|
361
|
+
});
|
|
362
|
+
return { degraded: null, closure, stale: false };
|
|
363
|
+
}
|
|
364
|
+
await writeStateJson(sl.anchorState, {
|
|
365
|
+
last_anchor_ts: lastTs,
|
|
366
|
+
last_anchor_seq: state?.last_anchor_seq ?? null,
|
|
367
|
+
last_anchor_hash: state?.last_anchor_hash ?? null,
|
|
368
|
+
failures: (state?.failures ?? 0) + 1,
|
|
369
|
+
audit_closure: closure,
|
|
370
|
+
});
|
|
371
|
+
return { degraded: "ANCHOR-DEGRADED", closure, error: result.error, stale: staleOf(headEntry, state) };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Anchor verdict fields for `verify` (CONTRACT §9): closure by configured
|
|
376
|
+
* type, last anchored position, staleness. CLI maps stale && external →
|
|
377
|
+
* ANCHOR-STALE (exit 2) and none/absent-state to release-gate failure — not
|
|
378
|
+
* this layer's job.
|
|
379
|
+
*/
|
|
380
|
+
export async function anchorSummary(ctx) {
|
|
381
|
+
const anchor = ctx.config?.anchor ?? null;
|
|
382
|
+
const closure = closureForAnchor(anchor);
|
|
383
|
+
if (anchor === null || anchor === undefined) {
|
|
384
|
+
const headEntry = await headEntryOf(ctx);
|
|
385
|
+
return { closure: "none", last_ts: null, last_seq: null, stale: headEntry !== null };
|
|
386
|
+
}
|
|
387
|
+
const state = await readAnchorState(ctx.sl);
|
|
388
|
+
const headEntry = await headEntryOf(ctx);
|
|
389
|
+
return {
|
|
390
|
+
closure,
|
|
391
|
+
last_ts: state?.last_anchor_ts ?? null,
|
|
392
|
+
last_seq: state?.last_anchor_seq ?? null,
|
|
393
|
+
stale: staleOf(headEntry, state),
|
|
394
|
+
};
|
|
395
|
+
}
|