mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-23 10:05:12 +02:00
Consolidate recovered eval framework and operator workflows (#3040)
* feat: consolidate offline eval and operator workflows
Compose the retained framework, operator skill, roadmap and cleanup ranges on current main. Preserve current release dependencies and keep candidate execution disabled pending OS containment. Repair draft/DOCX behavior, obligation uniqueness, trusted send and audience guidance, runner provenance and eval diagnostics.
Source-PR: 2930 0abe3727d2b500c6e4830bdeb47ed67cae3f4785
Source-PR: 2931 992b49c44ed872def49675b791168b8fcd091df6
Source-PR: 2932 4a193dd13041cb7a6bebf4d2e910a0cd32bcc797
Source-PR: 2933 59cdfe500a91949ba1415f1edd7279620f21e804
Source-Base: ca185ef5f7
* fix: repair foundation CI and update js-yaml
* fix: reconcile pending-delete capsule locks after close
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d2b352c202
commit
f8640355e4
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ECC eval-harness CLI.
|
||||
*
|
||||
* node scripts/eval-harness.js capsule verify <dir>
|
||||
* node scripts/eval-harness.js capsule project <dir>
|
||||
* node scripts/eval-harness.js capsule export <dir> <out-dir>
|
||||
* node scripts/eval-harness.js gate run <gate.config.json> [--work-dir <dir>] [--capsule <dir>]
|
||||
* node scripts/eval-harness.js receipt build <capsule-dir> [--artifact <file>] [--gate <gate-receipt.json>] [--out <file>]
|
||||
* node scripts/eval-harness.js receipt verify <receipt.json> <capsule-dir> [--artifact <file>] [--gate <gate-receipt.json>]
|
||||
* node scripts/eval-harness.js example
|
||||
*
|
||||
* Gate execution is unavailable: gate.isolation_required (exit 1).
|
||||
* Exit codes: 0 verified, 1 failed verification or unavailable, 2 usage error.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const harness = require('./lib/eval-harness');
|
||||
|
||||
function usage(message) {
|
||||
if (message) {
|
||||
process.stderr.write(`eval-harness: ${message}\n`);
|
||||
}
|
||||
const header = fs.readFileSync(__filename, 'utf8').split('\n').slice(3, 15).map((line) => line.replace(/^ \*\s?/, '')).join('\n');
|
||||
process.stderr.write(`${header}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function flag(args, name) {
|
||||
const indices = args.flatMap((value, index) => value === name ? [index] : []);
|
||||
for (const index of indices) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith('--')) usage(`${name} needs a value`);
|
||||
}
|
||||
if (indices.length > 1) usage(`${name} may only be supplied once`);
|
||||
return indices.length ? args[indices[0] + 1] : undefined;
|
||||
}
|
||||
|
||||
function print(value) {
|
||||
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf8'));
|
||||
}
|
||||
|
||||
function runExample(action) {
|
||||
const script = path.join(__dirname, '..', 'examples', 'eval-harness', 'run-example.js');
|
||||
const result = spawnSync(process.execPath, [script, ...(action ? [action] : [])], { stdio: 'inherit' });
|
||||
if (result.error) {
|
||||
// OS errors may contain command arguments or private paths. Report only
|
||||
// this stable diagnostic, never the child error object or its message.
|
||||
process.stderr.write('eval-harness: example.spawn_failed: unable to start example process\n');
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
}
|
||||
|
||||
function runCapsule(action, rest) {
|
||||
const dir = rest[0];
|
||||
if (!dir) usage('capsule commands need a capsule directory');
|
||||
if (action === 'verify') {
|
||||
const result = harness.capsule.verify(dir);
|
||||
print(result);
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
if (action === 'project') {
|
||||
print(harness.capsule.writeProjection(dir));
|
||||
return;
|
||||
}
|
||||
if (action === 'export') {
|
||||
if (!rest[1]) usage('capsule export needs an output directory');
|
||||
print(harness.capsule.exportBundle(dir, rest[1]));
|
||||
return;
|
||||
}
|
||||
usage(`unknown capsule action ${action}`);
|
||||
}
|
||||
|
||||
function runGate(action, rest) {
|
||||
if (action !== 'run' || !rest[0]) usage('gate run needs a config path');
|
||||
// Refuse before reading a config or creating/opening a capsule.
|
||||
harness.gate.requireSupportedIsolation();
|
||||
}
|
||||
|
||||
function receiptOptions(rest) {
|
||||
// Validate every value option before any file read or producer write.
|
||||
return {
|
||||
artifact: flag(rest, '--artifact'),
|
||||
gate: flag(rest, '--gate'),
|
||||
out: flag(rest, '--out'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildReceipt(rest, options) {
|
||||
const dir = rest[0];
|
||||
if (!dir) usage('receipt build needs a capsule directory');
|
||||
const receipt = harness.receipt.buildReceipt(dir, {
|
||||
artifact_path: options.artifact,
|
||||
gate_receipt: options.gate ? readJson(options.gate) : undefined,
|
||||
});
|
||||
if (options.out) harness.receipt.writeReceipt(receipt, options.out);
|
||||
print(receipt);
|
||||
}
|
||||
|
||||
function verifyReceipt(rest, options) {
|
||||
const [receiptPath, dir] = rest;
|
||||
if (!receiptPath || !dir) usage('receipt verify needs a receipt path and a capsule directory');
|
||||
const result = harness.receipt.verifyReceipt(readJson(receiptPath), dir, {
|
||||
artifact_path: options.artifact,
|
||||
gate_receipt: options.gate ? readJson(options.gate) : undefined,
|
||||
});
|
||||
print(result);
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
function runReceipt(action, rest) {
|
||||
const options = receiptOptions(rest);
|
||||
if (action === 'build') return buildReceipt(rest, options);
|
||||
if (action === 'verify') return verifyReceipt(rest, options);
|
||||
usage(`unknown receipt action ${action}`);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const [group, action, ...rest] = argv;
|
||||
if (!group) usage();
|
||||
if (group === 'example') return runExample(action);
|
||||
if (group === 'capsule') return runCapsule(action, rest);
|
||||
if (group === 'gate') return runGate(action, rest);
|
||||
if (group === 'receipt') return runReceipt(action, rest);
|
||||
usage(`unknown command ${group}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`eval-harness: ${error.code ? `${error.code}: ` : ''}${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Canonical JSON and hashing helpers shared by the eval-harness frameworks.
|
||||
*
|
||||
* Every hash in the capsule journal, the gate receipts, and the offline
|
||||
* receipts is computed over canonical JSON: object keys sorted recursively,
|
||||
* no whitespace, UTF-8. Two writers that agree on content therefore agree on
|
||||
* bytes, which is what makes projections and receipts reproducible.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function canonicalize(value) {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize);
|
||||
}
|
||||
const out = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const item = value[key];
|
||||
if (item === undefined) {
|
||||
continue;
|
||||
}
|
||||
// Generic JSON keys are data, including __proto__; never invoke a setter.
|
||||
Object.defineProperty(out, key, {
|
||||
value: canonicalize(item), enumerable: true, writable: true, configurable: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function canonicalJson(value) {
|
||||
return JSON.stringify(canonicalize(value));
|
||||
}
|
||||
|
||||
function sha256Hex(input) {
|
||||
return crypto.createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
function hashValue(value) {
|
||||
return sha256Hex(canonicalJson(value));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canonicalize,
|
||||
canonicalJson,
|
||||
sha256Hex,
|
||||
hashValue,
|
||||
};
|
||||
@@ -0,0 +1,410 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Local execution capsule: an append-only, hash-linked NDJSON journal with
|
||||
* five typed lineages and a deterministic projection.
|
||||
*
|
||||
* Framework 2 of the eval-harness set. Properties the tests pin down:
|
||||
* - every entry links to its predecessor by sha256 (parent_hash);
|
||||
* - verify() fails closed at the exact entry for tamper, truncation, and
|
||||
* reordering, and reports a partial trailing write as truncation;
|
||||
* - project() rebuilds the same bytes from the same journal every time;
|
||||
* - exportBundle() copies the journal and projection only, never the
|
||||
* workspace the run touched.
|
||||
*
|
||||
* What this does not claim: a hash chain does not stop an operator who
|
||||
* replaces the whole log. Witnessing is a later, opt-in layer.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { canonicalJson, hashValue, sha256Hex } = require('./canonical');
|
||||
const envelope = require('./envelope');
|
||||
|
||||
const JOURNAL_FILE = 'journal.ndjson';
|
||||
const PROJECTION_FILE = 'projection.json';
|
||||
const META_FILE = 'capsule.json';
|
||||
const APPEND_LOCK_FILE = '.append.lock';
|
||||
|
||||
class CapsuleError extends Error {
|
||||
constructor(code, message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'CapsuleError';
|
||||
this.code = code;
|
||||
Object.assign(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
function newId(prefix) {
|
||||
return `${prefix}-${crypto.randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
function nowIso(clock) {
|
||||
return (clock ? clock() : new Date()).toISOString();
|
||||
}
|
||||
|
||||
/** Validate metadata before persistence, and bind identity to every journal entry. */
|
||||
function metadataFailure(meta, entries = []) {
|
||||
const invalid = reason => ({ ok: false, code: 'capsule.metadata_invalid', reason, failed_at: null });
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta) || meta.schema !== envelope.SCHEMA_VERSION) {
|
||||
return invalid('capsule metadata has an invalid schema');
|
||||
}
|
||||
for (const field of ['run_id', 'capsule_id']) {
|
||||
if (typeof meta[field] !== 'string' || !envelope.ID_PATTERN.test(meta[field])) return invalid(`invalid metadata ${field}`);
|
||||
}
|
||||
for (const field of ['harness_version', 'task_family']) {
|
||||
if (typeof meta[field] !== 'string' || !meta[field].trim()) return invalid(`invalid metadata ${field}`);
|
||||
}
|
||||
const date = typeof meta.created_at === 'string' ? new Date(meta.created_at) : new Date(NaN);
|
||||
if (!Number.isFinite(date.getTime()) || date.toISOString() !== meta.created_at) return invalid('metadata created_at must be a canonical ISO timestamp');
|
||||
const fields = ['schema', 'run_id', 'capsule_id', 'harness_version', 'task_family'];
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
if (fields.some(field => entry[field] !== meta[field])) {
|
||||
return { ok: false, code: 'capsule.metadata_mismatch', reason: `metadata identity differs from journal entry ${index}`, failed_at: index };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function releaseOwnedLock(lockPath, fd, identity) {
|
||||
let inspectionDenied;
|
||||
try {
|
||||
// Keep the original descriptor open while checking ownership so its inode
|
||||
// cannot be reused. Preserve a replacement detected before release; this
|
||||
// check is not atomic against noncooperating filesystem mutation.
|
||||
if (identity) {
|
||||
let current;
|
||||
try { current = fs.lstatSync(lockPath); } catch (error) {
|
||||
if (error.code === 'ENOENT') throw new CapsuleError('capsule.lock_lost', 'append lock disappeared before release');
|
||||
if (error.code !== 'EPERM') throw error;
|
||||
inspectionDenied = error;
|
||||
}
|
||||
if (!inspectionDenied) {
|
||||
if (!current.isFile() || current.dev !== identity.dev || current.ino !== identity.ino) {
|
||||
throw new CapsuleError('capsule.lock_lost', 'append lock ownership changed before release');
|
||||
}
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
if (inspectionDenied) {
|
||||
// Windows may deny stat while a removed file awaits its last handle close.
|
||||
// Only confirmed absence changes the error. Never unlink after closing:
|
||||
// the pathname could now belong to another owner, even with a reused inode.
|
||||
try { fs.lstatSync(lockPath); } catch (error) {
|
||||
if (error.code === 'ENOENT') throw new CapsuleError('capsule.lock_lost', 'append lock disappeared before release');
|
||||
}
|
||||
throw inspectionDenied;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exclusive cooperative append lock. Never waits or infers stale ownership. */
|
||||
function withAppendLock(dir, operation) {
|
||||
const lockPath = path.join(dir, APPEND_LOCK_FILE);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, 'wx', 0o600);
|
||||
} catch (error) {
|
||||
if (error.code === 'EEXIST') throw new CapsuleError('capsule.busy', 'capsule append lock is already held');
|
||||
throw error;
|
||||
}
|
||||
let identity;
|
||||
try {
|
||||
identity = fs.fstatSync(fd);
|
||||
return operation();
|
||||
} finally {
|
||||
releaseOwnedLock(lockPath, fd, identity);
|
||||
}
|
||||
}
|
||||
|
||||
class Capsule {
|
||||
/**
|
||||
* @param {string} dir capsule root (created if missing)
|
||||
* @param {object} meta { run_id, capsule_id, harness_version, task_family }
|
||||
*/
|
||||
constructor(dir, meta, options = {}) {
|
||||
this.dir = path.resolve(dir);
|
||||
this.meta = meta;
|
||||
this.clock = options.clock || null;
|
||||
this.journalPath = path.join(this.dir, JOURNAL_FILE);
|
||||
this.lastHash = envelope.GENESIS_HASH;
|
||||
this.nextSeq = 0;
|
||||
}
|
||||
|
||||
static create(dir, options = {}) {
|
||||
const resolved = path.resolve(dir);
|
||||
if (fs.existsSync(path.join(resolved, META_FILE))) {
|
||||
throw new CapsuleError('capsule.exists', `capsule already exists at ${resolved}`);
|
||||
}
|
||||
const meta = {
|
||||
schema: envelope.SCHEMA_VERSION,
|
||||
run_id: options.run_id === undefined ? newId('run') : options.run_id,
|
||||
capsule_id: options.capsule_id === undefined ? newId('capsule') : options.capsule_id,
|
||||
harness_version: options.harness_version === undefined ? 'unknown' : options.harness_version,
|
||||
task_family: options.task_family === undefined ? 'unspecified' : options.task_family,
|
||||
created_at: nowIso(options.clock),
|
||||
};
|
||||
const failure = metadataFailure(meta);
|
||||
if (failure) throw new CapsuleError(failure.code, failure.reason);
|
||||
fs.mkdirSync(resolved, { recursive: true });
|
||||
fs.writeFileSync(path.join(resolved, META_FILE), canonicalJson(meta) + '\n', 'utf8');
|
||||
fs.writeFileSync(path.join(resolved, JOURNAL_FILE), '', 'utf8');
|
||||
return new Capsule(resolved, meta, options);
|
||||
}
|
||||
|
||||
static open(dir, options = {}) {
|
||||
const resolved = path.resolve(dir);
|
||||
const state = readCapsule(resolved);
|
||||
if (!state.ok) {
|
||||
throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
|
||||
}
|
||||
const capsule = new Capsule(resolved, state.meta, options);
|
||||
if (state.entries.length > 0) {
|
||||
const last = state.entries[state.entries.length - 1];
|
||||
capsule.lastHash = last.entry_hash;
|
||||
capsule.nextSeq = last.seq + 1;
|
||||
}
|
||||
return capsule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize cooperating appenders and validate current disk state under lock.
|
||||
* A partial I/O failure is preserved for diagnosis, never silently rolled back.
|
||||
*/
|
||||
append(lineage, kind, payload = {}, options = {}) {
|
||||
return withAppendLock(this.dir, () => {
|
||||
const state = readCapsule(this.dir);
|
||||
if (!state.ok) throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
|
||||
if (!envelope.LINEAGES.includes(lineage)) {
|
||||
throw new CapsuleError('capsule.bad_lineage', `unknown lineage ${lineage}`);
|
||||
}
|
||||
const effectClass = options.effect_class || 'SE0';
|
||||
const { payload: clean, dropped, findings, errors: payloadErrors } = envelope.redactPayload(payload, options);
|
||||
if (payloadErrors.length > 0) {
|
||||
throw new CapsuleError('capsule.payload_invalid', payloadErrors.join('; '));
|
||||
}
|
||||
if (findings.length > 0) {
|
||||
throw new CapsuleError('capsule.secret_canary', `payload tripped secret canary ${findings[0].canary} at ${findings[0].path}`, { findings });
|
||||
}
|
||||
if (dropped.length > 0 && options.strict !== false) {
|
||||
throw new CapsuleError('capsule.payload_denied', `payload keys not allowlisted: ${dropped.join(', ')}`, { dropped });
|
||||
}
|
||||
const body = {
|
||||
schema: envelope.SCHEMA_VERSION,
|
||||
run_id: state.meta.run_id,
|
||||
capsule_id: state.meta.capsule_id,
|
||||
seq: state.entries.length,
|
||||
ts: nowIso(this.clock),
|
||||
lineage,
|
||||
kind,
|
||||
effect_class: effectClass,
|
||||
harness_version: state.meta.harness_version,
|
||||
task_family: state.meta.task_family,
|
||||
parent_hash: state.root_hash,
|
||||
payload: clean,
|
||||
};
|
||||
const entry = { ...body, entry_hash: envelope.computeEntryHash(body) };
|
||||
const errors = envelope.validateEnvelope(entry);
|
||||
if (errors.length > 0) throw new CapsuleError('capsule.invalid_entry', errors.join('; '));
|
||||
const bytes = Buffer.from(canonicalJson(entry) + '\n', 'utf8');
|
||||
const fd = fs.openSync(this.journalPath, 'a');
|
||||
try {
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const written = fs.writeSync(fd, bytes, offset, bytes.length - offset, null);
|
||||
if (written <= 0) throw new CapsuleError('capsule.write_failed', 'journal write made no progress');
|
||||
offset += written;
|
||||
}
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
// These fields remain observable for compatibility, but are never used as
|
||||
// authoritative append state. A preopened handle always reloads above.
|
||||
this.meta = state.meta;
|
||||
this.lastHash = entry.entry_hash;
|
||||
this.nextSeq = entry.seq + 1;
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
entries() {
|
||||
const state = readJournal(this.journalPath);
|
||||
if (!state.ok) {
|
||||
throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
|
||||
}
|
||||
return state.entries;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and verify a journal file. Never throws for content problems; the
|
||||
* result names the first failing entry index and a stable reason code.
|
||||
*/
|
||||
function readJournal(journalPath) {
|
||||
if (!fs.existsSync(journalPath)) {
|
||||
return { ok: false, code: 'capsule.missing_journal', reason: 'journal file missing', failed_at: null, entries: [] };
|
||||
}
|
||||
let bytes;
|
||||
try { bytes = fs.readFileSync(journalPath); } catch {
|
||||
return { ok: false, code: 'capsule.unreadable_journal', reason: 'journal file could not be read', failed_at: null, entries: [] };
|
||||
}
|
||||
const raw = bytes.toString('utf8');
|
||||
if (!bytes.equals(Buffer.from(raw, 'utf8'))) {
|
||||
return { ok: false, code: 'capsule.non_canonical', reason: 'journal is not valid UTF-8', failed_at: null, entries: [] };
|
||||
}
|
||||
const journalDigest = sha256Hex(bytes);
|
||||
const entries = [];
|
||||
if (raw.length === 0) {
|
||||
return { ok: true, entries, root_hash: envelope.GENESIS_HASH, journal_sha256: journalDigest };
|
||||
}
|
||||
if (!raw.endsWith('\n')) {
|
||||
const index = raw.split('\n').length - 1;
|
||||
return { ok: false, code: 'capsule.truncated_tail', reason: 'last entry is incomplete (no terminating newline)', failed_at: index, entries };
|
||||
}
|
||||
const lines = raw.slice(0, -1).split('\n');
|
||||
let expectedParent = envelope.GENESIS_HASH;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(lines[index]);
|
||||
} catch (_error) {
|
||||
return { ok: false, code: 'capsule.corrupt_entry', reason: `entry ${index} is not valid JSON`, failed_at: index, entries };
|
||||
}
|
||||
const errors = envelope.validateEnvelope(entry);
|
||||
if (errors.length > 0) {
|
||||
return { ok: false, code: 'capsule.invalid_entry', reason: `entry ${index}: ${errors[0]}`, failed_at: index, entries };
|
||||
}
|
||||
if (entry.seq !== index) {
|
||||
return { ok: false, code: 'capsule.reordered', reason: `entry ${index} carries seq ${entry.seq}`, failed_at: index, entries };
|
||||
}
|
||||
if (entry.parent_hash !== expectedParent) {
|
||||
return { ok: false, code: 'capsule.broken_link', reason: `entry ${index} parent_hash does not match predecessor`, failed_at: index, entries };
|
||||
}
|
||||
if (canonicalJson(entry) !== lines[index]) {
|
||||
return { ok: false, code: 'capsule.non_canonical', reason: `entry ${index} is not canonical JSON`, failed_at: index, entries };
|
||||
}
|
||||
expectedParent = entry.entry_hash;
|
||||
entries.push(entry);
|
||||
}
|
||||
return { ok: true, entries, root_hash: expectedParent, journal_sha256: journalDigest };
|
||||
}
|
||||
|
||||
/** Read one journal snapshot and validate its capsule metadata. Never writes. */
|
||||
function readCapsule(dir) {
|
||||
const resolved = path.resolve(dir);
|
||||
const state = readJournal(path.join(resolved, JOURNAL_FILE));
|
||||
if (!state.ok) return state;
|
||||
let meta;
|
||||
try {
|
||||
const bytes = fs.readFileSync(path.join(resolved, META_FILE));
|
||||
const raw = bytes.toString('utf8');
|
||||
if (!bytes.equals(Buffer.from(raw, 'utf8'))) throw new Error('invalid UTF-8 metadata');
|
||||
meta = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ...state, ok: false, code: 'capsule.metadata_invalid', reason: 'capsule metadata is missing, unreadable or corrupt', failed_at: null };
|
||||
}
|
||||
const failure = metadataFailure(meta, state.entries);
|
||||
if (failure) return { ...state, ...failure };
|
||||
return { ...state, meta, projection: projectState(meta, state) };
|
||||
}
|
||||
|
||||
function verify(dir) {
|
||||
const state = readCapsule(dir);
|
||||
return {
|
||||
ok: state.ok,
|
||||
code: state.ok ? 'ok' : state.code,
|
||||
reason: state.ok ? 'journal verified' : state.reason,
|
||||
failed_at: state.ok ? null : state.failed_at,
|
||||
entry_count: state.entries.length,
|
||||
root_hash: state.ok ? state.root_hash : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic projection: the same journal always yields the same bytes.
|
||||
* Includes per-lineage counts, last seq, root hash, and the journal digest.
|
||||
*/
|
||||
function project(dir) {
|
||||
const state = readCapsule(dir);
|
||||
if (!state.ok) throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
|
||||
return state.projection;
|
||||
}
|
||||
|
||||
/** Derive the projection only from the metadata and journal snapshot just verified. */
|
||||
function projectState(meta, state) {
|
||||
const byLineage = {};
|
||||
for (const lineage of envelope.LINEAGES) {
|
||||
byLineage[lineage] = 0;
|
||||
}
|
||||
const byEffect = {};
|
||||
for (const effectClass of envelope.EFFECT_CLASSES) {
|
||||
byEffect[effectClass] = 0;
|
||||
}
|
||||
for (const entry of state.entries) {
|
||||
byLineage[entry.lineage] += 1;
|
||||
byEffect[entry.effect_class] += 1;
|
||||
}
|
||||
const projection = {
|
||||
schema: envelope.SCHEMA_VERSION,
|
||||
run_id: meta.run_id,
|
||||
capsule_id: meta.capsule_id,
|
||||
harness_version: meta.harness_version,
|
||||
task_family: meta.task_family,
|
||||
entry_count: state.entries.length,
|
||||
last_seq: state.entries.length === 0 ? null : state.entries.length - 1,
|
||||
root_hash: state.root_hash,
|
||||
journal_sha256: state.journal_sha256,
|
||||
by_lineage: byLineage,
|
||||
by_effect_class: byEffect,
|
||||
max_effect_class: maxEffectClass(state.entries),
|
||||
};
|
||||
return { ...projection, projection_hash: hashValue(projection) };
|
||||
}
|
||||
|
||||
function maxEffectClass(entries) {
|
||||
let rank = 0;
|
||||
for (const entry of entries) {
|
||||
rank = Math.max(rank, envelope.effectRank(entry.effect_class));
|
||||
}
|
||||
return envelope.EFFECT_CLASSES[rank];
|
||||
}
|
||||
|
||||
function writeProjection(dir) {
|
||||
const projection = project(dir);
|
||||
fs.writeFileSync(path.join(path.resolve(dir), PROJECTION_FILE), canonicalJson(projection) + '\n', 'utf8');
|
||||
return projection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a minimal bundle: capsule.json, journal.ndjson, projection.json.
|
||||
* Workspace contents are never copied.
|
||||
*/
|
||||
function exportBundle(dir, outDir) {
|
||||
const resolved = path.resolve(dir);
|
||||
const target = path.resolve(outDir);
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
writeProjection(resolved);
|
||||
for (const name of [META_FILE, JOURNAL_FILE, PROJECTION_FILE]) {
|
||||
fs.copyFileSync(path.join(resolved, name), path.join(target, name));
|
||||
}
|
||||
return { dir: target, files: [META_FILE, JOURNAL_FILE, PROJECTION_FILE] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Capsule,
|
||||
CapsuleError,
|
||||
JOURNAL_FILE,
|
||||
PROJECTION_FILE,
|
||||
META_FILE,
|
||||
readJournal,
|
||||
readCapsule,
|
||||
verify,
|
||||
project,
|
||||
writeProjection,
|
||||
exportBundle,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
// Retired execution entrypoint. No JS interception or trust flag provides
|
||||
// OS containment; refuse before reading requests or loading candidate code.
|
||||
require('./gate').requireSupportedIsolation();
|
||||
@@ -0,0 +1,251 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* capsule-envelope/v1: the portable record contract for one journal entry.
|
||||
*
|
||||
* Framework 1 of the eval-harness set (telemetry and capsule contract).
|
||||
* The envelope is deliberately small. It carries identity, lineage, effect
|
||||
* class, a hash link to its predecessor, and an allowlisted payload. Raw
|
||||
* secrets, credentials, and unrestricted reasoning text never enter the
|
||||
* default envelope: the payload passes through a default-deny property
|
||||
* allowlist and a secret canary scan before it is written.
|
||||
*/
|
||||
|
||||
const { hashValue } = require('./canonical');
|
||||
|
||||
const SCHEMA_VERSION = 'capsule-envelope/v1';
|
||||
|
||||
/** The five append-only lineages a capsule records. */
|
||||
const LINEAGES = Object.freeze(['plan', 'attempt', 'interaction', 'environment', 'strategy']);
|
||||
|
||||
/**
|
||||
* Side-effect classes, ordered from pure to irreversible.
|
||||
* SE0 read-only evaluation. SE1 reversible local writes inside a capsule root.
|
||||
* SE2 sandboxed process or filesystem mutation, no live network writes.
|
||||
* SE3 append-only remote evidence publication. SE4 economic or external effects.
|
||||
*/
|
||||
const EFFECT_CLASSES = Object.freeze(['SE0', 'SE1', 'SE2', 'SE3', 'SE4']);
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const HASH_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const GENESIS_HASH = '0'.repeat(64);
|
||||
|
||||
/** Scalar types mirror schemas/capsule-envelope.schema.json. */
|
||||
const PAYLOAD_TYPES = Object.freeze({
|
||||
task_id: 'string',
|
||||
task_family: 'string',
|
||||
tool: 'string',
|
||||
tool_call_id: 'string',
|
||||
args_hash: 'string',
|
||||
response_hash: 'string',
|
||||
status: 'string',
|
||||
exit_code: 'integer|null',
|
||||
duration_ms: 'number',
|
||||
tokens_in: 'integer',
|
||||
tokens_out: 'integer',
|
||||
cost_usd: 'number',
|
||||
model: 'string',
|
||||
message: 'string',
|
||||
note: 'string',
|
||||
decision: 'string',
|
||||
reason: 'string',
|
||||
score: 'number',
|
||||
passed: 'integer',
|
||||
failed: 'integer',
|
||||
total: 'integer',
|
||||
variant: 'string',
|
||||
digest: 'string',
|
||||
path: 'string',
|
||||
fixture_key: 'string',
|
||||
stage: 'string',
|
||||
verdict: 'string',
|
||||
hits: 'integer',
|
||||
branch_id: 'string',
|
||||
parent_branch_id: 'string',
|
||||
summary: 'string',
|
||||
});
|
||||
const DEFAULT_PAYLOAD_ALLOWLIST = Object.freeze(Object.keys(PAYLOAD_TYPES));
|
||||
const ENVELOPE_FIELDS = new Set([
|
||||
'schema', 'run_id', 'capsule_id', 'seq', 'ts', 'lineage', 'kind',
|
||||
'effect_class', 'harness_version', 'task_family', 'parent_hash', 'entry_hash', 'payload',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Secret and credential canaries. A match anywhere in a payload string is
|
||||
* a hard refusal: the entry is not written and the caller sees which
|
||||
* canary fired. Patterns are intentionally broad and cheap.
|
||||
*/
|
||||
const SECRET_CANARIES = Object.freeze([
|
||||
{ name: 'private_key_block', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
||||
{ name: 'aws_access_key', pattern: /\bAKIA[0-9A-Z]{16}\b/ },
|
||||
{ name: 'openai_style_key', pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
|
||||
{ name: 'github_token', pattern: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
|
||||
{ name: 'slack_token', pattern: /\bxox[abpr]-[A-Za-z0-9-]{10,}\b/ },
|
||||
{ name: 'stripe_key', pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/ },
|
||||
{ name: 'bearer_header', pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/ },
|
||||
{ name: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
|
||||
{ name: 'env_assignment', pattern: /\b(?:API_KEY|SECRET|TOKEN|PASSWORD|PASSWD)\s*=\s*\S{8,}/i },
|
||||
]);
|
||||
|
||||
function scanForCanaries(value, findings = [], trail = '$') {
|
||||
if (typeof value === 'string') {
|
||||
for (const canary of SECRET_CANARIES) {
|
||||
if (canary.pattern.test(value)) {
|
||||
findings.push({ canary: canary.name, path: trail });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => scanForCanaries(item, findings, `${trail}[${index}]`));
|
||||
return findings;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const key of Object.keys(value)) {
|
||||
scanForCanaries(value[key], findings, `${trail}.${key}`);
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
/** Inspect descriptors before reading values; this is not a boundary for proxies. */
|
||||
function dataObjectErrors(value, label) {
|
||||
if (!isPlainObject(value)) return [`${label} must be a plain data object`];
|
||||
const errors = [];
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (typeof key !== 'string' || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) {
|
||||
errors.push(`${label} must contain only enumerable string data properties`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function matchesPayloadType(value, type) {
|
||||
if (type === 'string') return typeof value === 'string';
|
||||
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
||||
if (type === 'integer|null' && value === null) return true;
|
||||
return typeof value === 'number' && Number.isInteger(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return { payload, dropped, findings, errors } without coercing retained fields.
|
||||
* Custom allowlists only narrow v1. Invalid data is never scanned or hashed.
|
||||
*/
|
||||
function redactPayload(payload, options = {}) {
|
||||
const errors = dataObjectErrors(payload, 'payload');
|
||||
if (errors.length) return { payload: {}, dropped: [], findings: [], errors };
|
||||
const allowlist = new Set(options.allowlist || DEFAULT_PAYLOAD_ALLOWLIST);
|
||||
const kept = {};
|
||||
const dropped = [];
|
||||
for (const key of Object.keys(payload)) {
|
||||
if (!Object.hasOwn(PAYLOAD_TYPES, key) || !allowlist.has(key)) {
|
||||
dropped.push(key);
|
||||
} else if (!matchesPayloadType(payload[key], PAYLOAD_TYPES[key])) {
|
||||
errors.push(`payload field ${key} must have type ${PAYLOAD_TYPES[key]}`);
|
||||
} else {
|
||||
kept[key] = payload[key];
|
||||
}
|
||||
}
|
||||
const findings = errors.length ? [] : scanForCanaries(kept);
|
||||
return { payload: kept, dropped: dropped.sort(), findings, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one envelope. Returns an array of error strings; empty means valid.
|
||||
* The check is structural and independent of the journal it came from.
|
||||
* Hash-link correctness is verified by the capsule reader, not here.
|
||||
*/
|
||||
function validateEnvelope(entry) {
|
||||
const errors = dataObjectErrors(entry, 'envelope');
|
||||
if (errors.length) return errors;
|
||||
if (Object.keys(entry).some(key => !ENVELOPE_FIELDS.has(key))) {
|
||||
errors.push('envelope has unknown top-level fields');
|
||||
}
|
||||
if ([...ENVELOPE_FIELDS].some(key => !Object.hasOwn(entry, key))) {
|
||||
errors.push('envelope is missing required own fields');
|
||||
}
|
||||
if (errors.length) return errors;
|
||||
if (entry.schema !== SCHEMA_VERSION) {
|
||||
errors.push(`schema must be ${SCHEMA_VERSION}`);
|
||||
}
|
||||
for (const field of ['run_id', 'capsule_id']) {
|
||||
if (typeof entry[field] !== 'string' || !ID_PATTERN.test(entry[field])) {
|
||||
errors.push(`${field} must match ${ID_PATTERN}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(entry.seq) || entry.seq < 0) {
|
||||
errors.push('seq must be a non-negative integer');
|
||||
}
|
||||
if (typeof entry.ts !== 'string' || Number.isNaN(Date.parse(entry.ts))) {
|
||||
errors.push('ts must be an ISO-8601 timestamp');
|
||||
}
|
||||
if (!LINEAGES.includes(entry.lineage)) {
|
||||
errors.push(`lineage must be one of ${LINEAGES.join(', ')}`);
|
||||
}
|
||||
if (typeof entry.kind !== 'string' || !/^[a-z][a-z0-9_.-]{0,63}$/.test(entry.kind)) {
|
||||
errors.push('kind must be a short lowercase identifier');
|
||||
}
|
||||
if (!EFFECT_CLASSES.includes(entry.effect_class)) {
|
||||
errors.push(`effect_class must be one of ${EFFECT_CLASSES.join(', ')}`);
|
||||
}
|
||||
if (typeof entry.harness_version !== 'string' || entry.harness_version.length === 0) {
|
||||
errors.push('harness_version must be a non-empty string');
|
||||
}
|
||||
if (typeof entry.task_family !== 'string' || entry.task_family.length === 0) {
|
||||
errors.push('task_family must be a non-empty string');
|
||||
}
|
||||
if (typeof entry.parent_hash !== 'string' || !HASH_PATTERN.test(entry.parent_hash)) {
|
||||
errors.push('parent_hash must be a 64-char hex sha256');
|
||||
}
|
||||
if (typeof entry.entry_hash !== 'string' || !HASH_PATTERN.test(entry.entry_hash)) {
|
||||
errors.push('entry_hash must be a 64-char hex sha256');
|
||||
}
|
||||
if (!isPlainObject(entry.payload)) {
|
||||
errors.push('payload must be an object');
|
||||
} else {
|
||||
const { dropped, findings, errors: payloadErrors } = redactPayload(entry.payload);
|
||||
errors.push(...payloadErrors);
|
||||
if (dropped.length > 0) {
|
||||
errors.push(`payload has non-allowlisted keys: ${dropped.join(', ')}`);
|
||||
}
|
||||
for (const finding of findings) {
|
||||
errors.push(`payload tripped secret canary ${finding.canary} at ${finding.path}`);
|
||||
}
|
||||
}
|
||||
if (errors.length === 0) {
|
||||
const expected = computeEntryHash(entry);
|
||||
if (expected !== entry.entry_hash) {
|
||||
errors.push('entry_hash does not match entry content');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** The hash covers every field except entry_hash itself. */
|
||||
function computeEntryHash(entry) {
|
||||
const { entry_hash: _ignored, ...rest } = entry;
|
||||
return hashValue(rest);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SCHEMA_VERSION,
|
||||
LINEAGES,
|
||||
EFFECT_CLASSES,
|
||||
GENESIS_HASH,
|
||||
DEFAULT_PAYLOAD_ALLOWLIST,
|
||||
SECRET_CANARIES,
|
||||
ID_PATTERN,
|
||||
HASH_PATTERN,
|
||||
redactPayload,
|
||||
scanForCanaries,
|
||||
validateEnvelope,
|
||||
computeEntryHash,
|
||||
effectRank: (effectClass) => EFFECT_CLASSES.indexOf(effectClass),
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
// Retired execution entrypoint. No JS interception or trust flag provides
|
||||
// OS containment; refuse before reading requests or loading candidate code.
|
||||
require('./gate').requireSupportedIsolation();
|
||||
@@ -0,0 +1,258 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Static variant inspection and disabled execution gate.
|
||||
*
|
||||
* Framework 3 of the eval-harness set. Candidate execution is disabled
|
||||
* because no verified
|
||||
* OS containment backend exists. Neither a copied directory, JavaScript
|
||||
* interception nor a caller-supplied trusted-local flag is a security boundary.
|
||||
*
|
||||
* Tripwires are cheap and syntactic on purpose: sandbox weakening, marker
|
||||
* tampering, hidden network enablement, and effect-class expansion are
|
||||
* reported by static inspection. Absence of detected tripwires does not
|
||||
* establish containment or prevent reward hacking.
|
||||
*
|
||||
* Non-goals: no automatic merge or release, no online self-editing, and no
|
||||
* claim that a small taskset measures small score deltas precisely.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { sha256Hex } = require('./canonical');
|
||||
const envelope = require('./envelope');
|
||||
|
||||
const GATE_SCHEMA = 'gate-receipt/v1';
|
||||
|
||||
const DEFAULT_THRESHOLDS = Object.freeze({
|
||||
smoke_tasks: 3,
|
||||
min_pass_rate: 0.9,
|
||||
max_regressions: 0,
|
||||
timeout_ms: 20000,
|
||||
});
|
||||
|
||||
/** Default syntactic tripwires. Each hit names the rule, file, and line. */
|
||||
const DEFAULT_TRIPWIRES = Object.freeze([
|
||||
{ rule: 'hidden_network', pattern: /require\(\s*['"](?:node:)?(?:http|https|net|tls|dgram|dns|http2)['"]\s*\)/ },
|
||||
{ rule: 'hidden_network', pattern: /\bfetch\s*\(/ },
|
||||
{ rule: 'process_spawn', pattern: /require\(\s*['"](?:node:)?child_process['"]\s*\)/ },
|
||||
{ rule: 'sandbox_weakening', pattern: /Module\._load|--no-sandbox|NODE_OPTIONS|effect-fence|ECC_EFFECT_FENCE/ },
|
||||
{ rule: 'checker_probe', pattern: /taskset|expected_output|\.gate-marker|gate-receipt|ECC_GATE_/ },
|
||||
{ rule: 'parent_escape', pattern: /(?:^|[^.\w])\.\.(?:[\\/]|['"`])/ },
|
||||
]);
|
||||
|
||||
class GateError extends Error {
|
||||
constructor(code, message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'GateError';
|
||||
this.code = code;
|
||||
Object.assign(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
function listFiles(dir, base = dir, acc = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.git') {
|
||||
continue;
|
||||
}
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
|
||||
throw new GateError('gate.variant_invalid', 'variant trees must contain only regular files and directories');
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
listFiles(full, base, acc);
|
||||
} else if (entry.isFile()) {
|
||||
acc.push(path.relative(base, full).split(path.sep).join('/'));
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** Read the opened regular file, never reopen a previously checked pathname.
|
||||
* No-follow/nonblocking flags reduce symlink and special-file hazards where
|
||||
* supported. Descriptor/path identity also rejects symlinks on other hosts.
|
||||
* This is static inspection of a caller-controlled tree, not OS containment.
|
||||
*/
|
||||
function readRegularFile(filePath, encoding) {
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(filePath, flags);
|
||||
const opened = fs.fstatSync(fd);
|
||||
const current = fs.lstatSync(filePath);
|
||||
if (!opened.isFile() || !current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) {
|
||||
throw new GateError('gate.variant_invalid', 'inspection requires the same regular file');
|
||||
}
|
||||
return fs.readFileSync(fd, encoding);
|
||||
} catch (error) {
|
||||
if (error.code === 'ELOOP') throw new GateError('gate.variant_invalid', 'inspection refuses symbolic links');
|
||||
throw error;
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/** Content digest of a directory tree: sorted relative paths and bytes. */
|
||||
function digestDir(dir) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
for (const relative of listFiles(dir)) {
|
||||
hash.update(relative);
|
||||
hash.update('\0');
|
||||
hash.update(readRegularFile(path.join(dir, relative)));
|
||||
hash.update('\0');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function loadVariant(dir) {
|
||||
const resolved = fs.realpathSync(path.resolve(dir));
|
||||
const manifestPath = path.join(resolved, 'variant.json');
|
||||
let manifestBytes;
|
||||
try {
|
||||
manifestBytes = readRegularFile(manifestPath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') throw new GateError('gate.variant_missing', `variant.json missing in ${resolved}`);
|
||||
throw error;
|
||||
}
|
||||
const manifest = JSON.parse(manifestBytes);
|
||||
if (typeof manifest.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(manifest.name) || !envelope.EFFECT_CLASSES.includes(manifest.effect_class)) {
|
||||
throw new GateError('gate.variant_invalid', `variant.json in ${resolved} needs name and a valid effect_class`);
|
||||
}
|
||||
const entry = manifest.entry === undefined ? 'run.js' : manifest.entry;
|
||||
if (typeof entry !== 'string' || !entry || path.isAbsolute(entry) || path.win32.isAbsolute(entry) || entry.includes('\\') || entry.split('/').includes('..')) {
|
||||
throw new GateError('gate.variant_invalid', 'entry must be a relative regular file within the variant');
|
||||
}
|
||||
const entryPath = path.resolve(resolved, entry);
|
||||
const relative = path.relative(resolved, entryPath);
|
||||
if (!relative || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) || !listFiles(resolved).includes(relative.split(path.sep).join('/')) || !fs.lstatSync(entryPath).isFile()) {
|
||||
throw new GateError('gate.variant_invalid', 'entry must be covered by the variant digest');
|
||||
}
|
||||
return { dir: resolved, name: manifest.name, effect_class: manifest.effect_class, entry: relative, digest: digestDir(resolved) };
|
||||
}
|
||||
|
||||
function loadTaskset(tasksetPath) {
|
||||
const resolved = path.resolve(tasksetPath);
|
||||
const taskset = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||
if (!taskset || typeof taskset !== 'object' || !taskset.version || !taskset.family || !Array.isArray(taskset.tasks) || taskset.tasks.length === 0) {
|
||||
throw new GateError('gate.taskset_invalid', 'taskset needs version, family, and a non-empty tasks array');
|
||||
}
|
||||
if (new Set(taskset.tasks.map(task => task && task.id)).size !== taskset.tasks.length) throw new GateError('gate.taskset_invalid', 'task ids must be unique');
|
||||
for (const task of taskset.tasks) {
|
||||
if (!task || typeof task !== 'object' || typeof task.id !== 'string' || !task.id || !('input' in task) || !('expected' in task)) {
|
||||
throw new GateError('gate.taskset_invalid', 'every task needs id, input, and expected');
|
||||
}
|
||||
}
|
||||
return { ...taskset, path: resolved, digest: sha256Hex(fs.readFileSync(resolved)) };
|
||||
}
|
||||
|
||||
/** Scan variant sources for tripwire patterns and effect-class expansion. */
|
||||
function scanTripwires(variant, options = {}) {
|
||||
const rules = options.tripwires || DEFAULT_TRIPWIRES;
|
||||
const maxRank = envelope.effectRank(options.max_effect_class || 'SE1');
|
||||
const hits = [];
|
||||
if (envelope.effectRank(variant.effect_class) > maxRank) {
|
||||
hits.push({ variant: variant.name, rule: 'effect_class_expansion', file: 'variant.json', line: 1, detail: `${variant.effect_class} exceeds ${options.max_effect_class || 'SE1'}` });
|
||||
}
|
||||
for (const relative of listFiles(variant.dir)) {
|
||||
if (!/\.(?:js|cjs|mjs|json|sh)$/.test(relative)) {
|
||||
continue;
|
||||
}
|
||||
const lines = readRegularFile(path.join(variant.dir, relative), 'utf8').split(/\r?\n/);
|
||||
lines.forEach((text, index) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.pattern.test(text)) {
|
||||
hits.push({ variant: variant.name, rule: rule.rule, file: relative, line: index + 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** No verified OS backend is implemented; caller-supplied flags cannot bypass this. */
|
||||
function requireSupportedIsolation() {
|
||||
throw new GateError('gate.isolation_required', 'Candidate execution is disabled: no verified OS containment backend is implemented.');
|
||||
}
|
||||
|
||||
/** Reject every legacy direct-runner invocation before copying or executing code. */
|
||||
function runVariant() {
|
||||
requireSupportedIsolation();
|
||||
}
|
||||
|
||||
/** Validate bounded child protocol data. This does not attest to isolation. */
|
||||
function parseChildResult(child, tasks) {
|
||||
const outputs = new Map();
|
||||
let fatal = null;
|
||||
if (!child || typeof child !== 'object') return { outputs, fatal: 'missing child result' };
|
||||
if (child.error) return { outputs, fatal: child.error.code === 'ETIMEDOUT' ? 'timeout' : 'child process error' };
|
||||
if (child.status !== 0 || child.signal) return { outputs, fatal: 'child exited unsuccessfully' };
|
||||
try {
|
||||
const raw = String(child.stdout || '');
|
||||
if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error('oversized child output');
|
||||
const lastLine = raw.trim().split('\n').filter(Boolean).pop() || '';
|
||||
const parsed = JSON.parse(lastLine);
|
||||
const owns = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid child envelope');
|
||||
if (owns(parsed, 'fatal')) {
|
||||
if (typeof parsed.fatal !== 'string' || !parsed.fatal || owns(parsed, 'results')) throw new Error('invalid fatal');
|
||||
fatal = 'child reported fatal failure';
|
||||
} else {
|
||||
const expectedIds = new Set(tasks.map(task => task.id));
|
||||
if (!Array.isArray(parsed.results) || parsed.results.length !== tasks.length || expectedIds.size !== tasks.length) throw new Error('incomplete results');
|
||||
for (const result of parsed.results) {
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result) || !expectedIds.delete(result.id) || owns(result, 'output') === owns(result, 'error')) throw new Error('invalid result');
|
||||
outputs.set(result.id, result);
|
||||
}
|
||||
if (expectedIds.size) throw new Error('missing result');
|
||||
}
|
||||
} catch {
|
||||
fatal = 'invalid child result protocol';
|
||||
}
|
||||
// Never expose partial rows from an invalid response as successful baseline results.
|
||||
return { outputs: fatal ? new Map() : outputs, fatal };
|
||||
}
|
||||
|
||||
/** Require a complete, error-free baseline before any future candidate scoring. */
|
||||
function baselineFailure(run, tasks) {
|
||||
const invalidTasks = !Array.isArray(tasks) || !tasks.length
|
||||
|| tasks.some(task => !task || typeof task.id !== 'string' || !task.id)
|
||||
|| new Set(tasks.map(task => task.id)).size !== tasks.length;
|
||||
if (invalidTasks || !run || run.fatal || run.exit_code !== 0
|
||||
|| run.marker_intact !== true || !Array.isArray(run.fence_events)
|
||||
|| run.fence_events.length || !(run.outputs instanceof Map)
|
||||
|| run.outputs.size !== tasks.length) {
|
||||
return 'baseline process, protocol or integrity failure';
|
||||
}
|
||||
for (const task of tasks) {
|
||||
const result = run.outputs.get(task.id);
|
||||
if (!result || result.id !== task.id
|
||||
|| !Object.prototype.hasOwnProperty.call(result, 'output')
|
||||
|| Object.prototype.hasOwnProperty.call(result, 'error')) {
|
||||
return 'baseline result missing or failed';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reject before inspecting config, reading files, or emitting any gate receipt. */
|
||||
function runGate() {
|
||||
requireSupportedIsolation();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GATE_SCHEMA,
|
||||
DEFAULT_THRESHOLDS,
|
||||
DEFAULT_TRIPWIRES,
|
||||
requireSupportedIsolation,
|
||||
parseChildResult,
|
||||
baselineFailure,
|
||||
GateError,
|
||||
digestDir,
|
||||
loadVariant,
|
||||
loadTaskset,
|
||||
scanTripwires,
|
||||
runVariant,
|
||||
runGate,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ECC eval-harness frameworks.
|
||||
*
|
||||
* envelope capsule-envelope/v1 contract, redaction, secret canaries
|
||||
* capsule append-only hash-linked journal with five lineages
|
||||
* gate static inspection and disabled execution gate, syntactic warnings
|
||||
* replay declared tool effects, fixtures, fail-closed replay, retired effect preload
|
||||
* receipt offline-verifiable capsule receipts
|
||||
*
|
||||
* See docs/architecture/eval-harness-frameworks.md and examples/eval-harness.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
canonical: require('./canonical'),
|
||||
envelope: require('./envelope'),
|
||||
capsule: require('./capsule'),
|
||||
gate: require('./gate'),
|
||||
replay: require('./replay'),
|
||||
receipt: require('./receipt'),
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Offline-verifiable capsule receipts.
|
||||
*
|
||||
* Framework 5 of the eval-harness set (verifiable receipts, local only).
|
||||
* A receipt names the capsule root hash, entry count, schema version, the
|
||||
* artifact digest under evaluation, and the gate receipt digest. It can be
|
||||
* verified on a machine that never sees the source store as long as it has
|
||||
* the exported bundle. The signature field is a detached interface: callers
|
||||
* pass a signer/verifier pair; nothing here generates or stores keys.
|
||||
*
|
||||
* Signatures prove who vouched for the bytes, not that the run was correct.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { isDeepStrictEqual } = require('util');
|
||||
|
||||
const { canonicalJson, hashValue, sha256Hex } = require('./canonical');
|
||||
const capsule = require('./capsule');
|
||||
const envelope = require('./envelope');
|
||||
|
||||
const RECEIPT_SCHEMA = 'capsule-receipt/v1';
|
||||
|
||||
function digestFile(filePath) {
|
||||
return sha256Hex(fs.readFileSync(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a receipt and persist its verified projection in the capsule directory.
|
||||
* options: { artifact_path | artifact_digest, gate_receipt (object), signer(fn) }
|
||||
*/
|
||||
function buildReceipt(capsuleDir, options = {}) {
|
||||
if (options.artifact_digest !== undefined && options.artifact_digest !== null
|
||||
&& (typeof options.artifact_digest !== 'string' || !envelope.HASH_PATTERN.test(options.artifact_digest))) {
|
||||
throw new capsule.CapsuleError('receipt.schema_invalid', 'artifact_digest must be a SHA-256 digest or null');
|
||||
}
|
||||
const artifactDigest = options.artifact_digest
|
||||
|| (options.artifact_path ? digestFile(options.artifact_path) : null);
|
||||
const projection = capsule.writeProjection(capsuleDir);
|
||||
const receipt = {
|
||||
schema: RECEIPT_SCHEMA,
|
||||
envelope_schema: envelope.SCHEMA_VERSION,
|
||||
capsule_id: projection.capsule_id,
|
||||
run_id: projection.run_id,
|
||||
capsule_root: projection.root_hash,
|
||||
entry_count: projection.entry_count,
|
||||
journal_sha256: projection.journal_sha256,
|
||||
projection_hash: projection.projection_hash,
|
||||
artifact_digest: artifactDigest,
|
||||
gate_receipt_digest: options.gate_receipt ? hashValue(options.gate_receipt) : null,
|
||||
gate_verdict: options.gate_receipt ? options.gate_receipt.verdict || null : null,
|
||||
created_at: (options.clock ? options.clock() : new Date()).toISOString(),
|
||||
signature: null,
|
||||
};
|
||||
const receiptHash = hashValue(receipt);
|
||||
return {
|
||||
...receipt,
|
||||
receipt_hash: receiptHash,
|
||||
signature: typeof options.signer === 'function' ? options.signer(receiptHash) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function validReceiptSchema(receipt) {
|
||||
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)
|
||||
|| receipt.schema !== RECEIPT_SCHEMA || receipt.envelope_schema !== envelope.SCHEMA_VERSION
|
||||
|| !Number.isSafeInteger(receipt.entry_count) || receipt.entry_count < 0) return false;
|
||||
for (const field of ['run_id', 'capsule_id']) {
|
||||
if (typeof receipt[field] !== 'string' || !envelope.ID_PATTERN.test(receipt[field])) return false;
|
||||
}
|
||||
for (const field of ['capsule_root', 'journal_sha256', 'projection_hash', 'receipt_hash']) {
|
||||
if (typeof receipt[field] !== 'string' || !envelope.HASH_PATTERN.test(receipt[field])) return false;
|
||||
}
|
||||
for (const field of ['artifact_digest', 'gate_receipt_digest']) {
|
||||
if (receipt[field] !== null && (typeof receipt[field] !== 'string' || !envelope.HASH_PATTERN.test(receipt[field]))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Read and compare the supplied projection without writing or regenerating it. */
|
||||
function projectionMatches(dir, expected, receipt) {
|
||||
try {
|
||||
const bytes = fs.readFileSync(path.join(path.resolve(dir), capsule.PROJECTION_FILE));
|
||||
const raw = bytes.toString('utf8');
|
||||
if (!bytes.equals(Buffer.from(raw, 'utf8'))) return false;
|
||||
const stored = JSON.parse(raw);
|
||||
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return false;
|
||||
const { projection_hash: claimed, ...body } = stored;
|
||||
return hashValue(body) === claimed && claimed === receipt.projection_hash
|
||||
&& isDeepStrictEqual(stored, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a receipt against a capsule directory (or exported bundle).
|
||||
* Returns { ok, check, reason }. `check` names the first failing check:
|
||||
* schema, receipt_hash, signature, journal_present, journal_integrity,
|
||||
* truncation, stale_checkpoint, capsule_root, metadata, projection, artifact, gate_receipt.
|
||||
*/
|
||||
function verifyReceipt(receipt, capsuleDir, options = {}) {
|
||||
const fail = (check, reason) => ({ ok: false, check, reason });
|
||||
if (!validReceiptSchema(receipt)) {
|
||||
return fail('schema', 'receipt schema, count, identity or digest fields are invalid');
|
||||
}
|
||||
const { receipt_hash: claimedHash, signature, ...unsigned } = receipt;
|
||||
const recomputed = hashValue({ ...unsigned, signature: null });
|
||||
if (recomputed !== claimedHash) {
|
||||
return fail('receipt_hash', 'receipt content does not match receipt_hash');
|
||||
}
|
||||
if (typeof options.verifier === 'function') {
|
||||
if (!signature) {
|
||||
return fail('signature', 'receipt is unsigned but a verifier was supplied');
|
||||
}
|
||||
if (!options.verifier(claimedHash, signature)) {
|
||||
return fail('signature', 'signature does not verify for this receipt_hash');
|
||||
}
|
||||
}
|
||||
const journalPath = path.join(path.resolve(capsuleDir), capsule.JOURNAL_FILE);
|
||||
if (!fs.existsSync(journalPath)) {
|
||||
return fail('journal_present', 'journal.ndjson missing from capsule directory');
|
||||
}
|
||||
const state = capsule.readCapsule(capsuleDir);
|
||||
if (!state.ok) {
|
||||
const check = state.code.startsWith('capsule.metadata_') ? 'metadata' : 'journal_integrity';
|
||||
return fail(check, `${state.reason} (entry ${state.failed_at})`);
|
||||
}
|
||||
if (state.entries.length < receipt.entry_count) {
|
||||
return fail('truncation', `journal has ${state.entries.length} entries, receipt names ${receipt.entry_count}`);
|
||||
}
|
||||
const rootAtReceipt = receipt.entry_count === 0
|
||||
? envelope.GENESIS_HASH
|
||||
: state.entries[receipt.entry_count - 1].entry_hash;
|
||||
if (rootAtReceipt !== receipt.capsule_root) {
|
||||
return fail('capsule_root', 'journal prefix does not reproduce the receipt capsule_root');
|
||||
}
|
||||
if (state.entries.length > receipt.entry_count) {
|
||||
return fail('stale_checkpoint', `journal advanced to ${state.entries.length} entries after the receipt (prefix verified)`);
|
||||
}
|
||||
if (receipt.journal_sha256 !== state.journal_sha256) {
|
||||
return fail('journal_integrity', 'journal bytes differ from receipt journal_sha256');
|
||||
}
|
||||
if (receipt.run_id !== state.meta.run_id || receipt.capsule_id !== state.meta.capsule_id) {
|
||||
return fail('metadata', 'receipt identity differs from the verified capsule');
|
||||
}
|
||||
if (!projectionMatches(capsuleDir, state.projection, receipt)) {
|
||||
return fail('projection', 'projection is missing, unreadable, corrupt or differs from the verified capsule and receipt');
|
||||
}
|
||||
if (options.artifact_path) {
|
||||
let digest;
|
||||
try { digest = digestFile(options.artifact_path); } catch {
|
||||
return fail('artifact', 'artifact could not be read');
|
||||
}
|
||||
if (digest !== receipt.artifact_digest) {
|
||||
return fail('artifact', 'artifact digest does not match receipt');
|
||||
}
|
||||
} else if (options.artifact_digest && options.artifact_digest !== receipt.artifact_digest) {
|
||||
return fail('artifact', 'artifact digest does not match receipt');
|
||||
}
|
||||
if (options.gate_receipt && hashValue(options.gate_receipt) !== receipt.gate_receipt_digest) {
|
||||
return fail('gate_receipt', 'gate receipt digest does not match receipt');
|
||||
}
|
||||
return { ok: true, check: null, reason: 'receipt verified' };
|
||||
}
|
||||
|
||||
function writeReceipt(receipt, filePath) {
|
||||
fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true });
|
||||
fs.writeFileSync(filePath, canonicalJson(receipt) + '\n', 'utf8');
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RECEIPT_SCHEMA,
|
||||
buildReceipt,
|
||||
verifyReceipt,
|
||||
writeReceipt,
|
||||
digestFile,
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Replay-safe tool calls: declared determinism and effect class per tool,
|
||||
* content-addressed fixtures, and fail-closed replay.
|
||||
*
|
||||
* Framework 4 of the eval-harness set (replay-safe branch and diff, first
|
||||
* slices). Modes:
|
||||
* record call the live implementation, store the response under the
|
||||
* canonical hash of (tool, args);
|
||||
* replay never call the live implementation; return the stored response
|
||||
* or fail with tool.fixture_missing. Tools declared SE3 or above
|
||||
* fail with tool.effect_forbidden regardless of fixtures.
|
||||
*
|
||||
* Money-touching or counterparty-facing tools never get permissive replay.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { canonicalJson, hashValue } = require('./canonical');
|
||||
const envelope = require('./envelope');
|
||||
|
||||
class ReplayError extends Error {
|
||||
constructor(code, message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'ReplayError';
|
||||
this.code = code;
|
||||
Object.assign(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
class FixtureStore {
|
||||
constructor(dir) {
|
||||
this.dir = path.resolve(dir);
|
||||
fs.mkdirSync(this.dir, { recursive: true });
|
||||
}
|
||||
|
||||
key(tool, args) {
|
||||
return hashValue({ tool, args });
|
||||
}
|
||||
|
||||
pathFor(key) {
|
||||
return path.join(this.dir, `${key}.json`);
|
||||
}
|
||||
|
||||
has(tool, args) {
|
||||
return fs.existsSync(this.pathFor(this.key(tool, args)));
|
||||
}
|
||||
|
||||
put(tool, args, response) {
|
||||
const key = this.key(tool, args);
|
||||
const record = {
|
||||
key,
|
||||
tool,
|
||||
args_hash: hashValue(args),
|
||||
response_hash: hashValue(response),
|
||||
response,
|
||||
};
|
||||
fs.writeFileSync(this.pathFor(key), canonicalJson(record) + '\n', 'utf8');
|
||||
return record;
|
||||
}
|
||||
|
||||
get(tool, args) {
|
||||
const key = this.key(tool, args);
|
||||
const filePath = this.pathFor(key);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new ReplayError('tool.fixture_missing', `no fixture for ${tool} (${key.slice(0, 16)})`, { tool, key });
|
||||
}
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (_error) {
|
||||
throw new ReplayError('tool.fixture_corrupt', `fixture ${key.slice(0, 16)} is not valid JSON`, { tool, key });
|
||||
}
|
||||
if (record.tool !== tool || record.args_hash !== hashValue(args)) {
|
||||
throw new ReplayError('tool.fixture_mismatch', `fixture ${key.slice(0, 16)} was recorded for different arguments`, { tool, key });
|
||||
}
|
||||
if (record.response_hash !== hashValue(record.response)) {
|
||||
throw new ReplayError('tool.fixture_mismatch', `fixture ${key.slice(0, 16)} response hash does not match its content`, { tool, key });
|
||||
}
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* tools: { name: { effect_class, determinism: 'deterministic'|'nondeterministic', impl(args) } }
|
||||
* options: { mode: 'record'|'replay', store: FixtureStore, maxEffectClass: 'SE2', onCall(entry) }
|
||||
*/
|
||||
function createReplayer(tools, options = {}) {
|
||||
const mode = options.mode || 'replay';
|
||||
const store = options.store;
|
||||
const maxRank = envelope.effectRank(options.maxEffectClass || 'SE2');
|
||||
if (!['record', 'replay'].includes(mode)) {
|
||||
throw new ReplayError('replay.bad_mode', `mode must be record or replay, got ${mode}`);
|
||||
}
|
||||
if (!store) {
|
||||
throw new ReplayError('replay.no_store', 'a FixtureStore is required');
|
||||
}
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
if (!envelope.EFFECT_CLASSES.includes(tool.effect_class)) {
|
||||
throw new ReplayError('replay.bad_declaration', `tool ${name} must declare an effect_class`);
|
||||
}
|
||||
if (!['deterministic', 'nondeterministic'].includes(tool.determinism)) {
|
||||
throw new ReplayError('replay.bad_declaration', `tool ${name} must declare determinism`);
|
||||
}
|
||||
}
|
||||
|
||||
const calls = [];
|
||||
const emit = (entry) => {
|
||||
calls.push(entry);
|
||||
if (typeof options.onCall === 'function') {
|
||||
options.onCall(entry);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
calls,
|
||||
call(name, args = {}) {
|
||||
const tool = tools[name];
|
||||
if (!tool) {
|
||||
throw new ReplayError('tool.unknown', `tool ${name} is not declared`);
|
||||
}
|
||||
const rank = envelope.effectRank(tool.effect_class);
|
||||
if (rank > maxRank) {
|
||||
emit({ tool: name, mode, status: 'refused', code: 'tool.effect_forbidden' });
|
||||
throw new ReplayError('tool.effect_forbidden', `tool ${name} is ${tool.effect_class}, above the allowed ${options.maxEffectClass || 'SE2'}`, { tool: name });
|
||||
}
|
||||
if (mode === 'replay') {
|
||||
if (rank >= envelope.effectRank('SE3')) {
|
||||
emit({ tool: name, mode, status: 'refused', code: 'tool.effect_forbidden' });
|
||||
throw new ReplayError('tool.effect_forbidden', `tool ${name} (${tool.effect_class}) can never be replayed`, { tool: name });
|
||||
}
|
||||
const record = store.get(name, args);
|
||||
emit({ tool: name, mode, status: 'replayed', fixture_key: record.key, args_hash: record.args_hash, response_hash: record.response_hash });
|
||||
return record.response;
|
||||
}
|
||||
const response = tool.impl(args);
|
||||
const record = store.put(name, args, response);
|
||||
emit({ tool: name, mode, status: 'recorded', fixture_key: record.key, args_hash: record.args_hash, response_hash: record.response_hash });
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ReplayError,
|
||||
FixtureStore,
|
||||
createReplayer,
|
||||
EFFECT_FENCE_PRELOAD: path.join(__dirname, 'effect-fence.js'),
|
||||
};
|
||||
Reference in New Issue
Block a user