wicked-vault 0.2.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 +176 -0
- package/bin/wicked-vault.mjs +161 -0
- package/docs/CONTRACTS.md +421 -0
- package/docs/adr/0001-standalone-and-council-revisions.md +101 -0
- package/docs/adr/0002-independent-evaluation-and-criteria-binding.md +184 -0
- package/install.mjs +192 -0
- package/package.json +52 -0
- package/skills/wicked-vault/analyze-evidence/SKILL.md +119 -0
- package/skills/wicked-vault/cross-check-evidence/SKILL.md +141 -0
- package/skills/wicked-vault/init/SKILL.md +58 -0
- package/skills/wicked-vault/record-evidence/SKILL.md +129 -0
- package/skills/wicked-vault/verify-evidence/SKILL.md +76 -0
- package/src/bus.mjs +75 -0
- package/src/hash.mjs +40 -0
- package/src/id.mjs +9 -0
- package/src/vault.mjs +425 -0
- package/src/verifiers.mjs +84 -0
package/src/vault.mjs
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { sha256, envelopeHash, canonical } from './hash.mjs';
|
|
5
|
+
import { newId } from './id.mjs';
|
|
6
|
+
import { runVerifier } from './verifiers.mjs';
|
|
7
|
+
|
|
8
|
+
const DIR = '.wicked-vault';
|
|
9
|
+
const SCHEMA = 1;
|
|
10
|
+
|
|
11
|
+
export function findRoot(start, { create = false } = {}) {
|
|
12
|
+
let cur = start;
|
|
13
|
+
for (;;) {
|
|
14
|
+
if (existsSync(join(cur, DIR))) return cur;
|
|
15
|
+
const parent = dirname(cur);
|
|
16
|
+
if (parent === cur) break;
|
|
17
|
+
cur = parent;
|
|
18
|
+
}
|
|
19
|
+
if (create) { initVault(start); return start; }
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function initVault(root) {
|
|
24
|
+
const base = join(root, DIR);
|
|
25
|
+
mkdirSync(join(base, 'entries'), { recursive: true });
|
|
26
|
+
mkdirSync(join(base, 'payloads'), { recursive: true });
|
|
27
|
+
mkdirSync(join(base, 'contracts'), { recursive: true });
|
|
28
|
+
mkdirSync(join(base, 'attestations'), { recursive: true });
|
|
29
|
+
const cfg = join(base, 'vault.json');
|
|
30
|
+
if (!existsSync(cfg)) {
|
|
31
|
+
writeFileSync(cfg, JSON.stringify(
|
|
32
|
+
{ schema_version: SCHEMA, store_mode: 'in-repo', payload_max_bytes: 1048576 }, null, 2));
|
|
33
|
+
}
|
|
34
|
+
return base;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function paths(root) {
|
|
38
|
+
const base = join(root, DIR);
|
|
39
|
+
return {
|
|
40
|
+
base,
|
|
41
|
+
entries: join(base, 'entries'),
|
|
42
|
+
payloads: join(base, 'payloads'),
|
|
43
|
+
contracts: join(base, 'contracts'),
|
|
44
|
+
attestations: join(base, 'attestations'), // G10 — append-only opinion log
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function payloadView(buf) {
|
|
49
|
+
const text = buf.toString('utf8');
|
|
50
|
+
let json = null;
|
|
51
|
+
try { json = JSON.parse(text); } catch { /* raw artifact */ }
|
|
52
|
+
return { text, json, raw: buf };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// "exit_code_eq:0" | "regex_match:[0-9a-f]{40}" | a JSON object string
|
|
56
|
+
export function parseVerifier(spec) {
|
|
57
|
+
if (spec.trim().startsWith('{')) return JSON.parse(spec);
|
|
58
|
+
const idx = spec.indexOf(':');
|
|
59
|
+
if (idx === -1) return { kind: spec, params: {} };
|
|
60
|
+
const kind = spec.slice(0, idx);
|
|
61
|
+
const rest = spec.slice(idx + 1);
|
|
62
|
+
switch (kind) {
|
|
63
|
+
case 'exit_code_eq': return { kind, params: { code: Number(rest) } };
|
|
64
|
+
case 'regex_match':
|
|
65
|
+
case 'not_contains': return { kind, params: { pattern: rest } };
|
|
66
|
+
case 'commit_exists': return { kind, params: { sha: rest } };
|
|
67
|
+
case 'jq_pred': return { kind, params: { expr: rest } };
|
|
68
|
+
default: return { kind, params: { value: rest } };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadContract(root, scope, phase) {
|
|
73
|
+
const p = join(paths(root).contracts, scope, `${phase}.json`);
|
|
74
|
+
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function record(root, opts) {
|
|
78
|
+
const P = paths(root);
|
|
79
|
+
|
|
80
|
+
// G4 — independent capture: the vault runs the source (or reads the file) and
|
|
81
|
+
// hashes/verifies it. It trusts no claimed status. (Env isolation is the
|
|
82
|
+
// harness's job — see CONTRACTS.md §4 G4 threat model.)
|
|
83
|
+
let blob;
|
|
84
|
+
if (opts.run) {
|
|
85
|
+
const r = spawnSync(opts.source, {
|
|
86
|
+
shell: true, cwd: opts.cwd || root, encoding: 'buffer', maxBuffer: 16 * 1024 * 1024,
|
|
87
|
+
});
|
|
88
|
+
const capture = {
|
|
89
|
+
command: opts.source,
|
|
90
|
+
exit_code: r.status === null ? 124 : r.status,
|
|
91
|
+
stdout: (r.stdout || Buffer.alloc(0)).toString('utf8'),
|
|
92
|
+
stderr: (r.stderr || Buffer.alloc(0)).toString('utf8'),
|
|
93
|
+
captured_at: new Date().toISOString(),
|
|
94
|
+
};
|
|
95
|
+
blob = Buffer.from(canonical(capture), 'utf8');
|
|
96
|
+
} else if (opts.artifact) {
|
|
97
|
+
blob = readFileSync(opts.artifact);
|
|
98
|
+
} else {
|
|
99
|
+
throw new Error('record requires --run or --artifact');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const payload_sha256 = sha256(blob);
|
|
103
|
+
|
|
104
|
+
// G10/D1 — acceptance criteria are mandatory and frozen to the evidence.
|
|
105
|
+
// Recording evidence without stating the bar it claims to clear is rejected.
|
|
106
|
+
if (typeof opts.criteria !== 'string' || opts.criteria.trim() === '') {
|
|
107
|
+
throw new Error('record requires --criteria (the acceptance criteria this evidence claims to clear)');
|
|
108
|
+
}
|
|
109
|
+
const acceptance_criteria = opts.criteria;
|
|
110
|
+
const criteria_sha256 = sha256(Buffer.from(acceptance_criteria, 'utf8'));
|
|
111
|
+
|
|
112
|
+
// The verifier is now an OPTIONAL deterministic sub-check (ADR-0002 D2), not
|
|
113
|
+
// the whole story — the independent judgment lives in the skill layer.
|
|
114
|
+
const verifier = (typeof opts.verifier === 'string' && opts.verifier) ? parseVerifier(opts.verifier) : null;
|
|
115
|
+
|
|
116
|
+
// G8 — contract pinning: if a contract pins this claim, record rejects a
|
|
117
|
+
// kind/source/verifier/criteria downgrade.
|
|
118
|
+
const contract = loadContract(root, opts.scope, opts.phase);
|
|
119
|
+
let criteria_authored_by = 'record'; // worker-supplied — weaker provenance (Gemini escalation)
|
|
120
|
+
if (contract) {
|
|
121
|
+
const pin = (contract.required_evidence || []).find((c) => c.claim_id === opts.claim);
|
|
122
|
+
if (pin) {
|
|
123
|
+
if (pin.kind && pin.kind !== opts.kind) throw new Error(`G8 pin violation: kind '${opts.kind}' != pinned '${pin.kind}'`);
|
|
124
|
+
if (pin.source_pin && pin.source_pin !== opts.source) throw new Error('G8 pin violation: source != pinned source');
|
|
125
|
+
if (pin.verifier && (!verifier || pin.verifier.kind !== verifier.kind)) throw new Error(`G8 pin violation: verifier '${verifier ? verifier.kind : 'none'}' != pinned '${pin.verifier.kind}'`);
|
|
126
|
+
// D1 trusted path: criteria pinned by the contract (authored separately
|
|
127
|
+
// from the worker). A mismatch is a downgrade; an exact match upgrades the
|
|
128
|
+
// provenance class to 'contract'.
|
|
129
|
+
if (typeof pin.criteria === 'string') {
|
|
130
|
+
if (pin.criteria !== acceptance_criteria) throw new Error('G8 pin violation: acceptance_criteria != pinned criteria');
|
|
131
|
+
criteria_authored_by = 'contract';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// content-addressed payload (dedupe)
|
|
137
|
+
const payloadPath = join(P.payloads, payload_sha256);
|
|
138
|
+
if (!existsSync(payloadPath)) writeFileSync(payloadPath, blob);
|
|
139
|
+
|
|
140
|
+
const id = newId();
|
|
141
|
+
const fields = {
|
|
142
|
+
scope: opts.scope, phase: opts.phase, claim_id: opts.claim,
|
|
143
|
+
kind: opts.kind, source: opts.source, verifier, criteria_sha256, payload_sha256,
|
|
144
|
+
};
|
|
145
|
+
const envelope_hash = envelopeHash(fields);
|
|
146
|
+
const sr = verifier
|
|
147
|
+
? runVerifier(verifier, payloadView(blob), { repoRoot: opts.cwd || root })
|
|
148
|
+
: { status: 'n/a', detail: 'no deterministic verifier (judgment-tier claim)' };
|
|
149
|
+
|
|
150
|
+
const entry = {
|
|
151
|
+
id, ...fields,
|
|
152
|
+
acceptance_criteria, criteria_authored_by,
|
|
153
|
+
payload_ref: `payloads/${payload_sha256}`,
|
|
154
|
+
envelope_hash,
|
|
155
|
+
status_at_record: sr.status, // informational ONLY — verify never reads it (G3)
|
|
156
|
+
state: 'active',
|
|
157
|
+
supersedes: null,
|
|
158
|
+
contract_version: contract ? contract.contract_version : null,
|
|
159
|
+
created_at: new Date().toISOString(),
|
|
160
|
+
created_by: process.env.USER || 'unknown',
|
|
161
|
+
};
|
|
162
|
+
writeFileSync(join(P.entries, `${id}.json`), JSON.stringify(entry, null, 2));
|
|
163
|
+
return { id, envelope_hash, criteria_authored_by, status_at_record: sr.status, status_detail: sr.detail };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// G6 — append-only supersede: record a NEW artifact stamped with `supersedes`,
|
|
167
|
+
// then flip the OLD entry's state to 'superseded'. Crash-safe ordering: the
|
|
168
|
+
// replacement is written and confirmed on disk FIRST, so a crash can never
|
|
169
|
+
// leave the old entry inactive with no active replacement (worst case: both
|
|
170
|
+
// briefly active, which cross-check tolerates — latest active wins).
|
|
171
|
+
export function supersede(root, oldId, recordOpts) {
|
|
172
|
+
const P = paths(root);
|
|
173
|
+
const oldPath = join(P.entries, `${oldId}.json`);
|
|
174
|
+
if (!existsSync(oldPath)) throw new Error(`supersede: old entry not found: ${oldId}`);
|
|
175
|
+
|
|
176
|
+
// 1. record the replacement (reuses the full record path: capture, hash,
|
|
177
|
+
// G8 pin check, verifier, envelope) and stamp the supersedes link.
|
|
178
|
+
const res = record(root, recordOpts);
|
|
179
|
+
const newPath = join(P.entries, `${res.id}.json`);
|
|
180
|
+
const newEntry = JSON.parse(readFileSync(newPath, 'utf8'));
|
|
181
|
+
newEntry.supersedes = oldId;
|
|
182
|
+
writeFileSync(newPath, JSON.stringify(newEntry, null, 2));
|
|
183
|
+
|
|
184
|
+
// 2. confirm the new entry is durably on disk BEFORE flipping the old one.
|
|
185
|
+
if (!existsSync(newPath)) throw new Error('supersede: replacement entry failed to persist');
|
|
186
|
+
|
|
187
|
+
// 3. flip the old entry to superseded (immutability is preserved for the
|
|
188
|
+
// identifying fields + payload; only `state` transitions, per G6).
|
|
189
|
+
const oldEntry = JSON.parse(readFileSync(oldPath, 'utf8'));
|
|
190
|
+
oldEntry.state = 'superseded';
|
|
191
|
+
writeFileSync(oldPath, JSON.stringify(oldEntry, null, 2));
|
|
192
|
+
|
|
193
|
+
return { new_id: res.id, old_id: oldId };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function verify(root, id) {
|
|
197
|
+
const P = paths(root);
|
|
198
|
+
const entryPath = join(P.entries, `${id}.json`);
|
|
199
|
+
if (!existsSync(entryPath)) return { id, hash_ok: false, status: 'error', detail: 'entry not found', rederived: false };
|
|
200
|
+
const entry = JSON.parse(readFileSync(entryPath, 'utf8'));
|
|
201
|
+
|
|
202
|
+
const payloadPath = join(P.payloads, entry.payload_sha256);
|
|
203
|
+
if (!existsSync(payloadPath)) return { id, hash_ok: false, status: 'error', detail: 'payload blob missing', rederived: false };
|
|
204
|
+
const blob = readFileSync(payloadPath);
|
|
205
|
+
|
|
206
|
+
// G2 — recompute the payload, criteria, AND envelope hashes from the actual
|
|
207
|
+
// blob + entry fields, and compare to what was stored. Any tamper diverges.
|
|
208
|
+
const recomputedPayloadSha = sha256(blob);
|
|
209
|
+
const payload_ok = recomputedPayloadSha === entry.payload_sha256;
|
|
210
|
+
const recomputedCriteriaSha = entry.acceptance_criteria !== undefined
|
|
211
|
+
? sha256(Buffer.from(entry.acceptance_criteria, 'utf8'))
|
|
212
|
+
: entry.criteria_sha256;
|
|
213
|
+
const criteria_ok = recomputedCriteriaSha === entry.criteria_sha256;
|
|
214
|
+
const recomputedEnvelope = envelopeHash({
|
|
215
|
+
scope: entry.scope, phase: entry.phase, claim_id: entry.claim_id,
|
|
216
|
+
kind: entry.kind, source: entry.source, verifier: entry.verifier,
|
|
217
|
+
criteria_sha256: recomputedCriteriaSha, payload_sha256: recomputedPayloadSha,
|
|
218
|
+
});
|
|
219
|
+
const envelope_ok = recomputedEnvelope === entry.envelope_hash;
|
|
220
|
+
const hash_ok = payload_ok && criteria_ok && envelope_ok;
|
|
221
|
+
|
|
222
|
+
// G3 — integrity tier: re-derive the deterministic verifier (if any) against
|
|
223
|
+
// the payload. status_at_record is NEVER consulted. A claim with no
|
|
224
|
+
// deterministic verifier (judgment-tier) passes integrity on an intact hash.
|
|
225
|
+
const sr = entry.verifier
|
|
226
|
+
? runVerifier(entry.verifier, payloadView(blob), { repoRoot: root })
|
|
227
|
+
: { status: 'pass', detail: 'integrity intact; no deterministic verifier (see attestations)' };
|
|
228
|
+
|
|
229
|
+
// fail-closed: a pass requires an intact hash AND (no verifier OR it passes).
|
|
230
|
+
const status = hash_ok ? sr.status : 'fail';
|
|
231
|
+
|
|
232
|
+
// G10 — surface the latest independent opinion for reference (NOT trusted as
|
|
233
|
+
// reproducible, NOT re-derived). Flagged stale if it judged different bytes.
|
|
234
|
+
const latest = latestAttestation(root, id);
|
|
235
|
+
const latest_attestation = latest ? {
|
|
236
|
+
attestation_id: latest.attestation_id, opinion: latest.opinion,
|
|
237
|
+
evaluator: latest.evaluator, model: latest.model, created_at: latest.created_at,
|
|
238
|
+
stale: latest.evidence_sha256 !== entry.payload_sha256 || latest.criteria_sha256 !== entry.criteria_sha256,
|
|
239
|
+
} : null;
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
id, hash_ok, payload_ok, criteria_ok, envelope_ok,
|
|
243
|
+
status, rederived: true,
|
|
244
|
+
detail: hash_ok ? sr.detail : `TAMPER: hash mismatch (payload_ok=${payload_ok}, criteria_ok=${criteria_ok}, envelope_ok=${envelope_ok})`,
|
|
245
|
+
ignored_cached_status: entry.status_at_record,
|
|
246
|
+
latest_attestation,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Judgment tier (G10) — model-free CLI side: inspect, attest, list ──────────
|
|
251
|
+
|
|
252
|
+
// What the analyze-evidence skill feeds the independent judge: the frozen
|
|
253
|
+
// criteria + evidence + an integrity check. Returns raw text/json so the skill
|
|
254
|
+
// can pass them as ESCAPED DATA (never as instructions) to the evaluator (D7).
|
|
255
|
+
export function inspect(root, id) {
|
|
256
|
+
const P = paths(root);
|
|
257
|
+
const entryPath = join(P.entries, `${id}.json`);
|
|
258
|
+
if (!existsSync(entryPath)) return { id, error: 'entry not found' };
|
|
259
|
+
const entry = JSON.parse(readFileSync(entryPath, 'utf8'));
|
|
260
|
+
const v = verify(root, id);
|
|
261
|
+
const payloadPath = join(P.payloads, entry.payload_sha256);
|
|
262
|
+
const blob = existsSync(payloadPath) ? readFileSync(payloadPath) : Buffer.alloc(0);
|
|
263
|
+
const view = payloadView(blob);
|
|
264
|
+
return {
|
|
265
|
+
id, scope: entry.scope, phase: entry.phase, claim_id: entry.claim_id,
|
|
266
|
+
kind: entry.kind, source: entry.source,
|
|
267
|
+
acceptance_criteria: entry.acceptance_criteria,
|
|
268
|
+
criteria_authored_by: entry.criteria_authored_by,
|
|
269
|
+
created_by: entry.created_by,
|
|
270
|
+
evidence: { text: view.text, json: view.json },
|
|
271
|
+
hash_ok: v.hash_ok,
|
|
272
|
+
integrity_status: v.status,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function attestationDir(root, id) { return join(paths(root).attestations, id); }
|
|
277
|
+
|
|
278
|
+
const OPINIONS = new Set(['pass', 'reject', 'unclear']);
|
|
279
|
+
|
|
280
|
+
// Append an independent opinion. Fail-closed on a tampered artifact; reject a
|
|
281
|
+
// self-grade (evaluator == the worker that produced the evidence). The verdict
|
|
282
|
+
// is NOT re-derivable — its trust is the attestation chain (G10).
|
|
283
|
+
export function attest(root, id, opts) {
|
|
284
|
+
const P = paths(root);
|
|
285
|
+
const entryPath = join(P.entries, `${id}.json`);
|
|
286
|
+
if (!existsSync(entryPath)) throw new Error(`attest: artifact not found: ${id}`);
|
|
287
|
+
const entry = JSON.parse(readFileSync(entryPath, 'utf8'));
|
|
288
|
+
|
|
289
|
+
if (!OPINIONS.has(opts.opinion)) throw new Error(`attest: --opinion must be one of pass|reject|unclear (got '${opts.opinion}')`);
|
|
290
|
+
if (typeof opts.evaluator !== 'string' || !opts.evaluator) throw new Error('attest requires --evaluator');
|
|
291
|
+
|
|
292
|
+
// G10/D4 — mechanical independence: the judge must differ from the worker.
|
|
293
|
+
if (entry.created_by && opts.evaluator === entry.created_by) {
|
|
294
|
+
throw new Error(`attest refused (G10/D4): evaluator '${opts.evaluator}' equals the artifact creator — a judgment must be independent of the worker`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Fail-closed (G5/G10): never attest against a tampered artifact.
|
|
298
|
+
const v = verify(root, id);
|
|
299
|
+
if (!v.hash_ok) throw new Error(`attest refused: artifact integrity check failed (${v.detail})`);
|
|
300
|
+
|
|
301
|
+
const att = {
|
|
302
|
+
attestation_id: newId(),
|
|
303
|
+
artifact_id: id,
|
|
304
|
+
opinion: opts.opinion,
|
|
305
|
+
rationale: opts.rationale || '',
|
|
306
|
+
evaluator: opts.evaluator,
|
|
307
|
+
model: opts.model || null,
|
|
308
|
+
prompt_hash: opts.prompt_hash || null,
|
|
309
|
+
sampling: opts.sampling || null,
|
|
310
|
+
evidence_sha256: entry.payload_sha256,
|
|
311
|
+
criteria_sha256: entry.criteria_sha256,
|
|
312
|
+
created_at: new Date().toISOString(),
|
|
313
|
+
};
|
|
314
|
+
// tamper-evident binding over the attestation tuple (G2-style, G10)
|
|
315
|
+
const attestation_hash = sha256(Buffer.from(canonical(att), 'utf8'));
|
|
316
|
+
const stored = { ...att, attestation_hash };
|
|
317
|
+
|
|
318
|
+
const dir = attestationDir(root, id);
|
|
319
|
+
mkdirSync(dir, { recursive: true });
|
|
320
|
+
writeFileSync(join(dir, `${att.attestation_id}.json`), JSON.stringify(stored, null, 2));
|
|
321
|
+
return { attestation_id: att.attestation_id, attestation_hash, opinion: att.opinion };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function listAttestations(root, id) {
|
|
325
|
+
const dir = attestationDir(root, id);
|
|
326
|
+
if (!existsSync(dir)) return [];
|
|
327
|
+
return readdirSync(dir)
|
|
328
|
+
.filter((f) => f.endsWith('.json'))
|
|
329
|
+
.map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')))
|
|
330
|
+
.sort((a, b) => (a.attestation_id < b.attestation_id ? 1 : -1)); // newest first
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function latestAttestation(root, id) {
|
|
334
|
+
return listAttestations(root, id)[0] || null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function declareContract(root, scope, phase, spec) {
|
|
338
|
+
const P = paths(root);
|
|
339
|
+
const required = spec.required_evidence || spec;
|
|
340
|
+
const contract_version = sha256(Buffer.from(canonical({ required_evidence: required }), 'utf8')).slice(0, 16);
|
|
341
|
+
const obj = {
|
|
342
|
+
scope, phase, required_evidence: required, contract_version,
|
|
343
|
+
origin: spec.origin || 'cli', declared_at: new Date().toISOString(),
|
|
344
|
+
};
|
|
345
|
+
const dir = join(P.contracts, scope);
|
|
346
|
+
mkdirSync(dir, { recursive: true });
|
|
347
|
+
writeFileSync(join(dir, `${phase}.json`), JSON.stringify(obj, null, 2));
|
|
348
|
+
return { contract_version };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function listEntries(root, scope, phase) {
|
|
352
|
+
const P = paths(root);
|
|
353
|
+
if (!existsSync(P.entries)) return [];
|
|
354
|
+
return readdirSync(P.entries)
|
|
355
|
+
.filter((f) => f.endsWith('.json'))
|
|
356
|
+
.map((f) => JSON.parse(readFileSync(join(P.entries, f), 'utf8')))
|
|
357
|
+
.filter((e) => (!scope || e.scope === scope) && (!phase || e.phase === phase));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// G9 — mechanical evaluation: the contract is consumer-authored; cross-check is
|
|
361
|
+
// a pure function of (contract, recorded artifacts). The vault decides WHETHER
|
|
362
|
+
// the contract is satisfied, never WHAT it should require.
|
|
363
|
+
export function crossCheck(root, scope, phase, opts = {}) {
|
|
364
|
+
const withAttestations = !!opts.withAttestations;
|
|
365
|
+
const contract = loadContract(root, scope, phase);
|
|
366
|
+
if (!contract) {
|
|
367
|
+
return { scope, phase, overall: 'ERROR', detail: 'no contract declared (fail-closed)', claims: [] };
|
|
368
|
+
}
|
|
369
|
+
const active = listEntries(root, scope, phase).filter((e) => e.state === 'active');
|
|
370
|
+
const claims = [];
|
|
371
|
+
for (const req of (contract.required_evidence || [])) {
|
|
372
|
+
const matches = active
|
|
373
|
+
.filter((e) => e.claim_id === req.claim_id)
|
|
374
|
+
.sort((a, b) => (a.id < b.id ? 1 : -1)); // latest active wins
|
|
375
|
+
const art = matches[0];
|
|
376
|
+
if (!art) {
|
|
377
|
+
claims.push({ claim_id: req.claim_id, result: req.required === false ? 'PASS' : 'MISSING' });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (req.verifier && (!art.verifier || art.verifier.kind !== req.verifier.kind)) {
|
|
381
|
+
claims.push({ claim_id: req.claim_id, artifact_id: art.id, result: 'ERROR', detail: 'verifier pin mismatch' });
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
// Integrity tier (default): deterministic, CI-safe.
|
|
385
|
+
const v = verify(root, art.id);
|
|
386
|
+
const integrity_ok = v.hash_ok && v.status === 'pass';
|
|
387
|
+
const claim = {
|
|
388
|
+
claim_id: req.claim_id, artifact_id: art.id,
|
|
389
|
+
hash_ok: v.hash_ok, verifier_status: v.status,
|
|
390
|
+
result: integrity_ok ? 'PASS' : 'FAIL', detail: v.detail,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Judgment tier (opt-in): consult the latest independent opinion on this
|
|
394
|
+
// artifact. Advisory unless the contract sets require_attestation, in which
|
|
395
|
+
// case a passing, non-stale, independent opinion is required (G10).
|
|
396
|
+
if (withAttestations) {
|
|
397
|
+
const att = latestAttestation(root, art.id);
|
|
398
|
+
const stale = att && (att.evidence_sha256 !== art.payload_sha256 || att.criteria_sha256 !== art.criteria_sha256);
|
|
399
|
+
claim.attestation = att
|
|
400
|
+
? { attestation_id: att.attestation_id, opinion: att.opinion, evaluator: att.evaluator, model: att.model, stale: !!stale }
|
|
401
|
+
: null;
|
|
402
|
+
if (req.require_attestation) {
|
|
403
|
+
const attested_pass = !!att && att.opinion === 'pass' && !stale;
|
|
404
|
+
if (!integrity_ok) {
|
|
405
|
+
claim.result = 'FAIL';
|
|
406
|
+
} else if (!att) {
|
|
407
|
+
claim.result = 'UNATTESTED'; claim.detail = 'require_attestation: no independent opinion recorded';
|
|
408
|
+
} else if (!attested_pass) {
|
|
409
|
+
claim.result = 'REJECT'; claim.detail = `independent opinion '${att.opinion}'${stale ? ' (stale)' : ''}`;
|
|
410
|
+
} else {
|
|
411
|
+
claim.result = 'PASS';
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
claims.push(claim);
|
|
416
|
+
}
|
|
417
|
+
const overall = claims.every((c) => c.result === 'PASS')
|
|
418
|
+
? 'PASS'
|
|
419
|
+
: (claims.some((c) => c.result === 'ERROR') ? 'ERROR' : 'REJECT');
|
|
420
|
+
return {
|
|
421
|
+
scope, phase, contract_version: contract.contract_version,
|
|
422
|
+
mode: withAttestations ? 'with-attestations' : 'integrity-only',
|
|
423
|
+
overall, claims, evaluated_at: new Date().toISOString(),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// A verifier is a PURE, DETERMINISTIC function of (payload-view, params) (G7).
|
|
4
|
+
// run(view, params, ctx) -> { status: 'pass' | 'fail' | 'error', detail }
|
|
5
|
+
// view = { text, json, raw } derived from the payload blob.
|
|
6
|
+
// An unknown kind is treated as ERROR by the caller (G5 fail-closed).
|
|
7
|
+
|
|
8
|
+
// For a --run capture the payload is {command, exit_code, stdout, stderr,...};
|
|
9
|
+
// the "verifiable text" is stdout+stderr. For a raw --artifact it is the text.
|
|
10
|
+
function verifiableText(view) {
|
|
11
|
+
if (view.json && (view.json.stdout !== undefined || view.json.stderr !== undefined)) {
|
|
12
|
+
return `${view.json.stdout ?? ''}\n${view.json.stderr ?? ''}`;
|
|
13
|
+
}
|
|
14
|
+
return view.text;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const VERIFIERS = {
|
|
18
|
+
exit_code_eq: {
|
|
19
|
+
determinism: 'deterministic',
|
|
20
|
+
run(view, params) {
|
|
21
|
+
if (!view.json || typeof view.json.exit_code !== 'number') {
|
|
22
|
+
return { status: 'fail', detail: 'payload carries no exit_code (not a --run capture)' };
|
|
23
|
+
}
|
|
24
|
+
const want = params.code ?? 0;
|
|
25
|
+
return view.json.exit_code === want
|
|
26
|
+
? { status: 'pass', detail: `exit_code=${view.json.exit_code}` }
|
|
27
|
+
: { status: 'fail', detail: `exit_code=${view.json.exit_code} != ${want}` };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
regex_match: {
|
|
32
|
+
determinism: 'deterministic',
|
|
33
|
+
run(view, params) {
|
|
34
|
+
const re = new RegExp(params.pattern, params.flags || 'm');
|
|
35
|
+
return re.test(verifiableText(view))
|
|
36
|
+
? { status: 'pass', detail: `matched /${params.pattern}/` }
|
|
37
|
+
: { status: 'fail', detail: `no match for /${params.pattern}/` };
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
not_contains: {
|
|
42
|
+
determinism: 'deterministic',
|
|
43
|
+
run(view, params) {
|
|
44
|
+
const re = new RegExp(params.pattern, params.flags || 'm');
|
|
45
|
+
return re.test(verifiableText(view))
|
|
46
|
+
? { status: 'fail', detail: `forbidden /${params.pattern}/ is present` }
|
|
47
|
+
: { status: 'pass', detail: `/${params.pattern}/ absent` };
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
jq_pred: {
|
|
52
|
+
determinism: 'deterministic',
|
|
53
|
+
run(view, params) {
|
|
54
|
+
const input = view.json !== null ? JSON.stringify(view.json) : view.text;
|
|
55
|
+
const r = spawnSync('jq', ['-e', params.expr], { input, encoding: 'utf8' });
|
|
56
|
+
if (r.error) return { status: 'error', detail: 'jq binary not available' };
|
|
57
|
+
return r.status === 0
|
|
58
|
+
? { status: 'pass', detail: `jq -e '${params.expr}' -> truthy` }
|
|
59
|
+
: { status: 'fail', detail: `jq -e '${params.expr}' -> false/null` };
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
commit_exists: {
|
|
64
|
+
determinism: 'deterministic',
|
|
65
|
+
run(view, params, ctx) {
|
|
66
|
+
const sha = (params.sha || verifiableText(view)).trim().split(/\s+/)[0];
|
|
67
|
+
const r = spawnSync('git', ['-C', ctx.repoRoot || '.', 'cat-file', '-e', `${sha}^{commit}`], { encoding: 'utf8' });
|
|
68
|
+
if (r.error) return { status: 'error', detail: 'git binary not available' };
|
|
69
|
+
return r.status === 0
|
|
70
|
+
? { status: 'pass', detail: `commit ${sha.slice(0, 10)} exists` }
|
|
71
|
+
: { status: 'fail', detail: `commit ${sha.slice(0, 10)} not found` };
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function runVerifier(verifier, view, ctx) {
|
|
77
|
+
const v = VERIFIERS[verifier.kind];
|
|
78
|
+
if (!v) return { status: 'error', detail: `unknown verifier kind: ${verifier.kind}` };
|
|
79
|
+
try {
|
|
80
|
+
return v.run(view, verifier.params || {}, ctx || {});
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return { status: 'error', detail: `verifier threw: ${e.message}` };
|
|
83
|
+
}
|
|
84
|
+
}
|