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,142 @@
|
|
|
1
|
+
// Wave planning — docs/CONTRACT.md §3/§4; pinned signature:
|
|
2
|
+
// docs/DESIGN-INTERNALS.md §src/manifest/plan.mjs.
|
|
3
|
+
// The ledger arrives as ctx.ledger ({append, verify, readAll}) from the
|
|
4
|
+
// integration layer; this module never imports the ledger module itself.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
|
|
8
|
+
import { fail, violation } from "../kernel/errors.mjs";
|
|
9
|
+
import { sha256Of } from "../kernel/canon.mjs";
|
|
10
|
+
import { atomicWriteJson, CAP_CONFIG, fileExists, nowIso, readJsonCapped } from "../kernel/fsutil.mjs";
|
|
11
|
+
import { HEX64_RE, readStateJson } from "../kernel/state.mjs";
|
|
12
|
+
import { assertNoDuplicateKeys } from "../config/load.mjs";
|
|
13
|
+
import { restoreArrayProtocols } from "../config/schemas.mjs";
|
|
14
|
+
import { validateManifest } from "./manifest.mjs";
|
|
15
|
+
|
|
16
|
+
// Integrity gate before any state read that could be poisoned by a broken
|
|
17
|
+
// chain: without a verifiable ledger, nothing may be planned.
|
|
18
|
+
async function assertLedgerHealthy(ctx) {
|
|
19
|
+
let verdict = null;
|
|
20
|
+
try {
|
|
21
|
+
verdict = (await ctx?.ledger?.verify?.()) ?? null;
|
|
22
|
+
} catch {
|
|
23
|
+
verdict = null;
|
|
24
|
+
}
|
|
25
|
+
if (verdict === null || verdict.chain_ok !== true) {
|
|
26
|
+
fail("LEDGER-BROKEN", "ledger missing or chain not verifiable; plan refused", [
|
|
27
|
+
violation("ledger", "chain", "unverifiable"),
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A --force re-plan may overwrite only inert cells; an in-flight (claimed) or
|
|
33
|
+
// finished (done) cell's state must never be silently erased.
|
|
34
|
+
async function assertOverwritable(sl, wave) {
|
|
35
|
+
let names;
|
|
36
|
+
try {
|
|
37
|
+
names = await fs.readdir(sl.cellsDir(wave));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.code === "ENOENT") return;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
for (const name of names) {
|
|
43
|
+
if (!name.endsWith(".json")) continue;
|
|
44
|
+
const cellId = name.slice(0, -".json".length);
|
|
45
|
+
const existing = await readStateJson(sl.cellFile(wave, cellId));
|
|
46
|
+
if (existing?.state === "claimed" || existing?.state === "done") {
|
|
47
|
+
fail("CELL-STATE", `--force refused: cell ${cellId} of wave ${wave} is ${existing.state}`, [
|
|
48
|
+
violation(`cells.${cellId}`, "cell-state", existing.state),
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function planWave(ctx, { manifestPath, force = false }) {
|
|
55
|
+
await assertLedgerHealthy(ctx);
|
|
56
|
+
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
({ value: raw } = await readJsonCapped(manifestPath, CAP_CONFIG, "SCHEMA-VIOLATION"));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error?.code === "ENOENT") {
|
|
62
|
+
fail("MANIFEST-INVALID", `manifest file not found: ${String(manifestPath)}`, [
|
|
63
|
+
violation("manifestPath", "missing", String(manifestPath)),
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
if (error?.name === "WardenError") throw error;
|
|
67
|
+
fail("MANIFEST-INVALID", `cannot read manifest ${String(manifestPath)}: ${error?.message}`);
|
|
68
|
+
}
|
|
69
|
+
restoreArrayProtocols(raw);
|
|
70
|
+
await assertNoDuplicateKeys(manifestPath, "MANIFEST-INVALID");
|
|
71
|
+
|
|
72
|
+
const { manifest, warnings } = await validateManifest(raw, {
|
|
73
|
+
config: ctx.config,
|
|
74
|
+
registry: ctx.registry,
|
|
75
|
+
policy: ctx.policy,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const sl = ctx.sl;
|
|
79
|
+
const wave = manifest.wave;
|
|
80
|
+
if (await fileExists(sl.waveDir(wave))) {
|
|
81
|
+
if (!force) {
|
|
82
|
+
fail("WAVE-EXISTS", `wave ${wave} is already planned in ${sl.waveDir(wave)}`, [
|
|
83
|
+
violation("wave", "wave-exists", wave),
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
await assertOverwritable(sl, wave);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const value of [ctx.configSha256, ctx.registrySha256]) {
|
|
90
|
+
if (typeof value !== "string" || !HEX64_RE.test(value)) {
|
|
91
|
+
fail("INTERNAL", "planWave: ctx.configSha256 / ctx.registrySha256 must be 64-hex digests");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const plannedAt = nowIso();
|
|
96
|
+
const cells = {};
|
|
97
|
+
for (const cell of manifest.cells) {
|
|
98
|
+
const state = {
|
|
99
|
+
id: cell.id,
|
|
100
|
+
wave,
|
|
101
|
+
kind: cell.kind,
|
|
102
|
+
line: cell.line,
|
|
103
|
+
cwd: cell.cwd,
|
|
104
|
+
allowed_roots: cell.allowed_roots,
|
|
105
|
+
read_roots: cell.read_roots,
|
|
106
|
+
deps: cell.deps,
|
|
107
|
+
role: cell.role,
|
|
108
|
+
role_file: cell.role_file,
|
|
109
|
+
session_started_at: cell.session_started_at,
|
|
110
|
+
brief: cell.brief,
|
|
111
|
+
acceptance: cell.acceptance,
|
|
112
|
+
state: "pending",
|
|
113
|
+
epoch: 0,
|
|
114
|
+
planned_at: plannedAt,
|
|
115
|
+
updated_at: plannedAt,
|
|
116
|
+
};
|
|
117
|
+
await atomicWriteJson(sl.cellFile(wave, cell.id), state);
|
|
118
|
+
cells[cell.id] = sha256Of(state);
|
|
119
|
+
}
|
|
120
|
+
await atomicWriteJson(sl.metaFile(wave), {
|
|
121
|
+
wave,
|
|
122
|
+
config_sha256: ctx.configSha256,
|
|
123
|
+
registry_sha256: ctx.registrySha256,
|
|
124
|
+
created_at: plannedAt,
|
|
125
|
+
cells,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// The plan event is appended only after every write landed, so a failed
|
|
129
|
+
// plan never leaves a phantom wave on the ledger.
|
|
130
|
+
await ctx.ledger.append("plan", {
|
|
131
|
+
wave,
|
|
132
|
+
cell: null,
|
|
133
|
+
actor: null,
|
|
134
|
+
payload: { cells: Object.keys(cells) },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
wave,
|
|
139
|
+
cells: manifest.cells.map((c) => ({ id: c.id, kind: c.kind, line: c.line, cwd: c.cwd, state: "pending" })),
|
|
140
|
+
warnings,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
// Receipt validation + done/fail placement — CONTRACT §7, DESIGN-INTERNALS
|
|
2
|
+
// §src/receipt.
|
|
3
|
+
//
|
|
4
|
+
// Module law (pinned):
|
|
5
|
+
// - Validation order is CONTRACT §7 EXACT: lease(actor→epoch) → receipt read
|
|
6
|
+
// (1 MiB cap, SCHEMA-VIOLATION) → schemalite → cell-id → commands →
|
|
7
|
+
// artifacts → reads → shadow extras → COMMIT. Any validation failure
|
|
8
|
+
// throws WardenError with ZERO state mutation.
|
|
9
|
+
// - The stored receipt FILE preserves the caller's exact bytes (evidence
|
|
10
|
+
// artifact): writeRawAtomic below is the kernel atomic-write pattern
|
|
11
|
+
// (tmp+fsync+rename+read-back) applied to raw bytes.
|
|
12
|
+
// - No lateral imports: this module re-implements its own lease loader
|
|
13
|
+
// rather than importing src/lease (layering rule).
|
|
14
|
+
// - Modules append only SUCCESS events; CLI appends rejected attempts.
|
|
15
|
+
// - ctx.nowFn() is the only clock source (tests inject).
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { fail, violation } from "../kernel/errors.mjs";
|
|
21
|
+
import { sha256File, sha256Hex } from "../kernel/canon.mjs";
|
|
22
|
+
import { CAP_CONFIG, fileExists, nowIso, readJsonCapped } from "../kernel/fsutil.mjs";
|
|
23
|
+
import { readCell, writeCell, HEX64_RE, SAFE_ID_RE } from "../kernel/state.mjs";
|
|
24
|
+
import { withExclusiveLock } from "../kernel/lock.mjs";
|
|
25
|
+
import { validateSchema } from "../kernel/schemalite.mjs";
|
|
26
|
+
|
|
27
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
|
28
|
+
const LEASE_KEYS = new Set([
|
|
29
|
+
"actor",
|
|
30
|
+
"pid",
|
|
31
|
+
"host",
|
|
32
|
+
"epoch",
|
|
33
|
+
"acquired_at",
|
|
34
|
+
"heartbeat_at",
|
|
35
|
+
"expires_at",
|
|
36
|
+
"max_hold_until",
|
|
37
|
+
"ttl_seconds",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------- primitives
|
|
41
|
+
|
|
42
|
+
function nowIsoOf(ctx) {
|
|
43
|
+
return (ctx?.nowFn ?? nowIso)();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function msOf(iso, what) {
|
|
47
|
+
const ms = Date.parse(iso);
|
|
48
|
+
if (!Number.isFinite(ms)) fail("STATE-CORRUPT", `unparseable timestamp in ${what}: ${iso}`);
|
|
49
|
+
return ms;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isIsoString(value) {
|
|
53
|
+
return typeof value === "string" && ISO_RE.test(value) && Number.isFinite(Date.parse(value));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateLeaseShape(owner, filePath) {
|
|
57
|
+
if (owner === null || typeof owner !== "object" || Array.isArray(owner)) {
|
|
58
|
+
fail("STATE-CORRUPT", `lease is not an object: ${filePath}`);
|
|
59
|
+
}
|
|
60
|
+
for (const key of Object.getOwnPropertyNames(owner)) {
|
|
61
|
+
if (!LEASE_KEYS.has(key)) {
|
|
62
|
+
fail("STATE-CORRUPT", `lease has unknown key "${key}": ${filePath}`, [
|
|
63
|
+
violation(filePath, "lease-unknown-key", key),
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const key of LEASE_KEYS) {
|
|
68
|
+
if (!Object.hasOwn(owner, key)) {
|
|
69
|
+
fail("STATE-CORRUPT", `lease missing key "${key}": ${filePath}`, [
|
|
70
|
+
violation(filePath, "lease-missing-key", key),
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (typeof owner.actor !== "string" || owner.actor.length === 0) fail("STATE-CORRUPT", `lease actor invalid: ${filePath}`);
|
|
75
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0) fail("STATE-CORRUPT", `lease pid invalid: ${filePath}`);
|
|
76
|
+
if (typeof owner.host !== "string" || owner.host.length === 0) fail("STATE-CORRUPT", `lease host invalid: ${filePath}`);
|
|
77
|
+
if (!Number.isInteger(owner.epoch) || owner.epoch < 1) fail("STATE-CORRUPT", `lease epoch invalid: ${filePath}`);
|
|
78
|
+
if (!Number.isInteger(owner.ttl_seconds) || owner.ttl_seconds < 30 || owner.ttl_seconds > 86400) {
|
|
79
|
+
fail("STATE-CORRUPT", `lease ttl_seconds invalid: ${filePath}`);
|
|
80
|
+
}
|
|
81
|
+
for (const key of ["acquired_at", "heartbeat_at", "expires_at", "max_hold_until"]) {
|
|
82
|
+
if (!isIsoString(owner[key])) fail("STATE-CORRUPT", `lease ${key} is not RFC3339: ${filePath}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validateSafeId(name, value) {
|
|
87
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)) {
|
|
88
|
+
fail("USAGE", `${name} must be a SafeId, got ${String(value)}`, [violation(name, "safe-id", String(value))]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function loadCellOrFail(ctx, wave, cell) {
|
|
93
|
+
validateSafeId("wave", wave);
|
|
94
|
+
validateSafeId("cell", cell);
|
|
95
|
+
if (!(await fileExists(ctx.sl.waveDir(wave)))) {
|
|
96
|
+
fail("WAVE-MISSING", `wave not found: ${wave}`, [violation("wave", "wave-missing", wave)]);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return await readCell(ctx.sl, wave, cell);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error && error.code === "ENOENT") {
|
|
102
|
+
fail("CELL-MISSING", `cell not found: ${wave}/${cell}`, [violation("cell", "cell-missing", `${wave}/${cell}`)]);
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// §7 step 1 authority: the live lease. Lease absent is a business reject
|
|
109
|
+
// (CELL-STATE, exit 1) — the terminal path of the §6 protocol (release first)
|
|
110
|
+
// is a retryable caller mistake. The deeper broken invariant "cell claimed
|
|
111
|
+
// but no lease file" stays fail-closed as STATE-CORRUPT.
|
|
112
|
+
async function loadLeaseForDone(ctx, wave, cell, cellRow) {
|
|
113
|
+
const filePath = ctx.sl.ownerFile(wave, cell);
|
|
114
|
+
let owner;
|
|
115
|
+
try {
|
|
116
|
+
({ value: owner } = await readJsonCapped(filePath, undefined, "STATE-CORRUPT"));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error && error.code === "ENOENT") {
|
|
119
|
+
if (cellRow.state === "claimed") {
|
|
120
|
+
fail("STATE-CORRUPT", `cell ${wave}/${cell} is claimed but owner.json is missing`, [
|
|
121
|
+
violation("lease", "claimed-without-lease", ctx.sl.cellFile(wave, cell)),
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
fail("CELL-STATE", `no live lease for done on ${wave}/${cell}`, [violation("lease", "no-lease", "absent")]);
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
validateLeaseShape(owner, filePath);
|
|
129
|
+
return owner;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function checkLeaseIdentity(owner, actor, epoch) {
|
|
133
|
+
if (owner.actor !== actor) {
|
|
134
|
+
fail("ACTOR-MISMATCH", `lease held by ${owner.actor}, done attempted as ${actor}`, [
|
|
135
|
+
violation("actor", "actor-mismatch", owner.actor),
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
if (owner.epoch !== epoch) {
|
|
139
|
+
fail("EPOCH-MISMATCH", `lease epoch is ${owner.epoch}, done attempted with ${epoch}`, [
|
|
140
|
+
violation("epoch", "epoch-mismatch", String(owner.epoch)),
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// path.relative based containment (realpath'd inputs; no trailing-slash trap).
|
|
146
|
+
function isInside(root, abs) {
|
|
147
|
+
const rel = path.relative(root, abs);
|
|
148
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Receipt-provided paths must already be absolute and normalized (CONTRACT
|
|
152
|
+
// §1: the loader rejects non-normalized input; no ".." segments).
|
|
153
|
+
function assertNormalizedAbs(raw, code, what) {
|
|
154
|
+
if (typeof raw !== "string" || !raw.startsWith("/")) {
|
|
155
|
+
fail(code, `${what} must be an absolute path: ${String(raw)}`, [violation(what, "abs-path", String(raw))]);
|
|
156
|
+
}
|
|
157
|
+
if (path.posix.normalize(raw) !== raw) {
|
|
158
|
+
fail(code, `${what} is not a normalized absolute path: ${raw}`, [violation(what, "normalized", raw)]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Receipt schema is pinned in DESIGN-INTERNALS as loaded via import.meta.url;
|
|
163
|
+
// src/config/schemas.mjs is the integration loader, but lateral/upward
|
|
164
|
+
// imports are banned for this module, so it loads its own cached copy.
|
|
165
|
+
// Plain JSON.parse (NOT readJsonCapped): readJsonCapped null-prototypes every
|
|
166
|
+
// array, and kernel schemalite iterates SCHEMA keyword arrays (allOf/anyOf/
|
|
167
|
+
// required) with for...of, which requires Array.prototype. The VALUE side is
|
|
168
|
+
// iterated by index only, so the null-proto receipt value stays compatible.
|
|
169
|
+
// The schema file is this repo's own trusted artifact; size cap + parse still
|
|
170
|
+
// fail closed with INTERNAL.
|
|
171
|
+
let receiptSchemaCache = null;
|
|
172
|
+
async function loadReceiptSchema() {
|
|
173
|
+
if (receiptSchemaCache !== null) return receiptSchemaCache;
|
|
174
|
+
const schemaPath = path.resolve(import.meta.dirname, "..", "..", "schemas", "receipt.schema.json");
|
|
175
|
+
let raw;
|
|
176
|
+
try {
|
|
177
|
+
raw = await fs.readFile(schemaPath);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
fail("INTERNAL", `cannot read receipt schema ${schemaPath}: ${error.message}`);
|
|
180
|
+
}
|
|
181
|
+
if (raw.length > CAP_CONFIG) {
|
|
182
|
+
fail("INTERNAL", `receipt schema exceeds ${CAP_CONFIG} byte cap`);
|
|
183
|
+
}
|
|
184
|
+
let parsed;
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
187
|
+
} catch {
|
|
188
|
+
fail("INTERNAL", `receipt schema is unparseable JSON: ${schemaPath}`);
|
|
189
|
+
}
|
|
190
|
+
receiptSchemaCache = parsed;
|
|
191
|
+
return receiptSchemaCache;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Atomic raw-byte write mirroring kernel atomicWriteJson (tmp same-dir → full
|
|
195
|
+
// write → fsync → rename → read-back) but preserving the caller's exact
|
|
196
|
+
// bytes: the receipt file IS the evidence artifact, not a re-serialization.
|
|
197
|
+
async function writeRawAtomic(filePath, bytes) {
|
|
198
|
+
const dir = path.dirname(filePath);
|
|
199
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
200
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`);
|
|
201
|
+
let handle;
|
|
202
|
+
try {
|
|
203
|
+
handle = await fs.open(tmp, "w", 0o600);
|
|
204
|
+
await handle.writeFile(bytes);
|
|
205
|
+
await handle.sync();
|
|
206
|
+
} finally {
|
|
207
|
+
await handle?.close();
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await fs.rename(tmp, filePath);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
await fs.rm(tmp, { force: true });
|
|
213
|
+
fail("INTERNAL", `atomic rename failed for ${filePath}: ${error.message}`);
|
|
214
|
+
}
|
|
215
|
+
const back = await fs.readFile(filePath);
|
|
216
|
+
if (!back.equals(Buffer.from(bytes))) {
|
|
217
|
+
fail("INTERNAL", `atomic write read-back mismatch: ${filePath}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Validate artifact/claim hash etc. AFTER all containment checks pass.
|
|
222
|
+
async function validateArtifacts(ctx, receipt, cellRow) {
|
|
223
|
+
// Kernel null-prototype note: readJsonCapped arrays have no
|
|
224
|
+
// Symbol.iterator — iterate disk-read arrays by index only.
|
|
225
|
+
const artifacts = Array.isArray(receipt.artifacts) ? receipt.artifacts : [];
|
|
226
|
+
const reals = [];
|
|
227
|
+
for (let i = 0; i < artifacts.length; i += 1) {
|
|
228
|
+
const artifact = artifacts[i];
|
|
229
|
+
const artifactPath = artifact.path;
|
|
230
|
+
assertNormalizedAbs(artifactPath, "PATH-ESCAPE", "artifact.path");
|
|
231
|
+
let stat;
|
|
232
|
+
try {
|
|
233
|
+
stat = await fs.lstat(artifactPath);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error && error.code === "ENOENT") {
|
|
236
|
+
// Pinned choice: a nonexistent artifact is RECEIPT-HASH (exit 1,
|
|
237
|
+
// business) with detail "missing" — the claimed hash cannot exist.
|
|
238
|
+
// PATH-ESCAPE is reserved for containment failures.
|
|
239
|
+
fail("RECEIPT-HASH", `artifact missing: ${artifactPath}`, [
|
|
240
|
+
violation(artifactPath, "artifact-missing", "missing"),
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
// Unverifiable containment fails closed on the containment code.
|
|
244
|
+
fail("PATH-ESCAPE", `cannot lstat artifact ${artifactPath}: ${error.message}`, [
|
|
245
|
+
violation(artifactPath, "artifact-lstat", error.message),
|
|
246
|
+
]);
|
|
247
|
+
}
|
|
248
|
+
if (stat.isSymbolicLink()) {
|
|
249
|
+
fail("PATH-ESCAPE", `artifact is a symlink: ${artifactPath}`, [
|
|
250
|
+
violation(artifactPath, "artifact-symlink", "lstat"),
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
if (!stat.isFile()) {
|
|
254
|
+
fail("PATH-ESCAPE", `artifact is not a regular file: ${artifactPath}`, [
|
|
255
|
+
violation(artifactPath, "artifact-not-file", "lstat"),
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
let real;
|
|
259
|
+
try {
|
|
260
|
+
real = await fs.realpath(artifactPath);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
fail("PATH-ESCAPE", `cannot resolve artifact ${artifactPath}: ${error.message}`, [
|
|
263
|
+
violation(artifactPath, "artifact-realpath", error.message),
|
|
264
|
+
]);
|
|
265
|
+
}
|
|
266
|
+
// Catches a symlink swap between lstat and realpath (TOCTOU guard).
|
|
267
|
+
if (real !== artifactPath) {
|
|
268
|
+
fail("PATH-ESCAPE", `artifact realpath differs from claimed path: ${artifactPath} -> ${real}`, [
|
|
269
|
+
violation(artifactPath, "artifact-swap", real),
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
// policy.artifactOk throws FORBIDDEN-SURFACE (forbidden hit first, per
|
|
273
|
+
// pinned artifactOk semantics) or PATH-ESCAPE (outside allowed_roots).
|
|
274
|
+
// Real policy impls are async (realpath inside); await so the
|
|
275
|
+
// rejection propagates instead of floating as a rejected promise.
|
|
276
|
+
await ctx.policy.artifactOk(real, cellRow);
|
|
277
|
+
const actual = await sha256File(real);
|
|
278
|
+
if (actual !== artifact.sha256) {
|
|
279
|
+
fail("RECEIPT-HASH", `artifact hash mismatch: ${artifactPath}`, [
|
|
280
|
+
violation(artifactPath, "hash-mismatch", `claimed ${artifact.sha256}, actual ${actual}`),
|
|
281
|
+
]);
|
|
282
|
+
}
|
|
283
|
+
reals.push(real);
|
|
284
|
+
}
|
|
285
|
+
return reals;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function validateReads(ctx, receipt, cellRow) {
|
|
289
|
+
const reads = Array.isArray(receipt.reads) ? receipt.reads : [];
|
|
290
|
+
const lineName = typeof cellRow.line === "string" ? cellRow.line : "";
|
|
291
|
+
for (let i = 0; i < reads.length; i += 1) {
|
|
292
|
+
const readPath = reads[i];
|
|
293
|
+
assertNormalizedAbs(readPath, "READ-OUTSIDE", "reads[]");
|
|
294
|
+
let real;
|
|
295
|
+
try {
|
|
296
|
+
real = await fs.realpath(readPath);
|
|
297
|
+
} catch {
|
|
298
|
+
// §7 step 6: every reads[] path must exist; absent is out-of-bounds.
|
|
299
|
+
fail("READ-OUTSIDE", `read path missing or unresolvable: ${readPath}`, [
|
|
300
|
+
violation(readPath, "read-missing", "absent"),
|
|
301
|
+
]);
|
|
302
|
+
}
|
|
303
|
+
if (real !== readPath) {
|
|
304
|
+
fail("READ-OUTSIDE", `read realpath differs from claimed path: ${readPath} -> ${real}`, [
|
|
305
|
+
violation(readPath, "read-swap", real),
|
|
306
|
+
]);
|
|
307
|
+
}
|
|
308
|
+
if ((await ctx.policy.readOk(real, cellRow, lineName)) !== true) {
|
|
309
|
+
fail("READ-OUTSIDE", `read path outside allowed union: ${real}`, [
|
|
310
|
+
violation(readPath, "read-outside", real),
|
|
311
|
+
]);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// CONTRACT §10 shadow extras, run after the §7 core pass. Wave prefix and
|
|
317
|
+
// receipt shape are shape violations (SCHEMA-VIOLATION, exit 2); an artifact
|
|
318
|
+
// outside shadow_home is a containment failure (PATH-ESCAPE, exit 2).
|
|
319
|
+
function validateShadow(ctx, receipt, wave, artifactReals) {
|
|
320
|
+
if (ctx.mode !== "shadow") return;
|
|
321
|
+
if (receipt.mode !== "shadow" || !HEX64_RE.test(String(receipt.shadow_of ?? ""))) {
|
|
322
|
+
fail("SCHEMA-VIOLATION", `shadow receipt must carry mode="shadow" and a 64hex shadow_of`, [
|
|
323
|
+
violation("shadow_of", "shadow-shape", String(receipt.shadow_of)),
|
|
324
|
+
]);
|
|
325
|
+
}
|
|
326
|
+
if (!wave.startsWith("shadow-")) {
|
|
327
|
+
fail("SCHEMA-VIOLATION", `shadow wave id must start with "shadow-", got ${wave}`, [
|
|
328
|
+
violation("wave", "shadow-prefix", wave),
|
|
329
|
+
]);
|
|
330
|
+
}
|
|
331
|
+
const shadowHome = ctx.config?.shadow_home;
|
|
332
|
+
assertNormalizedAbs(shadowHome, "PATH-ESCAPE", "config.shadow_home");
|
|
333
|
+
for (const real of artifactReals) {
|
|
334
|
+
if (!isInside(shadowHome, real)) {
|
|
335
|
+
fail("PATH-ESCAPE", `shadow artifact outside shadow_home ${shadowHome}: ${real}`, [
|
|
336
|
+
violation(real, "shadow-home-containment", shadowHome),
|
|
337
|
+
]);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Normal-mode receipts must not carry shadow keys (CONTRACT §7: "No other
|
|
343
|
+
// keys, except in shadow mode..."). The schema's shadow branch is
|
|
344
|
+
// mode-agnostic, so the context check lives here.
|
|
345
|
+
function checkShadowKeyPurity(ctx, receipt) {
|
|
346
|
+
if (ctx.mode === "shadow") return;
|
|
347
|
+
if (Object.hasOwn(receipt, "mode") || Object.hasOwn(receipt, "shadow_of")) {
|
|
348
|
+
fail("SCHEMA-VIOLATION", "normal-mode receipt must not carry mode/shadow_of", [
|
|
349
|
+
violation("mode", "shadow-keys-in-normal-mode", "forbidden"),
|
|
350
|
+
]);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* doneWithReceipt(ctx, {wave, cell, actor, epoch, receiptPath}) → {file, sha256}
|
|
356
|
+
* CONTRACT §7 exact order; zero mutation on any rejection.
|
|
357
|
+
*/
|
|
358
|
+
async function doneLocked(ctx, { wave, cell, actor, epoch, receiptPath }) {
|
|
359
|
+
if (typeof actor !== "string" || actor.length === 0) fail("USAGE", "done requires a non-empty actor");
|
|
360
|
+
const cellRow = await loadCellOrFail(ctx, wave, cell);
|
|
361
|
+
|
|
362
|
+
// §7 step 1 — lease identity (actor before epoch, pinned).
|
|
363
|
+
const owner = await loadLeaseForDone(ctx, wave, cell, cellRow);
|
|
364
|
+
checkLeaseIdentity(owner, actor, epoch);
|
|
365
|
+
|
|
366
|
+
// §7 receipt read: 1 MiB cap, SCHEMA-VIOLATION family; ENOENT is mapped
|
|
367
|
+
// into the same code family (a caller mistake, exit 2 by the receipt code
|
|
368
|
+
// table since the file content cannot be repaired by waiting).
|
|
369
|
+
let parsed;
|
|
370
|
+
try {
|
|
371
|
+
({ value: parsed } = await readJsonCapped(receiptPath, CAP_CONFIG, "SCHEMA-VIOLATION"));
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error && error.code === "ENOENT") {
|
|
374
|
+
fail("SCHEMA-VIOLATION", `receipt file missing: ${String(receiptPath)}`, [
|
|
375
|
+
violation("receipt", "receipt-missing", String(receiptPath)),
|
|
376
|
+
]);
|
|
377
|
+
}
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// schemalite (unknown keys rejected — schema additionalProperties:false).
|
|
382
|
+
const schema = await loadReceiptSchema();
|
|
383
|
+
const violations = validateSchema(schema, parsed);
|
|
384
|
+
if (violations.length > 0) {
|
|
385
|
+
fail("SCHEMA-VIOLATION", `receipt violates receipt.schema.json: ${violations[0].field}: ${violations[0].rule}`, violations);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// §7 step 3 — cell id must match the target (contract pins SCHEMA-VIOLATION
|
|
389
|
+
// for this bullet).
|
|
390
|
+
if (parsed.cell !== cell) {
|
|
391
|
+
fail("SCHEMA-VIOLATION", `receipt cell ${String(parsed.cell)} does not match target ${cell}`, [
|
|
392
|
+
violation("cell", "cell-match", String(parsed.cell)),
|
|
393
|
+
]);
|
|
394
|
+
}
|
|
395
|
+
checkShadowKeyPurity(ctx, parsed);
|
|
396
|
+
|
|
397
|
+
// §7 step 2 — status done requires every command exit 0. (Order within the
|
|
398
|
+
// §7 numbering: commands before artifacts/reads, after shape/cell match.)
|
|
399
|
+
const commands = Array.isArray(parsed.commands) ? parsed.commands : [];
|
|
400
|
+
if (parsed.status === "done") {
|
|
401
|
+
for (let i = 0; i < commands.length; i += 1) {
|
|
402
|
+
const command = commands[i];
|
|
403
|
+
if (command.exit !== 0) {
|
|
404
|
+
fail("RECEIPT-COMMANDS", `receipt status done but command exited ${command.exit}: ${command.cmd}`, [
|
|
405
|
+
violation("commands", "command-exit", String(command.exit)),
|
|
406
|
+
]);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// §7 steps 4+5 — artifacts: containment (lstat/realpath/policy) first, then
|
|
412
|
+
// the hash recompute, for every artifact, before any read-path work.
|
|
413
|
+
const artifactReals = await validateArtifacts(ctx, parsed, cellRow);
|
|
414
|
+
|
|
415
|
+
// §7 step 6 — reads.
|
|
416
|
+
await validateReads(ctx, parsed, cellRow);
|
|
417
|
+
|
|
418
|
+
// §10 shadow extras.
|
|
419
|
+
validateShadow(ctx, parsed, wave, artifactReals);
|
|
420
|
+
|
|
421
|
+
// ---- COMMIT (all validation above passed; nothing mutated until here) ----
|
|
422
|
+
// Receipt FILE stores exactly the caller's bytes (evidence artifact). The
|
|
423
|
+
// stored sha256 and ledger receipt_sha256 are of these file bytes. Accepted
|
|
424
|
+
// time-of-validation window: §7 proves "content-at-time", the source file
|
|
425
|
+
// is caller-owned.
|
|
426
|
+
const rawBytes = await fs.readFile(receiptPath);
|
|
427
|
+
const stored = ctx.sl.receiptFile(wave, cell);
|
|
428
|
+
await writeRawAtomic(stored, rawBytes);
|
|
429
|
+
const receiptSha = sha256Hex(rawBytes);
|
|
430
|
+
|
|
431
|
+
const nowIsoStr = nowIsoOf(ctx);
|
|
432
|
+
const finalState = parsed.status === "done" ? "done" : "failed";
|
|
433
|
+
await writeCell(ctx.sl, wave, cell, { ...cellRow, state: finalState, updated_at: nowIsoStr });
|
|
434
|
+
// Finishing consumes the lease (CONTRACT §6/§7): a cell that is not
|
|
435
|
+
// claimed must not carry a lease, or requeue-after-fail would deadlock.
|
|
436
|
+
await fs.rm(ctx.sl.ownerFile(wave, cell), { force: true });
|
|
437
|
+
await ctx.ledger.append(finalState === "done" ? "done" : "fail", {
|
|
438
|
+
wave,
|
|
439
|
+
cell,
|
|
440
|
+
actor,
|
|
441
|
+
payload: { receipt_sha256: receiptSha, status: parsed.status },
|
|
442
|
+
});
|
|
443
|
+
return { file: stored, sha256: receiptSha };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* failCell(ctx, {wave, cell, actor, epoch, reason}) → {file, sha256}
|
|
448
|
+
* Synthesizes a failed receipt and lands the cell failed. Same lease
|
|
449
|
+
* identity law as done; input validated before any mutation.
|
|
450
|
+
*/
|
|
451
|
+
async function failCellLocked(ctx, { wave, cell, actor, epoch, reason }) {
|
|
452
|
+
if (typeof actor !== "string" || actor.length === 0) fail("USAGE", "fail requires a non-empty actor");
|
|
453
|
+
if (typeof reason !== "string" || reason.length === 0) {
|
|
454
|
+
fail("USAGE", "fail requires a non-empty --reason", [violation("reason", "usage", "empty")]);
|
|
455
|
+
}
|
|
456
|
+
if (reason.length > 4096) {
|
|
457
|
+
fail("USAGE", `reason exceeds 4096-char string cap (${reason.length})`, [
|
|
458
|
+
violation("reason", "string-cap", String(reason.length)),
|
|
459
|
+
]);
|
|
460
|
+
}
|
|
461
|
+
const cellRow = await loadCellOrFail(ctx, wave, cell);
|
|
462
|
+
const owner = await loadLeaseForDone(ctx, wave, cell, cellRow);
|
|
463
|
+
checkLeaseIdentity(owner, actor, epoch);
|
|
464
|
+
|
|
465
|
+
const receipt = {
|
|
466
|
+
version: 1,
|
|
467
|
+
cell,
|
|
468
|
+
actor,
|
|
469
|
+
epoch: owner.epoch,
|
|
470
|
+
status: "failed",
|
|
471
|
+
summary: reason,
|
|
472
|
+
commands: [],
|
|
473
|
+
artifacts: [],
|
|
474
|
+
reads: [],
|
|
475
|
+
};
|
|
476
|
+
if (ctx.mode === "shadow") {
|
|
477
|
+
receipt.mode = "shadow";
|
|
478
|
+
receipt.shadow_of = ctx.config?.shadow_of;
|
|
479
|
+
if (typeof receipt.shadow_of !== "string" || !HEX64_RE.test(receipt.shadow_of)) {
|
|
480
|
+
// Config shape problem (exit-2 family), caught before any mutation.
|
|
481
|
+
fail("SCHEMA-VIOLATION", "shadow config lacks a 64hex shadow_of", [
|
|
482
|
+
violation("shadow_of", "config-shadow", String(receipt.shadow_of)),
|
|
483
|
+
]);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const bytes = Buffer.from(`${JSON.stringify(receipt, null, 2)}\n`, "utf8");
|
|
488
|
+
const stored = ctx.sl.receiptFile(wave, cell);
|
|
489
|
+
await writeRawAtomic(stored, bytes);
|
|
490
|
+
const receiptSha = sha256Hex(bytes);
|
|
491
|
+
|
|
492
|
+
const nowIsoStr = nowIsoOf(ctx);
|
|
493
|
+
await writeCell(ctx.sl, wave, cell, { ...cellRow, state: "failed", updated_at: nowIsoStr });
|
|
494
|
+
// Finishing consumes the lease (see doneWithReceipt).
|
|
495
|
+
await fs.rm(ctx.sl.ownerFile(wave, cell), { force: true });
|
|
496
|
+
await ctx.ledger.append("fail", {
|
|
497
|
+
wave,
|
|
498
|
+
cell,
|
|
499
|
+
actor,
|
|
500
|
+
payload: { reason, receipt_sha256: receiptSha, status: "failed" },
|
|
501
|
+
});
|
|
502
|
+
return { file: stored, sha256: receiptSha };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Validation + commit run inside the per-cell lease lock (same critical
|
|
506
|
+
// section as claim/renew/release): lease identity checks, state flip, and
|
|
507
|
+
// owner removal must be atomic against a concurrent steal/reclaim, and a
|
|
508
|
+
// validation pass must not be based on a lease that vanished mid-check.
|
|
509
|
+
|
|
510
|
+
// Wave/cell existence is proven BEFORE locking: acquiring the lock runs
|
|
511
|
+
// ensureDir, and letting it mint directories for unknown waves would flip
|
|
512
|
+
// WAVE-MISSING into CELL-MISSING inside claimLocked's checks.
|
|
513
|
+
async function requireWaveCell(ctx, wave, cell) {
|
|
514
|
+
for (const [name, value] of [["wave", wave], ["cell", cell]]) {
|
|
515
|
+
if (typeof value !== "string" || !SAFE_ID_RE.test(value)) {
|
|
516
|
+
fail("USAGE", `${name} must be a safe id: ${String(value)}`, [
|
|
517
|
+
violation(name, "safe-id", String(value)),
|
|
518
|
+
]);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const fs = await import("node:fs/promises");
|
|
522
|
+
try {
|
|
523
|
+
await fs.stat(ctx.sl.metaFile(wave));
|
|
524
|
+
} catch {
|
|
525
|
+
// plan always writes meta.json; the cells-dir alternative keeps
|
|
526
|
+
// hand-seeded wave fixtures working. Absent both = no wave.
|
|
527
|
+
try {
|
|
528
|
+
await fs.stat(ctx.sl.cellsDir(wave));
|
|
529
|
+
} catch {
|
|
530
|
+
fail("WAVE-MISSING", `no such wave: ${wave}`, [violation("wave", "wave-missing", wave)]);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
await fs.stat(ctx.sl.cellFile(wave, cell));
|
|
535
|
+
} catch {
|
|
536
|
+
fail("CELL-MISSING", `cell not found: ${wave}/${cell}`, [violation("cell", "cell-missing", `${wave}/${cell}`)]);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function withCellLock(ctx, wave, cell, fn) {
|
|
541
|
+
await requireWaveCell(ctx, wave, cell);
|
|
542
|
+
return withExclusiveLock(
|
|
543
|
+
ctx.sl.leaseLock(wave, cell),
|
|
544
|
+
{ busyCode: "LEASE-BUSY", busyMessage: `lease mutation busy for ${wave}/${cell}` },
|
|
545
|
+
fn,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export async function doneWithReceipt(ctx, args) {
|
|
550
|
+
return withCellLock(ctx, args.wave, args.cell, () => doneLocked(ctx, args));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export async function failCell(ctx, args) {
|
|
554
|
+
return withCellLock(ctx, args.wave, args.cell, () => failCellLocked(ctx, args));
|
|
555
|
+
}
|