@entrorove/skill 0.1.6 → 0.1.22
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/AGREEMENT.md +4 -2
- package/CAPABILITY-MAP.md +18 -0
- package/CHANGELOG.md +80 -0
- package/SKILL.md +13 -3
- package/cli.mjs +34 -43
- package/local-approved-plan.mjs +81 -0
- package/local-budget-renewal.mjs +37 -0
- package/local-cli.mjs +58 -0
- package/local-execution.mjs +482 -0
- package/local-formula.mjs +130 -0
- package/local-integrity.mjs +118 -0
- package/local-ledger.mjs +58 -0
- package/local-probe.mjs +13 -0
- package/local-state-lock.mjs +84 -0
- package/local-task-routing.mjs +36 -0
- package/manifest.json +8 -2
- package/package.json +14 -2
- package/release-state.json +20 -8
- package/release.mjs +16 -1
- package/releases.json +96 -0
- package/remote-client.mjs +91 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { createHash, createPublicKey, generateKeyPairSync, sign, verify, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { formulaFinalGateFile, formulaFinalGate, evaluateFormula, evaluateFormulaGraph, Decimal } from './local-formula.mjs';
|
|
7
|
+
import { LOCAL_LEDGER_OPERATIONS, LOCAL_LEDGER_SCHEMAS, branchCreate, branchList, branchSwitch, diffReport, preflight, ledgerQuery, chainInit, chainResume, chainStatus, chainAnswer, appendLedgerRecord } from './local-ledger.mjs';
|
|
8
|
+
import { verifyArchguardPreflight, verifyReceipt, verifySnapshotManifest, verifyTrustedEvidence } from './local-integrity.mjs';
|
|
9
|
+
import { RUNNER_CAPABILITIES, runApprovedPlan } from './local-approved-plan.mjs';
|
|
10
|
+
import { LOCAL_RENEWAL_OPERATIONS, LOCAL_RENEWAL_SCHEMAS, budgetAutoRenewRequest, budgetAutoRenew, budgetAutoRenewStop, consumeBudgetRenewal } from './local-budget-renewal.mjs';
|
|
11
|
+
import { LOCAL_PROBE_OPERATIONS, LOCAL_PROBE_SCHEMAS, probe, reassess } from './local-probe.mjs';
|
|
12
|
+
import { LOCAL_TASK_ROUTING_OPERATIONS, LOCAL_TASK_ROUTING_SCHEMAS, taskDescribe, messageRoute, messageAccept, taskResume } from './local-task-routing.mjs';
|
|
13
|
+
import { withStateLock } from './local-state-lock.mjs';
|
|
14
|
+
export const LOCAL_SCHEMA = 'entrorove.local-execution/1.0';
|
|
15
|
+
export const SNAPSHOT_SCHEMA = 'entrorove.snapshot/1.0';
|
|
16
|
+
export const RECEIPT_SCHEMA = 'entrorove.snapshot-receipt/1.0';
|
|
17
|
+
export const PASS_SCHEMA = 'entrorove.mutation-pass/1.0';
|
|
18
|
+
export const EVIDENCE_SCHEMA = 'entrorove.test-evidence/2.0';
|
|
19
|
+
export const EVIDENCE_RECEIPT_SCHEMA = 'entrorove.runner-receipt/1.0';
|
|
20
|
+
export const BUDGET_SCHEMA = 'entrorove.read-budget/1.0';
|
|
21
|
+
export const COORDINATION_SCHEMA = 'entrorove.coordination/1.0';
|
|
22
|
+
export const READ_BUDGETS = Object.freeze({
|
|
23
|
+
lock: Object.freeze({ maxFiles: 3, maxTokenEstimate: 12_000, maxDurationMs: 120_000 }),
|
|
24
|
+
probe: Object.freeze({ maxFiles: 10, maxTokenEstimate: 30_000, maxDurationMs: 480_000 }),
|
|
25
|
+
swarm: Object.freeze({ maxFiles: 30, maxTokenEstimate: 100_000, maxDurationMs: 3_600_000 }),
|
|
26
|
+
});
|
|
27
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
28
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
29
|
+
const MISSING_SHA256 = sha256('entrorove.missing-file/1.0');
|
|
30
|
+
export function sha256(value) {
|
|
31
|
+
return createHash('sha256').update(value).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
function canonical(value) {
|
|
34
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
35
|
+
if (value && typeof value === 'object') {
|
|
36
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
37
|
+
}
|
|
38
|
+
return JSON.stringify(value);
|
|
39
|
+
}
|
|
40
|
+
function fail(code, message) {
|
|
41
|
+
const error = new Error(message);
|
|
42
|
+
error.code = code;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
function id(value, field) {
|
|
46
|
+
if (typeof value !== 'string' || !ID.test(value)) fail('ENTROROVE_ID_INVALID', `${field} must be a safe identifier`);
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
function safePath(value, field = 'path') {
|
|
50
|
+
if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value)
|
|
51
|
+
|| /^[A-Za-z]:/.test(value) || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value)
|
|
52
|
+
|| value.split('/').some(part => !part || part === '.' || part === '..')) {
|
|
53
|
+
fail('ENTROROVE_PATH_UNSAFE', `${field} is unsafe`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function homeRoot(home = os.homedir()) {
|
|
58
|
+
if (typeof home !== 'string' || !path.isAbsolute(home)) fail('ENTROROVE_HOME_INVALID', 'home must be an absolute path');
|
|
59
|
+
return path.join(home, '.entrorove');
|
|
60
|
+
}
|
|
61
|
+
async function privateDir(directory) {
|
|
62
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
63
|
+
const stat = await fs.lstat(directory);
|
|
64
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'Entrorove state directory must be a real directory');
|
|
65
|
+
if (process.platform !== 'win32') await fs.chmod(directory, 0o700);
|
|
66
|
+
return directory;
|
|
67
|
+
}
|
|
68
|
+
async function atomicJson(file, value) {
|
|
69
|
+
await privateDir(path.dirname(file));
|
|
70
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
71
|
+
await fs.writeFile(temporary, `${canonical(value)}\n`, { flag: 'wx', mode: 0o600 });
|
|
72
|
+
await fs.rename(temporary, file);
|
|
73
|
+
if (process.platform !== 'win32') await fs.chmod(file, 0o600);
|
|
74
|
+
}
|
|
75
|
+
async function readJson(file) {
|
|
76
|
+
await privateDir(path.dirname(file));
|
|
77
|
+
const stat = await fs.lstat(file);
|
|
78
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'State file must be a regular file');
|
|
79
|
+
const value = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
80
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('ENTROROVE_STATE_INVALID', 'State file must contain an object');
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
export async function repositoryRoot(value) {
|
|
84
|
+
if (typeof value !== 'string' || !value.trim() || !path.isAbsolute(path.resolve(value))) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot is required');
|
|
85
|
+
const resolved = path.resolve(value);
|
|
86
|
+
const stat = await fs.lstat(resolved);
|
|
87
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory');
|
|
88
|
+
return fs.realpath(resolved);
|
|
89
|
+
}
|
|
90
|
+
function inside(root, target, field) {
|
|
91
|
+
const relative = path.relative(root, target);
|
|
92
|
+
if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) return;
|
|
93
|
+
fail('ENTROROVE_PATH_ESCAPE', `${field} escapes repositoryRoot`);
|
|
94
|
+
}
|
|
95
|
+
async function projectFile(root, relative, allowMissing = false) {
|
|
96
|
+
const normalized = safePath(relative);
|
|
97
|
+
const target = path.resolve(root, ...normalized.split('/'));
|
|
98
|
+
inside(root, target, normalized);
|
|
99
|
+
try {
|
|
100
|
+
const stat = await fs.lstat(target);
|
|
101
|
+
if (stat.isSymbolicLink() || !stat.isFile()) fail('ENTROROVE_FILE_INVALID', `${normalized} must be a regular file`);
|
|
102
|
+
inside(root, await fs.realpath(target), normalized);
|
|
103
|
+
return { path: normalized, target, stat, exists: true };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!allowMissing || error.code !== 'ENOENT') throw error;
|
|
106
|
+
let parent = path.dirname(target);
|
|
107
|
+
while (parent !== root) {
|
|
108
|
+
try { inside(root, await fs.realpath(parent), normalized); break; } catch (cause) {
|
|
109
|
+
if (cause.code !== 'ENOENT') throw cause;
|
|
110
|
+
parent = path.dirname(parent);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { path: normalized, target, stat: null, exists: false };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function repoKey(root) { return sha256(root).slice(0, 32); }
|
|
117
|
+
function statePath(home, kind, root, idValue, suffix) {
|
|
118
|
+
return path.join(homeRoot(home), kind, repoKey(root), idValue, suffix);
|
|
119
|
+
}
|
|
120
|
+
async function audit(home, root, event, data = {}) {
|
|
121
|
+
const file = path.join(homeRoot(home), 'invocations.jsonl');
|
|
122
|
+
await privateDir(path.dirname(file));
|
|
123
|
+
const record = { schemaVersion: LOCAL_SCHEMA, at: new Date().toISOString(), event, repositoryDigest: repoKey(root), ...data };
|
|
124
|
+
await fs.appendFile(file, `${canonical(record)}\n`, { mode: 0o600 });
|
|
125
|
+
if (process.platform !== 'win32') await fs.chmod(file, 0o600);
|
|
126
|
+
}
|
|
127
|
+
function normalizePaths(paths) {
|
|
128
|
+
if (!Array.isArray(paths) || paths.length === 0) fail('ENTROROVE_PATHS_REQUIRED', 'paths must be a non-empty array');
|
|
129
|
+
const normalized = [...new Set(paths.map(item => safePath(item)))].sort();
|
|
130
|
+
if (normalized.length !== paths.length) fail('ENTROROVE_PATHS_DUPLICATE', 'paths must be unique');
|
|
131
|
+
return normalized;
|
|
132
|
+
}
|
|
133
|
+
async function keyPair(home, root) {
|
|
134
|
+
const directory = await privateDir(path.join(homeRoot(home), 'keys'));
|
|
135
|
+
const stem = repoKey(root);
|
|
136
|
+
const privatePath = path.join(directory, `${stem}.private.pem`);
|
|
137
|
+
const publicPath = path.join(directory, `${stem}.public.pem`);
|
|
138
|
+
try {
|
|
139
|
+
const [privateStat, publicStat] = await Promise.all([fs.lstat(privatePath), fs.lstat(publicPath)]);
|
|
140
|
+
if (privateStat.isSymbolicLink() || publicStat.isSymbolicLink() || !privateStat.isFile() || !publicStat.isFile()) fail('ENTROROVE_KEY_INVALID', 'local signing keys must be regular files');
|
|
141
|
+
const [privateKey, publicKey] = await Promise.all([fs.readFile(privatePath, 'utf8'), fs.readFile(publicPath, 'utf8')]);
|
|
142
|
+
const publicDer = createPublicKey(publicKey).export({ format: 'der', type: 'spki' });
|
|
143
|
+
return { privateKey, publicKey, publicDer, keyId: sha256(publicDer) };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error.code !== 'ENOENT') throw error;
|
|
146
|
+
const pair = generateKeyPairSync('ed25519', { privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
|
|
147
|
+
await fs.writeFile(privatePath, pair.privateKey, { flag: 'wx', mode: 0o600 });
|
|
148
|
+
await fs.writeFile(publicPath, pair.publicKey, { flag: 'wx', mode: 0o600 });
|
|
149
|
+
const publicDer = createPublicKey(pair.publicKey).export({ format: 'der', type: 'spki' });
|
|
150
|
+
return { privateKey: pair.privateKey, publicKey: pair.publicKey, publicDer, keyId: sha256(publicDer) };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function fileDigest(file) { return file.exists ? sha256(await fs.readFile(file.target)) : MISSING_SHA256; }
|
|
154
|
+
export async function snapshotPlan(input) {
|
|
155
|
+
const root = await repositoryRoot(input.repositoryRoot);
|
|
156
|
+
const flowId = id(input.flowId, 'flowId'); const stepId = id(input.stepId, 'stepId');
|
|
157
|
+
const paths = normalizePaths(input.paths);
|
|
158
|
+
const snapshotId = id(input.snapshotId ?? `snap-${randomUUID()}`, 'snapshotId');
|
|
159
|
+
const directory = path.dirname(statePath(input.home, 'snapshots', root, flowId, 'manifest.json'));
|
|
160
|
+
const snapshotRoot = path.join(directory, snapshotId);
|
|
161
|
+
await privateDir(snapshotRoot);
|
|
162
|
+
const files = [];
|
|
163
|
+
for (const relative of paths) {
|
|
164
|
+
const source = await projectFile(root, relative, true);
|
|
165
|
+
const digest = await fileDigest(source);
|
|
166
|
+
const target = path.join(snapshotRoot, relative);
|
|
167
|
+
if (source.exists) { await privateDir(path.dirname(target)); await fs.copyFile(source.target, target, constants.COPYFILE_EXCL); }
|
|
168
|
+
files.push({ path: relative, exists: source.exists, sha256: digest, mode: source.exists ? source.stat.mode & 0o777 : null });
|
|
169
|
+
}
|
|
170
|
+
const contractDigest = input.contractDigest ?? null;
|
|
171
|
+
if (contractDigest !== null && !SHA256.test(contractDigest)) fail('ENTROROVE_CONTRACT_DIGEST', 'contractDigest must be a lowercase SHA-256 digest');
|
|
172
|
+
const manifest = { schemaVersion: SNAPSHOT_SCHEMA, snapshotId, flowId, stepId, repositoryDigest: repoKey(root), paths, files, contractDigest,
|
|
173
|
+
createdAt: new Date().toISOString(), snapshotDigest: sha256(canonical({ snapshotId, flowId, stepId, paths, files, contractDigest })) };
|
|
174
|
+
const manifestPath = path.join(snapshotRoot, 'manifest.json');
|
|
175
|
+
await atomicJson(manifestPath, manifest);
|
|
176
|
+
await audit(input.home, root, 'snapshot-plan', { flowId, stepId, snapshotId });
|
|
177
|
+
return { schemaVersion: LOCAL_SCHEMA, snapshotId, snapshotRoot, manifestPath, paths, contractDigest, snapshotDigest: manifest.snapshotDigest, forbidGitBranch: true, forbidWorktree: true };
|
|
178
|
+
}
|
|
179
|
+
export async function snapshotVerify(input) {
|
|
180
|
+
const root = await repositoryRoot(input.repositoryRoot); const flowId = id(input.flowId, 'flowId'); const stepId = id(input.stepId, 'stepId');
|
|
181
|
+
const snapshotId = id(input.snapshotId, 'snapshotId');
|
|
182
|
+
const manifestPath = path.join(path.dirname(statePath(input.home, 'snapshots', root, flowId, 'manifest.json')), snapshotId, 'manifest.json');
|
|
183
|
+
const manifest = await readJson(manifestPath);
|
|
184
|
+
const integrity = verifySnapshotManifest(manifest, { snapshotId, flowId, stepId, repositoryDigest: repoKey(root) });
|
|
185
|
+
if (!integrity.verified) fail('ENTROROVE_SNAPSHOT_CORRUPT', integrity.reason ?? 'snapshot manifest is invalid');
|
|
186
|
+
const files = [];
|
|
187
|
+
for (const expected of manifest.files) {
|
|
188
|
+
const source = await projectFile(root, expected.path, true);
|
|
189
|
+
const actual = await fileDigest(source);
|
|
190
|
+
if (actual !== expected.sha256) fail('ENTROROVE_SNAPSHOT_CHANGED', `${expected.path} changed after snapshot`);
|
|
191
|
+
const snapshotTarget = path.join(path.dirname(manifestPath), expected.path);
|
|
192
|
+
const snapshotDigest = expected.exists ? sha256(await fs.readFile(snapshotTarget)) : MISSING_SHA256;
|
|
193
|
+
if (snapshotDigest !== expected.sha256) fail('ENTROROVE_SNAPSHOT_CORRUPT', `${expected.path} snapshot copy is invalid`);
|
|
194
|
+
files.push({ path: expected.path, sourceHash: actual, snapshotHash: expected.sha256 });
|
|
195
|
+
}
|
|
196
|
+
const core = { schemaVersion: RECEIPT_SCHEMA, snapshotId, flowId, stepId, repositoryDigest: repoKey(root), snapshotDigest: manifest.snapshotDigest, contractDigest: manifest.contractDigest ?? null, paths: manifest.paths, fileManifestDigest: sha256(canonical(files)) };
|
|
197
|
+
const receipt = { ...core, receiptDigest: sha256(canonical(core)) };
|
|
198
|
+
await audit(input.home, root, 'snapshot-verify', { flowId, stepId, snapshotId, receiptDigest: receipt.receiptDigest });
|
|
199
|
+
return { schemaVersion: LOCAL_SCHEMA, verified: true, receipt };
|
|
200
|
+
}
|
|
201
|
+
async function loadReceipt(input) {
|
|
202
|
+
const receipt = input.receipt;
|
|
203
|
+
if (!receipt || receipt.schemaVersion !== RECEIPT_SCHEMA || !SHA256.test(receipt.snapshotDigest) || !SHA256.test(receipt.receiptDigest)
|
|
204
|
+
|| !Array.isArray(receipt.paths) || receipt.receiptDigest !== sha256(canonical({ schemaVersion: receipt.schemaVersion, snapshotId: receipt.snapshotId,
|
|
205
|
+
flowId: receipt.flowId, stepId: receipt.stepId, repositoryDigest: receipt.repositoryDigest, snapshotDigest: receipt.snapshotDigest,
|
|
206
|
+
contractDigest: receipt.contractDigest ?? null, paths: receipt.paths, fileManifestDigest: receipt.fileManifestDigest }))) fail('ENTROROVE_RECEIPT_INVALID', 'snapshot receipt is invalid');
|
|
207
|
+
return receipt;
|
|
208
|
+
}
|
|
209
|
+
async function snapshotAuthority(input, root, receipt) {
|
|
210
|
+
const manifestPath = path.join(path.dirname(statePath(input.home, 'snapshots', root, receipt.flowId, 'manifest.json')), receipt.snapshotId, 'manifest.json'); const manifest = await readJson(manifestPath);
|
|
211
|
+
const check = verifySnapshotManifest(manifest, { snapshotId: receipt.snapshotId, flowId: receipt.flowId, stepId: receipt.stepId, repositoryDigest: repoKey(root) });
|
|
212
|
+
if (!check.verified || manifest.snapshotDigest !== receipt.snapshotDigest || canonical(manifest.paths) !== canonical(receipt.paths) || (manifest.contractDigest ?? null) !== (receipt.contractDigest ?? null)) fail('ENTROROVE_SNAPSHOT_BINDING', check.reason ?? 'snapshot receipt does not match its manifest');
|
|
213
|
+
return { manifest, manifestPath };
|
|
214
|
+
}
|
|
215
|
+
export async function mutateGate(input) {
|
|
216
|
+
if (input.accepted !== true) return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', allowed: false, code: 'ENTROROVE_MUTATE_NOT_ACCEPTED' };
|
|
217
|
+
id(input.flowId, 'flowId'); id(input.stepId, 'stepId');
|
|
218
|
+
const receipt = await loadReceipt(input); const root = await repositoryRoot(input.repositoryRoot);
|
|
219
|
+
if (receipt.flowId !== input.flowId || receipt.stepId !== input.stepId || receipt.repositoryDigest !== repoKey(root)) return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', allowed: false, code: 'ENTROROVE_RECEIPT_SCOPE' };
|
|
220
|
+
await snapshotAuthority(input, root, receipt);
|
|
221
|
+
if (input.paths && canonical(normalizePaths(input.paths)) !== canonical(normalizePaths(receipt.paths))) return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', allowed: false, code: 'ENTROROVE_RECEIPT_PATHS' };
|
|
222
|
+
if (input.coordinationRequired === true) {
|
|
223
|
+
try { await verifyCoordinationLease({ ...input, paths: receipt.paths }); } catch (error) { return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', allowed: false, code: error.code ?? 'ENTROROVE_LEASE_INVALID' }; }
|
|
224
|
+
}
|
|
225
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'verified', allowed: true, snapshotId: receipt.snapshotId, receiptDigest: receipt.receiptDigest, paths: receipt.paths, coordinationRequired: input.coordinationRequired === true };
|
|
226
|
+
}
|
|
227
|
+
export async function issueMutationPass(input) {
|
|
228
|
+
const gate = await mutateGate(input); if (!gate.allowed) return gate;
|
|
229
|
+
const root = await repositoryRoot(input.repositoryRoot); const authority = await keyPair(input.home, root); const now = Date.now();
|
|
230
|
+
const ttlSeconds = input.ttlSeconds ?? 300;
|
|
231
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 300) fail('ENTROROVE_PASS_TTL_INVALID', 'ttlSeconds must be 1..300');
|
|
232
|
+
const pass = { schemaVersion: PASS_SCHEMA, passId: `pass-${randomUUID()}`, flowId: input.flowId, stepId: input.stepId,
|
|
233
|
+
repositoryDigest: repoKey(root), snapshotId: gate.snapshotId, snapshotReceiptDigest: gate.receiptDigest, snapshotReceipt: input.receipt, paths: gate.paths, coordinationRequired: gate.coordinationRequired,
|
|
234
|
+
issuedAt: new Date(now).toISOString(), expiresAt: new Date(now + ttlSeconds * 1000).toISOString(), keyId: authority.keyId, nonce: randomUUID() };
|
|
235
|
+
const signature = sign(null, Buffer.from(canonical(pass)), authority.privateKey).toString('base64url');
|
|
236
|
+
const signedPass = { ...pass, signature }; const file = statePath(input.home, 'gates', root, input.flowId, `${pass.passId}.json`);
|
|
237
|
+
await atomicJson(file, signedPass); await audit(input.home, root, 'mutation-pass-issued', { flowId: input.flowId, stepId: input.stepId, passId: pass.passId });
|
|
238
|
+
return { schemaVersion: LOCAL_SCHEMA, gatePassPath: file, pass: signedPass };
|
|
239
|
+
}
|
|
240
|
+
async function verifyPass(input) {
|
|
241
|
+
id(input.flowId, 'flowId'); id(input.stepId, 'stepId');
|
|
242
|
+
const root = await repositoryRoot(input.repositoryRoot); const gateDirectory = path.dirname(statePath(input.home, 'gates', root, input.flowId, 'pass.json'));
|
|
243
|
+
const gatePath = path.resolve(input.gatePassPath ?? ''); inside(gateDirectory, gatePath, 'gatePassPath');
|
|
244
|
+
const pass = await readJson(gatePath); const authority = await keyPair(input.home, root);
|
|
245
|
+
const target = safePath(input.targetPath, 'targetPath'); const paths = Array.isArray(pass.paths) ? pass.paths : [];
|
|
246
|
+
const valid = pass.schemaVersion === PASS_SCHEMA && pass.repositoryDigest === repoKey(root) && pass.flowId === input.flowId && pass.stepId === input.stepId
|
|
247
|
+
&& paths.includes(target) && pass.keyId === authority.keyId && typeof pass.signature === 'string'
|
|
248
|
+
&& verify(null, Buffer.from(canonical(Object.fromEntries(Object.entries(pass).filter(([key]) => key !== 'signature')))), authority.publicKey, Buffer.from(pass.signature, 'base64url'))
|
|
249
|
+
&& Date.parse(pass.issuedAt) <= Date.now() && Date.parse(pass.expiresAt) > Date.now();
|
|
250
|
+
if (!valid) fail('ENTROROVE_PASS_INVALID', 'mutation pass is forged, expired, or outside its scope');
|
|
251
|
+
if (!pass.snapshotReceipt || pass.snapshotReceipt.receiptDigest !== pass.snapshotReceiptDigest) fail('ENTROROVE_PASS_INVALID', 'mutation pass has no bound snapshot receipt');
|
|
252
|
+
const receipt = await loadReceipt({ receipt: pass.snapshotReceipt });
|
|
253
|
+
await snapshotAuthority(input, root, receipt);
|
|
254
|
+
if (receipt.flowId !== pass.flowId || receipt.stepId !== pass.stepId || receipt.repositoryDigest !== pass.repositoryDigest || receipt.snapshotId !== pass.snapshotId) fail('ENTROROVE_PASS_INVALID', 'mutation pass snapshot scope is invalid');
|
|
255
|
+
if (pass.coordinationRequired === true) await verifyCoordinationLease({ ...input, paths: [target] });
|
|
256
|
+
return { root, pass, target };
|
|
257
|
+
}
|
|
258
|
+
export async function verifyMutationPass(input) { const verified = await verifyPass(input); return { schemaVersion: LOCAL_SCHEMA, verified: true, pass: verified.pass, targetPath: verified.target }; }
|
|
259
|
+
export async function guardedWrite(input) {
|
|
260
|
+
const verified = await verifyPass(input);
|
|
261
|
+
const lockFile = path.join(homeRoot(input.home), 'write-locks', repoKey(verified.root), `${sha256(verified.target)}.lock`);
|
|
262
|
+
return withStateLock(lockFile, async () => {
|
|
263
|
+
const target = await projectFile(verified.root, verified.target, true);
|
|
264
|
+
const { manifest } = await snapshotAuthority(input, verified.root, verified.pass.snapshotReceipt); const baseline = manifest.files.find(item => item.path === verified.target);
|
|
265
|
+
if (!baseline) fail('ENTROROVE_WRITE_SCOPE', 'target is absent from the snapshot manifest');
|
|
266
|
+
if (await fileDigest(target) !== baseline.sha256) fail('ENTROROVE_WRITE_BASELINE', 'target changed after the snapshot');
|
|
267
|
+
const expected = input.expectedSha256 ?? null;
|
|
268
|
+
if (expected && (!SHA256.test(expected) || await fileDigest(target) !== expected)) fail('ENTROROVE_WRITE_BASELINE', 'target does not match the expected snapshot hash');
|
|
269
|
+
const content = typeof input.content === 'string' ? input.content : JSON.stringify(input.content);
|
|
270
|
+
if (typeof content !== 'string') fail('ENTROROVE_CONTENT_INVALID', 'content must be text or JSON');
|
|
271
|
+
await privateDir(path.dirname(target.target)); const temporary = path.join(path.dirname(target.target), `.${path.basename(target.target)}.${randomUUID()}.tmp`);
|
|
272
|
+
await fs.writeFile(temporary, content, { flag: 'wx', mode: target.exists ? target.stat.mode & 0o777 : 0o600 }); await fs.rename(temporary, target.target);
|
|
273
|
+
await audit(input.home, verified.root, 'guarded-write', { flowId: input.flowId, stepId: input.stepId, targetPath: verified.target });
|
|
274
|
+
await appendLedgerRecord({ home: input.home, repositoryRoot: verified.root }, 'guarded-write', { flowId: input.flowId, stepId: input.stepId, targetPath: verified.target, sha256: sha256(content) });
|
|
275
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'verified', written: true, targetPath: verified.target, sha256: sha256(content) };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function enforced(context) {
|
|
279
|
+
if (!context || typeof context !== 'object' || Array.isArray(context)) fail('ENTROROVE_CONTEXT_REQUIRED', 'executionContext is required');
|
|
280
|
+
if (Object.keys(context).length === 1 && context.executionIsolation === 'local') return false;
|
|
281
|
+
if (Object.keys(context).length !== 2 || !['direct', 'sandbox'].includes(context.executionIsolation) || typeof context.cloudSandboxEnabled !== 'boolean') fail('ENTROROVE_CONTEXT_INVALID', 'executionContext is invalid');
|
|
282
|
+
if (context.executionIsolation === 'sandbox' && context.cloudSandboxEnabled !== true) fail('ENTROROVE_CONTEXT_INVALID', 'sandbox requires cloudSandboxEnabled=true');
|
|
283
|
+
return context.executionIsolation === 'sandbox';
|
|
284
|
+
}
|
|
285
|
+
async function budgetFile(input) { const root = await repositoryRoot(input.repositoryRoot); const flowId = id(input.flowId, 'flowId'); return { root, file: statePath(input.home, 'budgets', root, flowId, 'budget.json') }; }
|
|
286
|
+
function budgetView(state, now = Date.now()) {
|
|
287
|
+
const elapsedMs = state.enforced ? state.elapsedMs + now - Date.parse(state.startedAt) : state.elapsedMs;
|
|
288
|
+
return { ...state, elapsedMs, remainingFiles: state.enforced ? Math.max(0, state.maxFiles - state.files.length) : null,
|
|
289
|
+
remainingTokenEstimate: state.enforced && state.maxTokenEstimate !== null ? Math.max(0, state.maxTokenEstimate - state.tokenEstimate) : null,
|
|
290
|
+
remainingDurationMs: state.enforced ? Math.max(0, state.maxDurationMs - elapsedMs) : null,
|
|
291
|
+
enforcement: state.enforced ? 'cloud-sandbox' : 'continuous' };
|
|
292
|
+
}
|
|
293
|
+
export async function budgetInit(input) {
|
|
294
|
+
const mode = input.mode; if (!READ_BUDGETS[mode]) fail('ENTROROVE_MODE_INVALID', 'mode must be lock, probe, or swarm');
|
|
295
|
+
const { root, file } = await budgetFile(input); const limits = READ_BUDGETS[mode]; const isEnforced = enforced(input.executionContext);
|
|
296
|
+
return withStateLock(file, async () => {
|
|
297
|
+
try { const existing = await readJson(file); if (existing.schemaVersion !== BUDGET_SCHEMA || existing.flowId !== input.flowId || existing.repositoryDigest !== repoKey(root) || existing.mode !== mode || canonical(existing.executionContext) !== canonical(input.executionContext)) fail('ENTROROVE_BUDGET_REINITIALIZE', 'budget is already initialized with a different scope'); return budgetView(existing); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
298
|
+
const state = { schemaVersion: BUDGET_SCHEMA, flowId: input.flowId, repositoryDigest: repoKey(root), mode, executionContext: input.executionContext,
|
|
299
|
+
enforced: isEnforced, startedAt: new Date().toISOString(), elapsedMs: 0, ...limits, files: [], readCalls: 0, tokenEstimate: 0, extensions: [] };
|
|
300
|
+
await atomicJson(file, state); await audit(input.home, root, 'budget-init', { flowId: input.flowId, mode }); return budgetView(state);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
export async function budgetContext(input) {
|
|
304
|
+
const { root, file } = await budgetFile(input);
|
|
305
|
+
return withStateLock(file, async () => {
|
|
306
|
+
const state = await readJson(file); const isEnforced = enforced(input.executionContext); if (state.enforced && !isEnforced) fail('ENTROROVE_BUDGET_DOWNGRADE', 'an enforced sandbox budget cannot be downgraded');
|
|
307
|
+
const updated = { ...state, executionContext: input.executionContext, enforced: isEnforced }; await atomicJson(file, updated); return budgetView(updated);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
export async function budgetStatus(input) { const { file } = await budgetFile(input); return budgetView(await readJson(file)); }
|
|
311
|
+
export async function budgetRead(input) {
|
|
312
|
+
const { root, file } = await budgetFile(input);
|
|
313
|
+
return withStateLock(file, async () => {
|
|
314
|
+
const relative = safePath(input.path); const originalState = await readJson(file); const state = await consumeBudgetRenewal({ file, state: originalState }); const before = budgetView(state);
|
|
315
|
+
const source = await projectFile(root, relative); const bytes = source.stat.size; const tokens = Math.ceil(bytes / 4); const fresh = !state.files.includes(relative);
|
|
316
|
+
if (state.enforced && fresh && before.remainingFiles <= 0) fail('ENTROROVE_BUDGET_EXHAUSTED', 'read file budget exhausted');
|
|
317
|
+
if (state.enforced && before.remainingTokenEstimate !== null && tokens > before.remainingTokenEstimate) fail('ENTROROVE_BUDGET_EXHAUSTED', 'read token budget exhausted');
|
|
318
|
+
if (state.enforced && before.remainingDurationMs <= 0) fail('ENTROROVE_BUDGET_EXHAUSTED', 'read duration budget exhausted');
|
|
319
|
+
const updated = { ...state, files: fresh ? [...state.files, relative] : state.files, readCalls: state.readCalls + 1, tokenEstimate: state.tokenEstimate + tokens };
|
|
320
|
+
await atomicJson(file, updated); await audit(input.home, root, 'budget-read', { flowId: input.flowId, path: relative, tokens });
|
|
321
|
+
return { schemaVersion: LOCAL_SCHEMA, path: relative, content: await fs.readFile(source.target, 'utf8'), budget: budgetView(updated) };
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
export async function budgetExtend(input) {
|
|
325
|
+
const { root, file } = await budgetFile(input);
|
|
326
|
+
return withStateLock(file, async () => {
|
|
327
|
+
const state = await readJson(file); if (!state.enforced) fail('ENTROROVE_BUDGET_NOT_APPLICABLE', 'budget extension applies only to a cloud sandbox');
|
|
328
|
+
const confirmation = input.confirmation; const additions = input.additions;
|
|
329
|
+
if (confirmation?.answer !== 'approve' || typeof confirmation.requestId !== 'string') fail('ENTROROVE_CONFIRMATION_REQUIRED', 'explicit budget confirmation is required');
|
|
330
|
+
if (!additions || !Number.isSafeInteger(additions.files) || !Number.isSafeInteger(additions.tokenEstimate) || !Number.isSafeInteger(additions.durationMs)
|
|
331
|
+
|| additions.files < 0 || additions.tokenEstimate < 0 || additions.durationMs < 0 || additions.files + additions.tokenEstimate + additions.durationMs === 0) fail('ENTROROVE_EXTENSION_INVALID', 'budget additions must be positive integers');
|
|
332
|
+
if (state.extensions.some(item => item.requestId === confirmation.requestId)) fail('ENTROROVE_CONFIRMATION_REPLAYED', 'budget confirmation was already applied');
|
|
333
|
+
const updated = { ...state, maxFiles: state.maxFiles + additions.files, maxTokenEstimate: state.maxTokenEstimate === null ? additions.tokenEstimate : state.maxTokenEstimate + additions.tokenEstimate,
|
|
334
|
+
maxDurationMs: state.maxDurationMs + additions.durationMs, extensions: [...state.extensions, { requestId: confirmation.requestId, additions, at: new Date().toISOString() }] };
|
|
335
|
+
await atomicJson(file, updated); await audit(input.home, root, 'budget-extend', { flowId: input.flowId, requestId: confirmation.requestId }); return budgetView(updated);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
async function coordinationState(input, create = false) {
|
|
339
|
+
const root = await repositoryRoot(input.repositoryRoot); const flowId = id(input.flowId, 'flowId'); const file = statePath(input.home, 'coordination', root, flowId, 'state.json');
|
|
340
|
+
if (create) { try { return { root, file, state: await readJson(file) }; } catch (error) { if (error.code !== 'ENOENT') throw error; const state = { schemaVersion: COORDINATION_SCHEMA, flowId, repositoryDigest: repoKey(root), workers: [], leases: [], events: [] }; await atomicJson(file, state); return { root, file, state }; } }
|
|
341
|
+
return { root, file, state: await readJson(file) };
|
|
342
|
+
}
|
|
343
|
+
async function coordinateUnlocked(input) {
|
|
344
|
+
const workerCount = input.workerCount ?? 0; if (!Number.isSafeInteger(workerCount) || workerCount < 0 || workerCount > 50) fail('ENTROROVE_WORKER_LIMIT', 'workerCount must be between 0 and 50');
|
|
345
|
+
if (input.priority !== undefined && !['urgent', 'normal', 'dependency', 'background'].includes(input.priority)) fail('ENTROROVE_PRIORITY_INVALID', 'priority is invalid');
|
|
346
|
+
const authority = await coordinationState(input, true); const updated = { ...authority.state, maxWorkers: workerCount, priority: input.priority ?? 'normal', events: [...authority.state.events, { event: 'coordinate', at: new Date().toISOString() }] };
|
|
347
|
+
await atomicJson(authority.file, updated); await audit(input.home, authority.root, 'peer-coordinate', { flowId: input.flowId, workerCount }); return { schemaVersion: LOCAL_SCHEMA, status: 'verified', maxWorkers: workerCount, priority: updated.priority };
|
|
348
|
+
}
|
|
349
|
+
export async function peerCoordinate(input) {
|
|
350
|
+
const root = await repositoryRoot(input.repositoryRoot); const flowId = id(input.flowId, 'flowId');
|
|
351
|
+
return withStateLock(statePath(input.home, 'coordination', root, flowId, 'state.json'), () => coordinateUnlocked(input));
|
|
352
|
+
}
|
|
353
|
+
async function spawnUnlocked(input) {
|
|
354
|
+
const paths = normalizePaths(input.paths); const priority = input.priority ?? 'normal'; if (!['urgent', 'normal', 'dependency', 'background'].includes(priority)) fail('ENTROROVE_PRIORITY_INVALID', 'priority is invalid');
|
|
355
|
+
const authority = await coordinationState(input, true); const active = authority.state.workers.filter(worker => worker.status === 'active'); if (active.length >= (authority.state.maxWorkers ?? 0)) fail('ENTROROVE_WORKER_LIMIT', 'coordination worker budget is exhausted');
|
|
356
|
+
if (input.parentWorkerId) fail('ENTROROVE_RECURSION_FORBIDDEN', 'derived workers cannot recursively spawn workers');
|
|
357
|
+
const workerId = id(input.workerId ?? `worker-${randomUUID()}`, 'workerId'); const leaseId = `lease-${randomUUID()}`; const expiresAt = new Date(Date.now() + 300_000).toISOString();
|
|
358
|
+
const worker = { workerId, leaseId, flowId: input.flowId, paths, priority, status: 'active', expiresAt, createdAt: new Date().toISOString() };
|
|
359
|
+
await atomicJson(authority.file, { ...authority.state, workers: [...authority.state.workers, worker], leases: [...authority.state.leases, worker], events: [...authority.state.events, { event: 'spawn-bind', workerId, at: new Date().toISOString() }] });
|
|
360
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'verified', workerId, leaseId, paths, expiresAt };
|
|
361
|
+
}
|
|
362
|
+
export async function spawnBind(input) {
|
|
363
|
+
const root = await repositoryRoot(input.repositoryRoot); const flowId = id(input.flowId, 'flowId');
|
|
364
|
+
return withStateLock(statePath(input.home, 'coordination', root, flowId, 'state.json'), () => spawnUnlocked(input));
|
|
365
|
+
}
|
|
366
|
+
async function completeUnlocked(input) {
|
|
367
|
+
const authority = await coordinationState(input); const worker = authority.state.workers.find(item => item.workerId === input.workerId && item.leaseId === input.leaseId && item.status === 'active'); if (!worker) fail('ENTROROVE_LEASE_INVALID', 'coordination lease is invalid');
|
|
368
|
+
if (Date.parse(worker.expiresAt) <= Date.now()) fail('ENTROROVE_LEASE_EXPIRED', 'coordination lease has expired');
|
|
369
|
+
const status = input.status ?? 'completed'; if (!['completed', 'failed', 'blocked'].includes(status)) fail('ENTROROVE_STATUS_INVALID', 'peer status is invalid');
|
|
370
|
+
const updatedWorker = { ...worker, status, completedAt: new Date().toISOString() }; const state = { ...authority.state, workers: authority.state.workers.map(item => item.workerId === worker.workerId ? updatedWorker : item), leases: authority.state.leases.map(item => item.workerId === worker.workerId ? updatedWorker : item) };
|
|
371
|
+
await atomicJson(authority.file, state); return { schemaVersion: LOCAL_SCHEMA, status: 'verified', workerId: worker.workerId, state: status };
|
|
372
|
+
}
|
|
373
|
+
export async function completePeer(input) {
|
|
374
|
+
const root = await repositoryRoot(input.repositoryRoot); const flowId = id(input.flowId, 'flowId');
|
|
375
|
+
return withStateLock(statePath(input.home, 'coordination', root, flowId, 'state.json'), () => completeUnlocked(input));
|
|
376
|
+
}
|
|
377
|
+
async function verifyEvidenceValue(evidence, input, requireBinding = false) {
|
|
378
|
+
const fields = ['schemaVersion', 'evidenceId', 'kind', 'runner', 'command', 'exitCode', 'durationMs', 'summary', 'artifactSha256', 'subject', 'subjectDigest', 'receipt', 'flowId', 'stepId'];
|
|
379
|
+
if (!evidence || Object.keys(evidence).some(field => !fields.includes(field)) || evidence.schemaVersion !== EVIDENCE_SCHEMA || typeof evidence.evidenceId !== 'string' || !evidence.evidenceId.trim() || evidence.evidenceId.length > 200 || !['test', 'build', 'lint', 'security', 'benchmark'].includes(evidence.kind)
|
|
380
|
+
|| !['local', 'trusted-runner', 'sandbox'].includes(evidence.runner) || typeof evidence.command !== 'string' || evidence.command.trim().length === 0 || evidence.command.length > 2_000 || !Number.isInteger(evidence.exitCode) || evidence.exitCode < -128 || evidence.exitCode > 255
|
|
381
|
+
|| !Number.isInteger(evidence.durationMs) || evidence.durationMs < 0 || evidence.durationMs > 86_400_000 || typeof evidence.summary !== 'string' || evidence.summary.trim().length === 0 || evidence.summary.length > 4_000 || !SHA256.test(evidence.artifactSha256)) return { verified: false, reason: 'evidence schema invalid' };
|
|
382
|
+
if (evidence.runner === 'local') return { verified: false, reason: 'local evidence is incomplete without a trusted receipt' };
|
|
383
|
+
const receipt = evidence.receipt; if (!receipt || ![EVIDENCE_RECEIPT_SCHEMA, 'validator.execution-receipt/1.0'].includes(receipt.schemaVersion)) return { verified: false, reason: 'signed evidence receipt is required' };
|
|
384
|
+
const binding = { flowId: input.flowId, stepId: input.stepId, snapshotDigest: input.snapshotDigest, contractDigest: input.contractDigest };
|
|
385
|
+
if (requireBinding) return verifyTrustedEvidence(evidence, binding);
|
|
386
|
+
const subject = Object.fromEntries(Object.entries(evidence).filter(([key]) => key !== 'receipt'));
|
|
387
|
+
try { if (receipt.schemaVersion === 'validator.execution-receipt/1.0') { if (typeof input.receiptPublicKey !== 'string' || receipt.subjectDigest !== evidence.subjectDigest || receipt.result?.runner !== 'trusted-runner' || receipt.result.exitCode !== evidence.exitCode || receipt.result.durationMs !== evidence.durationMs || receipt.result.summary !== evidence.summary) throw new Error('Trusted execution receipt is not bound'); const unsigned = Object.fromEntries(Object.entries(receipt).filter(([key]) => key !== 'signature')); verifyReceipt(receipt, unsigned, { schemaVersion: 'validator.execution-receipt/1.0', publicKey: input.receiptPublicKey }); } else verifyReceipt(receipt, subject); return { verified: true, evidence }; } catch (error) { return { verified: false, reason: error instanceof Error ? error.message : String(error) }; }
|
|
388
|
+
}
|
|
389
|
+
export async function verifyCoordinationLease(input) {
|
|
390
|
+
const authority = await coordinationState(input); const worker = authority.state.workers.find(item => item.workerId === input.workerId && item.leaseId === input.leaseId && item.status === 'active');
|
|
391
|
+
if (!worker || Date.parse(worker.expiresAt) <= Date.now()) fail('ENTROROVE_LEASE_INVALID', 'coordination lease is invalid or expired');
|
|
392
|
+
const paths = normalizePaths(input.paths ?? []); if (paths.some(target => !worker.paths.some(scope => target === scope || target.startsWith(`${scope}/`)))) fail('ENTROROVE_LEASE_SCOPE', 'coordination lease does not cover target paths');
|
|
393
|
+
return { schemaVersion: LOCAL_SCHEMA, verified: true, workerId: worker.workerId, leaseId: worker.leaseId, paths: worker.paths, expiresAt: worker.expiresAt };
|
|
394
|
+
}
|
|
395
|
+
export async function signEvidence(input) {
|
|
396
|
+
const root = await repositoryRoot(input.repositoryRoot); const authority = await keyPair(input.home, root); const evidence = { ...input.evidence, schemaVersion: EVIDENCE_SCHEMA, runner: input.evidence?.runner ?? 'local' };
|
|
397
|
+
if (typeof evidence.evidenceId !== 'string' || !evidence.evidenceId.trim() || evidence.evidenceId.length > 200) fail('ENTROROVE_EVIDENCE_ID', 'evidenceId must be a non-empty string up to 200 characters');
|
|
398
|
+
if (!SHA256.test(evidence.artifactSha256)) fail('ENTROROVE_EVIDENCE_ARTIFACT', 'artifactSha256 must be a lowercase SHA-256 digest');
|
|
399
|
+
const publicKey = createPublicKey(authority.publicKey).export({ format: 'der', type: 'spki' }).toString('base64');
|
|
400
|
+
const receipt = { schemaVersion: EVIDENCE_RECEIPT_SCHEMA, keyId: authority.keyId, publicKey,
|
|
401
|
+
signature: sign(null, Buffer.from(canonical(evidence)), authority.privateKey).toString('base64url') };
|
|
402
|
+
return { ...evidence, receipt };
|
|
403
|
+
}
|
|
404
|
+
export async function verifyEvidence(input) { return verifyEvidenceValue(input.evidence, input); }
|
|
405
|
+
export async function mergeVerified(input) {
|
|
406
|
+
const root = await repositoryRoot(input.repositoryRoot); id(input.flowId, 'flowId'); id(input.stepId, 'stepId'); if (!input.sourceRef || !input.targetRef) fail('ENTROROVE_MERGE_REFS_REQUIRED', 'sourceRef and targetRef are required');
|
|
407
|
+
if (!input.snapshotReceipt) return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', code: 'ENTROROVE_SNAPSHOT_REQUIRED' };
|
|
408
|
+
const snapshotReceipt = await loadReceipt({ receipt: input.snapshotReceipt });
|
|
409
|
+
if (snapshotReceipt.flowId !== input.flowId || snapshotReceipt.stepId !== input.stepId || snapshotReceipt.repositoryDigest !== repoKey(root)) return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', code: 'ENTROROVE_SNAPSHOT_SCOPE' };
|
|
410
|
+
let manifest;
|
|
411
|
+
try { ({ manifest } = await snapshotAuthority(input, root, snapshotReceipt)); } catch (error) { return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', code: error.code ?? 'ENTROROVE_SNAPSHOT_BINDING' }; }
|
|
412
|
+
const binding = { flowId: input.flowId, stepId: input.stepId, snapshotDigest: snapshotReceipt.snapshotDigest, contractDigest: snapshotReceipt.contractDigest, receiptPublicKey: input.receiptPublicKey };
|
|
413
|
+
const preflight = verifyArchguardPreflight(input.preflight, binding);
|
|
414
|
+
if (!preflight.verified) return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', code: 'ENTROROVE_PREFLIGHT_BLOCKED', reason: preflight.reason };
|
|
415
|
+
if (!SHA256.test(binding.contractDigest ?? '')) return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', code: 'ENTROROVE_CONTRACT_DIGEST' };
|
|
416
|
+
if (!Array.isArray(input.evidence) || input.evidence.length === 0) return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', code: 'ENTROROVE_EVIDENCE_REQUIRED' };
|
|
417
|
+
const checks = await Promise.all(input.evidence.map(evidence => verifyEvidenceValue(evidence, binding, true))); if (checks.some(check => !check.verified) || input.evidence.some(evidence => evidence.exitCode !== 0)) return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', code: 'ENTROROVE_EVIDENCE_UNVERIFIED', checks };
|
|
418
|
+
let written = 0; for (const change of input.changes ?? []) { await guardedWrite({ ...change, repositoryRoot: root, home: input.home, flowId: input.flowId, stepId: input.stepId }); written += 1; }
|
|
419
|
+
await audit(input.home, root, 'merge-verified', { flowId: input.flowId, stepId: input.stepId, sourceRef: input.sourceRef, targetRef: input.targetRef });
|
|
420
|
+
await appendLedgerRecord({ home: input.home, repositoryRoot: root }, 'merge-verified', { flowId: input.flowId, stepId: input.stepId, sourceRef: input.sourceRef, targetRef: input.targetRef, snapshotDigest: binding.snapshotDigest, contractDigest: binding.contractDigest });
|
|
421
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'verified', merged: true, changes: written, sourceRef: input.sourceRef, targetRef: input.targetRef, preflightKeyId: preflight.keyId };
|
|
422
|
+
}
|
|
423
|
+
export async function rollback(input) {
|
|
424
|
+
const root = await repositoryRoot(input.repositoryRoot); const flowId = id(input.flowId, 'flowId'); const snapshotId = id(input.snapshotId, 'snapshotId'); const manifestPath = path.join(path.dirname(statePath(input.home, 'snapshots', root, flowId, 'manifest.json')), snapshotId, 'manifest.json'); const manifest = await readJson(manifestPath);
|
|
425
|
+
const integrity = verifySnapshotManifest(manifest, { snapshotId, flowId, stepId: input.stepId ?? manifest.stepId, repositoryDigest: repoKey(root) });
|
|
426
|
+
if (!integrity.verified) fail('ENTROROVE_SNAPSHOT_CORRUPT', integrity.reason ?? 'snapshot manifest is invalid');
|
|
427
|
+
for (const entry of manifest.files) { const target = await projectFile(root, entry.path, true); const copy = path.join(path.dirname(manifestPath), entry.path); if (entry.exists) { if (sha256(await fs.readFile(copy)) !== entry.sha256) fail('ENTROROVE_SNAPSHOT_CORRUPT', `${entry.path} snapshot copy is invalid`); await privateDir(path.dirname(target.target)); await fs.copyFile(copy, target.target); } else if (target.exists) await fs.unlink(target.target); }
|
|
428
|
+
await audit(input.home, root, 'rollback', { flowId, stepId: input.stepId ?? null, snapshotId }); return { schemaVersion: LOCAL_SCHEMA, status: 'verified', rolledBack: true, snapshotId };
|
|
429
|
+
}
|
|
430
|
+
export async function finalGate(input) {
|
|
431
|
+
const checks = [input.audit, input.test, input.operations]; const passed = checks.every(check => check === true || ['passed', 'green', 'complete'].includes(check?.status));
|
|
432
|
+
const evidence = Array.isArray(input.evidence) ? input.evidence : [];
|
|
433
|
+
const bound = input.preflight !== undefined || input.snapshotDigest !== undefined || input.contractDigest !== undefined;
|
|
434
|
+
const binding = { flowId: input.flowId, stepId: input.stepId, snapshotDigest: input.snapshotDigest, contractDigest: input.contractDigest, receiptPublicKey: input.receiptPublicKey };
|
|
435
|
+
const preflight = bound ? verifyArchguardPreflight(input.preflight, binding) : { verified: true };
|
|
436
|
+
const validation = evidence.length ? await Promise.all(evidence.map(item => verifyEvidenceValue(item, bound ? binding : input, bound))) : [];
|
|
437
|
+
const trusted = evidence.length > 0 && preflight.verified && validation.every(item => item.verified) && evidence.every(evidenceItem => evidenceItem.exitCode === 0);
|
|
438
|
+
const complete = passed && trusted;
|
|
439
|
+
if (input.repositoryRoot) {
|
|
440
|
+
const root = await repositoryRoot(input.repositoryRoot);
|
|
441
|
+
await appendLedgerRecord({ home: input.home, repositoryRoot: root }, 'final-gate', { flowId: input.flowId ?? null, stepId: input.stepId ?? null, status: complete ? 'complete' : 'blocked', evidenceCount: evidence.length });
|
|
442
|
+
}
|
|
443
|
+
return { schemaVersion: LOCAL_SCHEMA, status: complete ? 'complete' : 'blocked', authority: 'trusted-local-runner', checks: { audit: Boolean(passed && checks[0]), test: Boolean(passed && checks[1]), operations: Boolean(passed && checks[2]) }, evidenceCount: evidence.length, guarantee: complete ? 'local-completion-gate-green' : 'local-completion-gate-blocked', preflight: preflight.verified ? 'verified' : 'blocked' };
|
|
444
|
+
}
|
|
445
|
+
export const LOCAL_OPERATION_SCHEMAS = Object.fromEntries([
|
|
446
|
+
['capabilities', []], ['snapshot-plan', ['repositoryRoot', 'flowId', 'stepId', 'paths']], ['snapshot-verify', ['repositoryRoot', 'flowId', 'stepId', 'snapshotId']],
|
|
447
|
+
['mutate-gate', ['repositoryRoot', 'flowId', 'stepId', 'accepted', 'receipt']], ['gate-issue', ['repositoryRoot', 'flowId', 'stepId', 'accepted', 'receipt']],
|
|
448
|
+
['gate-verify', ['repositoryRoot', 'flowId', 'stepId', 'gatePassPath', 'targetPath']], ['guarded-write', ['repositoryRoot', 'flowId', 'stepId', 'gatePassPath', 'targetPath', 'content']],
|
|
449
|
+
['budget-init', ['repositoryRoot', 'flowId', 'mode', 'executionContext']], ['budget-context', ['repositoryRoot', 'flowId', 'executionContext']], ['budget-read', ['repositoryRoot', 'flowId', 'path']], ['budget-status', ['repositoryRoot', 'flowId']], ['budget-extend', ['repositoryRoot', 'flowId', 'confirmation', 'additions']],
|
|
450
|
+
['peer-coordinate', ['repositoryRoot', 'flowId']], ['coordinate', ['repositoryRoot', 'flowId']], ['spawn-bind', ['repositoryRoot', 'flowId', 'paths']], ['peer-complete', ['repositoryRoot', 'flowId', 'workerId', 'leaseId']], ['complete', ['repositoryRoot', 'flowId', 'workerId', 'leaseId']], ['lease-verify', ['repositoryRoot', 'flowId', 'workerId', 'leaseId', 'paths']],
|
|
451
|
+
['evidence-sign', ['repositoryRoot', 'evidence']], ['evidence-verify', ['repositoryRoot', 'evidence']], ['merge-verified', ['repositoryRoot', 'flowId', 'stepId', 'sourceRef', 'targetRef', 'snapshotReceipt', 'evidence', 'preflight']], ['rollback', ['repositoryRoot', 'flowId', 'snapshotId']], ['final-gate', ['repositoryRoot', 'audit', 'test', 'operations', 'evidence']],
|
|
452
|
+
['formula-evaluate', ['formula']], ['formula-graph', ['formulas']], ['formula-final-gate', ['engine']], ['calculation-final-gate', ['engine']],
|
|
453
|
+
['run-approved-plan', ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath']],
|
|
454
|
+
].map(([operation, required]) => [operation, { type: 'object', additionalProperties: false, required }]));
|
|
455
|
+
Object.assign(LOCAL_OPERATION_SCHEMAS, LOCAL_LEDGER_SCHEMAS);
|
|
456
|
+
Object.assign(LOCAL_OPERATION_SCHEMAS, LOCAL_RENEWAL_SCHEMAS, LOCAL_PROBE_SCHEMAS);
|
|
457
|
+
Object.assign(LOCAL_OPERATION_SCHEMAS, LOCAL_TASK_ROUTING_SCHEMAS);
|
|
458
|
+
Object.freeze(LOCAL_OPERATION_SCHEMAS);
|
|
459
|
+
export const LOCAL_CAPABILITIES = Object.freeze({ schemaVersion: LOCAL_SCHEMA, operations: Object.freeze([
|
|
460
|
+
'capabilities', 'snapshot-plan', 'snapshot-verify', 'mutate-gate', 'gate-issue', 'gate-verify', 'guarded-write',
|
|
461
|
+
'budget-init', 'budget-context', 'budget-read', 'budget-status', 'budget-extend', 'peer-coordinate', 'spawn-bind',
|
|
462
|
+
'peer-complete', 'complete', 'lease-verify', 'evidence-sign', 'evidence-verify', 'merge-verified', 'rollback', 'final-gate',
|
|
463
|
+
'formula-evaluate', 'formula-graph', 'formula-final-gate', 'calculation-final-gate', 'run-approved-plan',
|
|
464
|
+
...Object.keys(LOCAL_LEDGER_OPERATIONS),
|
|
465
|
+
...Object.keys(LOCAL_RENEWAL_OPERATIONS), ...Object.keys(LOCAL_PROBE_OPERATIONS),
|
|
466
|
+
...Object.keys(LOCAL_TASK_ROUTING_OPERATIONS),
|
|
467
|
+
]), operationSchemas: LOCAL_OPERATION_SCHEMAS, stateDirectory: '~/.entrorove', forbidGitBranch: true, forbidWorktree: true, evidenceSchema: EVIDENCE_SCHEMA, budgets: READ_BUDGETS, validatorRunner: RUNNER_CAPABILITIES });
|
|
468
|
+
export function capabilities() { return LOCAL_CAPABILITIES; }
|
|
469
|
+
export const guardedWriteFile = guardedWrite;
|
|
470
|
+
export const verifyMutationPassFile = verifyMutationPass;
|
|
471
|
+
export const initializeReadBudget = budgetInit;
|
|
472
|
+
export const configureReadBudgetContext = budgetContext;
|
|
473
|
+
export const readFileWithinBudget = budgetRead;
|
|
474
|
+
export const readBudgetStatus = budgetStatus;
|
|
475
|
+
export const extendReadBudget = budgetExtend;
|
|
476
|
+
export const runLocalFinalGate = finalGate;
|
|
477
|
+
export { Decimal, evaluateFormula, evaluateFormulaGraph, formulaFinalGate, formulaFinalGateFile, runApprovedPlan };
|
|
478
|
+
export { LOCAL_LEDGER_OPERATIONS };
|
|
479
|
+
export { LOCAL_RENEWAL_OPERATIONS, LOCAL_PROBE_OPERATIONS };
|
|
480
|
+
export { branchCreate, branchList, branchSwitch, diffReport, preflight, ledgerQuery, appendLedgerRecord, chainInit, chainResume, chainStatus, chainAnswer };
|
|
481
|
+
export { budgetAutoRenewRequest, budgetAutoRenew, budgetAutoRenewStop, probe, reassess };
|
|
482
|
+
export { LOCAL_TASK_ROUTING_OPERATIONS, taskDescribe, messageRoute, messageAccept, taskResume };
|