@entrorove/skill 0.1.24 → 0.1.28
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/CAPABILITY-MAP.md +73 -15
- package/CHANGELOG.md +20 -0
- package/SKILL.md +11 -1
- package/calctool-runtime-entry.mjs +2 -2
- package/cli.mjs +18 -1
- package/local-archguard-checkpoint.mjs +5 -2
- package/local-archguard-snapshot.mjs +18 -13
- package/local-autocoord17-authority.mjs +36 -0
- package/local-autocoord17-controller.mjs +164 -0
- package/local-autocoord17-peers.mjs +111 -0
- package/local-autocoord17-store.mjs +110 -0
- package/local-autocoord17-tasks.mjs +68 -0
- package/local-autocoord17-waits.mjs +118 -0
- package/local-calctool-cli.mjs +3 -1
- package/local-calctool-composition17.mjs +50 -0
- package/local-calctool-runtime.mjs +1 -1
- package/local-calctool.mjs +1 -1
- package/local-chain-bindings.mjs +93 -0
- package/local-chain-broker.mjs +61 -0
- package/local-chain-contracts.mjs +21 -0
- package/local-chain-controller.mjs +321 -0
- package/local-chain-human.mjs +48 -0
- package/local-chain-lock.mjs +54 -0
- package/local-chain-operations.mjs +7 -0
- package/local-chain-plan.mjs +232 -0
- package/local-chain-release-labels.mjs +12 -0
- package/local-chain-results.mjs +75 -0
- package/local-cli.mjs +23 -15
- package/local-contract-types.mjs +1 -1
- package/local-contracts.mjs +2 -0
- package/local-error.mjs +23 -0
- package/local-execution.mjs +1 -1
- package/local-final-gate16-bundle.mjs +2 -2
- package/local-formula-ast.mjs +23 -14
- package/local-formula-engine.mjs +17 -3
- package/local-formula.mjs +1 -1
- package/local-invocations.mjs +35 -0
- package/local-ledger.mjs +13 -2
- package/local-mutation-authority.mjs +13 -6
- package/local-mutation-binding.mjs +5 -2
- package/local-mutation-files.mjs +29 -4
- package/local-task-handoff-observation.mjs +4 -1
- package/local-validator-isolation17.mjs +256 -0
- package/manifest.json +1 -1
- package/package.json +22 -2
- package/release-state.json +48 -29
- package/release.mjs +22 -1
- package/releases.json +24 -0
- package/runtime/calctool-runtime-v7.0.43.mjs +40 -17
- package/workflow-local-request.mjs +3 -1
package/local-invocations.mjs
CHANGED
|
@@ -9,6 +9,7 @@ const UUID = /^[a-f0-9-]{36}$/;
|
|
|
9
9
|
const ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
10
10
|
const COUNTS = ['inputTokens', 'outputTokens', 'durationMs'];
|
|
11
11
|
const events = new Set(['started', 'completed', 'rejected', 'outcome-unknown', 'not-sent', 'status']);
|
|
12
|
+
const readOnlyOperations = new Set(['ledger-query', 'chain-ledger-query']);
|
|
12
13
|
|
|
13
14
|
function receipt(value, requestId) {
|
|
14
15
|
if (!value || !UUID.test(value.usageId) || value.requestId !== requestId
|
|
@@ -44,6 +45,22 @@ async function ledgerFile(home) {
|
|
|
44
45
|
return path.join(directory, 'invocations.jsonl');
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
async function finalGateStateExists(input) {
|
|
49
|
+
if (!input || typeof input.home !== 'string' || typeof input.repositoryRoot !== 'string' || typeof input.gateId !== 'string') return false;
|
|
50
|
+
try {
|
|
51
|
+
const root = await fs.realpath(input.repositoryRoot);
|
|
52
|
+
const repositoryDigest = createHash('sha256').update(root).digest('hex');
|
|
53
|
+
const base = path.join(await fs.realpath(input.home), '.entrorove', 'calctool', 'final-gates', repositoryDigest);
|
|
54
|
+
for (const flow of await fs.readdir(base, { withFileTypes: true })) {
|
|
55
|
+
if (!flow.isDirectory() || flow.isSymbolicLink()) continue;
|
|
56
|
+
const target = path.join(base, flow.name, `${input.gateId}.json`);
|
|
57
|
+
try { const stat = await fs.lstat(target); if (stat.isFile() && !stat.isSymbolicLink()) return true; }
|
|
58
|
+
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
59
|
+
}
|
|
60
|
+
} catch (error) { if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error; }
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
export async function appendInvocation(value, home = os.homedir()) {
|
|
48
65
|
if (!value || !SHA.test(value.requestId) || !ID.test(value.operation) || !events.has(value.event)
|
|
49
66
|
|| !['remote', 'local'].includes(value.kind)) throw new Error('Invalid invocation ledger metadata');
|
|
@@ -77,6 +94,24 @@ function canonical(value) {
|
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
export async function withLocalInvocation(operation, input, work) {
|
|
97
|
+
// A final-gate recovery carries only the immutable identity fields and must
|
|
98
|
+
// remain a read-only GET-style lookup. A fresh gate includes plans and keeps
|
|
99
|
+
// the normal invocation audit record.
|
|
100
|
+
if (readOnlyOperations.has(operation)) return work();
|
|
101
|
+
if (operation === 'final-gate' && input) {
|
|
102
|
+
if (!Object.hasOwn(input, 'plans')) return work();
|
|
103
|
+
try {
|
|
104
|
+
const { statePath16 } = await import('./local-final-gate16-state.mjs');
|
|
105
|
+
const target = await statePath16(input);
|
|
106
|
+
if (target) {
|
|
107
|
+
try { await fs.access(target); return work(); }
|
|
108
|
+
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
109
|
+
}
|
|
110
|
+
if (await finalGateStateExists(input)) return work();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
80
115
|
const requestId = createHash('sha256').update(operation + canonical(input)).digest('hex');
|
|
81
116
|
const metadata = { kind: 'local', operation, requestId };
|
|
82
117
|
await appendInvocation({ ...metadata, event: 'started' }, input.home);
|
package/local-ledger.mjs
CHANGED
|
@@ -19,9 +19,20 @@ async function repository(value) { if (typeof value !== 'string' || !path.isAbso
|
|
|
19
19
|
function repoDigest(root) { return sha(root).slice(0, 32); }
|
|
20
20
|
async function privateDir(directory) { await fs.mkdir(directory, { recursive: true, mode: 0o700 }); const stat = await fs.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'state directory must be a real directory'); if (process.platform !== 'win32') await fs.chmod(directory, 0o700); }
|
|
21
21
|
async function atomic(file, value) { await privateDir(path.dirname(file)); const tmp = `${file}.${randomUUID()}.tmp`; await fs.writeFile(tmp, `${canonical(value)}\n`, { flag: 'wx', mode: 0o600 }); await fs.rename(tmp, file); if (process.platform !== 'win32') await fs.chmod(file, 0o600); }
|
|
22
|
-
async function read(file) { await privateDir(path.dirname(file)); const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'ledger state must be a regular file'); return JSON.parse(await fs.readFile(file, 'utf8')); }
|
|
22
|
+
async function read(file, ensureDirectory = true) { if (ensureDirectory) await privateDir(path.dirname(file)); const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'ledger state must be a regular file'); return JSON.parse(await fs.readFile(file, 'utf8')); }
|
|
23
23
|
function ledgerPath(home, root) { return path.join(rootFor(home), 'ledgers', repoDigest(root), 'state.json'); }
|
|
24
24
|
async function loadLedger(input, create = false) { const root = await repository(input.repositoryRoot); const file = ledgerPath(input.home, root); try { const state = await read(file); if (state.schemaVersion !== LEDGER_SCHEMA || state.repositoryDigest !== repoDigest(root)) fail('ENTROROVE_LEDGER_INVALID', 'ledger identity mismatch'); return { root, file, state }; } catch (error) { if (!create || error.code !== 'ENOENT') throw error; const state = { schemaVersion: LEDGER_SCHEMA, repositoryDigest: repoDigest(root), sequence: 0, head: null, activeBranch: null, branches: [], records: [] }; await atomic(file, state); return { root, file, state }; } }
|
|
25
|
+
async function readLedgerForQuery(input) {
|
|
26
|
+
const root = await repository(input.repositoryRoot); const file = ledgerPath(input.home, root);
|
|
27
|
+
try {
|
|
28
|
+
const state = await read(file, false);
|
|
29
|
+
if (state.schemaVersion !== LEDGER_SCHEMA || state.repositoryDigest !== repoDigest(root)) fail('ENTROROVE_LEDGER_INVALID', 'ledger identity mismatch');
|
|
30
|
+
return { root, file, state };
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error.code !== 'ENOENT') throw error;
|
|
33
|
+
return { root, file, state: { schemaVersion: LEDGER_SCHEMA, repositoryDigest: repoDigest(root), sequence: 0, head: null, activeBranch: null, branches: [], records: [] } };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
25
36
|
async function append(input, event, data = {}) { const root = await repository(input.repositoryRoot); const file = ledgerPath(input.home, root); const previous = ledgerLocks.get(file) ?? Promise.resolve(); let release; const current = new Promise(resolve => { release = resolve; }); const queued = previous.then(() => current); ledgerLocks.set(file, queued); await previous; try { return await withStateLock(file, async () => { const authority = await loadLedger(input, true); const at = new Date().toISOString(); const unsigned = { sequence: authority.state.sequence + 1, event, at, ...data }; const record = { ...unsigned, previousDigest: authority.state.head, digest: sha(canonical(unsigned) + (authority.state.head ?? '')) }; const state = { ...authority.state, sequence: record.sequence, head: record.digest, records: [...authority.state.records, record] }; await atomic(authority.file, state); return { authority, state, record }; }); } finally { release(); if (ledgerLocks.get(file) === queued) ledgerLocks.delete(file); } }
|
|
26
37
|
export async function appendLedgerRecord(input, event, data = {}) { if (typeof event !== 'string' || !event.trim()) fail('ENTROROVE_LEDGER_EVENT', 'ledger event is required'); return (await append(input, event, data)).record; }
|
|
27
38
|
export async function branchCreate(input) { const name = id(input.name ?? input.branchName, 'branchName'); const fromRef = typeof input.fromRef === 'string' && input.fromRef ? input.fromRef : 'working-tree'; const authority = await loadLedger(input, true); if (authority.state.branches.some(item => item.name === name)) fail('ENTROROVE_BRANCH_EXISTS', `virtual branch already exists: ${name}`); const branch = { branchId: `branch-${randomUUID()}`, name, fromRef, description: input.description ?? '', virtual: true, createdAt: new Date().toISOString(), snapshotId: input.snapshotId ?? null }; const state = { ...authority.state, branches: [...authority.state.branches, branch], activeBranch: authority.state.activeBranch ?? name }; await atomic(authority.file, state); await append(input, 'branch-create', { name, fromRef, virtual: true }); return { schemaVersion: LEDGER_SCHEMA, status: 'verified', branch, forbidGitBranch: true, forbidWorktree: true }; }
|
|
@@ -36,7 +47,7 @@ export async function preflight(input) {
|
|
|
36
47
|
const reasons = []; if (!archguard.verified) reasons.push(archguard.reason); if (!evidence) reasons.push('trusted signed evidence bound to this flow, snapshot, and contract with exitCode 0 is required');
|
|
37
48
|
return { schemaVersion: LEDGER_SCHEMA, status: archguard.verified && evidence ? 'verified' : 'blocked', source: 'entrorove-ledger', checks: { archguard: archguard.verified, evidence }, reasons, virtualOnly: true, keyId: archguard.keyId ?? null };
|
|
38
49
|
}
|
|
39
|
-
export async function ledgerQuery(input) { const authority = await
|
|
50
|
+
export async function ledgerQuery(input) { const authority = await readLedgerForQuery(input); const limit = Number.isSafeInteger(input.limit) ? Math.min(500, Math.max(1, input.limit)) : 100; return { schemaVersion: LEDGER_SCHEMA, status: 'verified', repositoryDigest: repoDigest(authority.root), records: authority.state.records.slice(-limit), head: authority.state.head, sequence: authority.state.sequence }; }
|
|
40
51
|
|
|
41
52
|
function validatePlan(plan) { if (!plan || typeof plan !== 'object' || Array.isArray(plan) || plan.schemaVersion !== 'entrorove.execution-plan/1.0' || !ID.test(plan.chainId) || !Array.isArray(plan.steps) || !plan.steps.length) fail('ENTROROVE_CHAIN_PLAN_INVALID', 'chain plan must declare a schema, chainId, and steps'); const seen = new Set(); for (const [index, step] of plan.steps.entries()) { if (!step || typeof step !== 'object' || !ID.test(step.stepId) || seen.has(step.stepId) || !['command', 'skill', 'coordinator', 'decision'].includes(step.kind)) fail('ENTROROVE_CHAIN_STEP_INVALID', `step ${index} is invalid`); seen.add(step.stepId); } return plan; }
|
|
42
53
|
async function chainAuthority(input, create = false) { const root = await repository(input.repositoryRoot); const chainId = id(input.chainId ?? input.plan?.chainId, 'chainId'); const file = path.join(rootFor(input.home), 'chains', repoDigest(root), chainId, 'state.json'); try { const state = await read(file); if (state.schemaVersion !== CHAIN_SCHEMA || state.chainId !== chainId || state.repositoryDigest !== repoDigest(root)) fail('ENTROROVE_CHAIN_INVALID', 'chain state identity mismatch'); return { root, chainId, file, state }; } catch (error) { if (!create || error.code !== 'ENOENT') throw error; return { root, chainId, file, state: null }; } }
|
|
@@ -4,7 +4,7 @@ import { appendLedgerRecord } from './local-ledger.mjs';
|
|
|
4
4
|
import { withStateLock } from './local-state-lock.mjs';
|
|
5
5
|
import { withTaskHandoffIssueFence, withTaskHandoffWriteFence, assertTaskHandoffSnapshotBinding } from './local-task-handoff-fence.mjs';
|
|
6
6
|
import { mutationFail14, mutationPath14, mutationStateRoot14,
|
|
7
|
-
privateMutationDirectory14, writeMutationProjectFile14 } from './local-mutation-files.mjs';
|
|
7
|
+
mutationRead14, privateMutationDirectory14, writeMutationProjectFile14 } from './local-mutation-files.mjs';
|
|
8
8
|
import { mutationPass14, mutationLifetime14 } from './local-mutation-wire.mjs';
|
|
9
9
|
import { mutationOriginalBinding14, mutationSnapshotBinding14 } from './local-mutation-binding.mjs';
|
|
10
10
|
import { installMutationPass14, mutationPassLocations14, readMutationConsumption14,
|
|
@@ -42,20 +42,27 @@ export async function issueMutationPass14(input) {
|
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
|
-
async function checkedPass(input, state) {
|
|
45
|
+
async function checkedPass(input, state, { allowTargetDrift = false } = {}) {
|
|
46
46
|
const pass = await readStoredMutationPass14(input), target = mutationPath14(input.targetPath, 'targetPath');
|
|
47
47
|
if (!pass.paths.includes(target)) mutationFail14('ENTROROVE_MUTATION_WRITE_SCOPE', 'Guarded writing is outside the cloud pass path set');
|
|
48
48
|
const binding = await mutationOriginalBinding14(input, pass, state);
|
|
49
|
-
const snapshot = await mutationSnapshotBinding14(input, pass, { targetPath: target });
|
|
49
|
+
const snapshot = await mutationSnapshotBinding14(input, pass, { targetPath: target, allowTargetDrift });
|
|
50
50
|
assertTaskHandoffSnapshotBinding(binding.state, pass.snapshotReceipt);
|
|
51
51
|
const locations = await mutationPassLocations14(input, binding, pass);
|
|
52
52
|
const ledger = await readMutationConsumption14(pass, locations);
|
|
53
|
-
|
|
53
|
+
const consumedWrite = ledger.writes[target];
|
|
54
|
+
if (allowTargetDrift) {
|
|
55
|
+
if (consumedWrite?.status !== 'written') mutationFail14('ENTROROVE_MUTATION_PASS_CONSUMED', 'Checkpoint requires a completed guarded write');
|
|
56
|
+
const current = await mutationRead14(input, binding.root, target);
|
|
57
|
+
if (current.sha256 !== consumedWrite.contentDigest) mutationFail14('ENTROROVE_MUTATION_WRITE_CHANGED', 'Checkpoint target differs from its completed guarded write');
|
|
58
|
+
} else if (consumedWrite) {
|
|
59
|
+
mutationFail14('ENTROROVE_MUTATION_PASS_CONSUMED', 'Mutation pass was already consumed or has an unresolved physical write');
|
|
60
|
+
}
|
|
54
61
|
return { pass, target, binding, snapshot, locations, ledger };
|
|
55
62
|
}
|
|
56
|
-
export async function verifyMutationPass14(input) {
|
|
63
|
+
export async function verifyMutationPass14(input, { allowTargetDrift = false } = {}) {
|
|
57
64
|
return withMutationAudit14(input, 'mutation-pass-verify', async () => {
|
|
58
|
-
const value = await checkedPass(input);
|
|
65
|
+
const value = await checkedPass(input, undefined, { allowTargetDrift });
|
|
59
66
|
return { schemaVersion: SCHEMA, verified: true, pass: value.pass, targetPath: value.target };
|
|
60
67
|
});
|
|
61
68
|
}
|
|
@@ -39,7 +39,7 @@ export async function mutationOriginalBinding14(input, authority, state) {
|
|
|
39
39
|
}
|
|
40
40
|
return { root, home, context: ctx, state: current };
|
|
41
41
|
}
|
|
42
|
-
export async function mutationSnapshotBinding14(input, pass, { allSources = false, targetPath } = {}) {
|
|
42
|
+
export async function mutationSnapshotBinding14(input, pass, { allSources = false, targetPath, allowTargetDrift = false } = {}) {
|
|
43
43
|
mutationPass14(pass);
|
|
44
44
|
const root = await mutationRoot14(input.repositoryRoot);
|
|
45
45
|
const snapshot = await snapshotAuthority14(input, root, pass.snapshotReceipt);
|
|
@@ -53,7 +53,10 @@ export async function mutationSnapshotBinding14(input, pass, { allSources = fals
|
|
|
53
53
|
if (snapshot.manifest.files.some(file => !file.exists && !pass.newFilePaths.includes(file.path))) {
|
|
54
54
|
mutationFail14('ENTROROVE_MUTATION_NEW_FILE_SCOPE', 'Missing source is outside the original planned new-file set');
|
|
55
55
|
}
|
|
56
|
-
|
|
56
|
+
// ArchGuard validates the changed target after a guarded write. The original
|
|
57
|
+
// snapshot still binds the pass, while its target source is expected to drift
|
|
58
|
+
// before checkpoint evaluation. Ordinary mutation paths keep the hash check.
|
|
59
|
+
if (allSources || targetPath && !allowTargetDrift) await verifySnapshotSources14(input, root, snapshot.manifest,
|
|
57
60
|
{ paths: allSources ? snapshot.manifest.paths : [targetPath] });
|
|
58
61
|
return { ...snapshot, root };
|
|
59
62
|
}
|
package/local-mutation-files.mjs
CHANGED
|
@@ -63,6 +63,31 @@ async function inspectPath(target, { allowMissing = false, final = 'file', code
|
|
|
63
63
|
else return { target: resolved, exists: true, stat, ancestors };
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
async function canonicalDirectoryPath(value, { allowMissing = false, code }) {
|
|
67
|
+
const resolved = absolute(value, 'filesystem path');
|
|
68
|
+
const base = path.parse(resolved).root;
|
|
69
|
+
const parts = path.relative(base, resolved).split(path.sep).filter(Boolean);
|
|
70
|
+
let current = base;
|
|
71
|
+
for (const [index, part] of parts.entries()) {
|
|
72
|
+
const candidate = path.join(current, part);
|
|
73
|
+
let stat;
|
|
74
|
+
try { stat = await fs.lstat(candidate); } catch (error) {
|
|
75
|
+
if (error.code === 'ENOENT' && allowMissing) return path.join(current, ...parts.slice(index));
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (stat.isSymbolicLink()) {
|
|
79
|
+
const linked = await fs.realpath(candidate);
|
|
80
|
+
const systemAlias = process.platform === 'darwin' && current === base
|
|
81
|
+
&& ['/var', '/tmp', '/etc'].includes(candidate) && linked === path.join('/private', candidate);
|
|
82
|
+
if (!systemAlias) mutationFail14(code, 'mutation paths refuse symlink, non-directory ancestors, and invalid file types');
|
|
83
|
+
current = linked;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!stat.isDirectory()) mutationFail14(code, 'mutation paths refuse symlink, non-directory ancestors, and invalid file types');
|
|
87
|
+
current = candidate;
|
|
88
|
+
}
|
|
89
|
+
return current;
|
|
90
|
+
}
|
|
66
91
|
async function assertAncestors(item, code = 'ENTROROVE_STATE_CHANGED') {
|
|
67
92
|
for (const ancestor of item.ancestors) {
|
|
68
93
|
const stat = await fs.lstat(ancestor.path);
|
|
@@ -72,12 +97,12 @@ async function assertAncestors(item, code = 'ENTROROVE_STATE_CHANGED') {
|
|
|
72
97
|
}
|
|
73
98
|
}
|
|
74
99
|
export async function mutationRoot14(value) {
|
|
75
|
-
const
|
|
76
|
-
await inspectPath(
|
|
77
|
-
return
|
|
100
|
+
const canonicalRoot = await canonicalDirectoryPath(value, { code: 'ENTROROVE_ROOT_INVALID' });
|
|
101
|
+
await inspectPath(canonicalRoot, { final: 'directory', code: 'ENTROROVE_ROOT_INVALID' });
|
|
102
|
+
return canonicalRoot;
|
|
78
103
|
}
|
|
79
104
|
export async function mutationHome14(value = os.homedir()) {
|
|
80
|
-
const home =
|
|
105
|
+
const home = await canonicalDirectoryPath(value, { allowMissing: true, code: 'ENTROROVE_HOME_INVALID' });
|
|
81
106
|
await inspectPath(home, { allowMissing: true, final: 'directory', code: 'ENTROROVE_HOME_INVALID' });
|
|
82
107
|
return home;
|
|
83
108
|
}
|
|
@@ -9,7 +9,10 @@ function fail(message) { throw Object.assign(new Error(message), { code: 'ENTROR
|
|
|
9
9
|
export async function loadTaskHandoffSigner(ctx, file, keyId) {
|
|
10
10
|
async function control(target) {
|
|
11
11
|
if (typeof target !== 'string' || !path.isAbsolute(target)) fail('runner signer controls are unavailable; an external absolute configuration is required');
|
|
12
|
-
|
|
12
|
+
let actual;
|
|
13
|
+
try { actual = await fs.realpath(target); }
|
|
14
|
+
catch { fail('runner signer controls are unavailable; an external absolute configuration is required'); }
|
|
15
|
+
const relative = path.relative(ctx.root, actual);
|
|
13
16
|
if (actual !== path.resolve(target) || relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) fail('runner signer controls must be external real files');
|
|
14
17
|
const value = await regular(target, 16 * 1024);
|
|
15
18
|
if ((process.platform !== 'win32' && (value.stat.mode & 0o077)) || (typeof process.getuid === 'function' && value.stat.uid !== process.getuid())) fail('runner signer controls must be owned private files');
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, randomUUID, sign, verify } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { runApprovedPlanObject, RUNNER_LIMITS } from './local-approved-plan.mjs';
|
|
6
|
+
import { assertBudgetRunnerAllowed } from './local-budget-reads.mjs';
|
|
7
|
+
|
|
8
|
+
export const ISOLATION_SCHEMA17 = 'entrorove.validator-isolation-receipt/1.0';
|
|
9
|
+
const CONTROL_SCHEMA = 'entrorove.validator-isolation-control/1.0';
|
|
10
|
+
const SANDBOX = '/usr/bin/sandbox-exec';
|
|
11
|
+
const MARKER = 'ENTROROVE_ISOLATION17:';
|
|
12
|
+
export const isolationCanonical17 = value => Array.isArray(value) ? `[${value.map(isolationCanonical17).join(',')}]`
|
|
13
|
+
: value && typeof value === 'object' ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${isolationCanonical17(value[key])}`).join(',')}}` : JSON.stringify(value);
|
|
14
|
+
export const isolationDigest17 = value => createHash('sha256').update(value).digest('hex');
|
|
15
|
+
function fail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
16
|
+
function exact(value, fields, label) {
|
|
17
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join('|') !== [...fields].sort().join('|')) {
|
|
18
|
+
fail('ENTROROVE_ISOLATION_INVALID', `${label} requires its exact original fields`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function platform() {
|
|
22
|
+
if (process.platform !== 'darwin') fail('ENTROROVE_ISOLATION_PLATFORM_UNSUPPORTED', 'Validator isolation17 requires the macOS Seatbelt backend');
|
|
23
|
+
}
|
|
24
|
+
function inside(root, target) { const r = path.relative(root, target); return r === '' || !path.isAbsolute(r) && r !== '..' && !r.startsWith('../'); }
|
|
25
|
+
async function directory(value, privateDirectory = false) {
|
|
26
|
+
if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_ISOLATION_PATH', 'isolation directory must be absolute');
|
|
27
|
+
const canonical = await fs.realpath(value), stat = await fs.lstat(canonical);
|
|
28
|
+
if (canonical !== value || !stat.isDirectory() || stat.isSymbolicLink() || privateDirectory && (stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o700)) {
|
|
29
|
+
fail('ENTROROVE_ISOLATION_PATH', 'isolation directory must be canonical, owned and private where required');
|
|
30
|
+
}
|
|
31
|
+
return canonical;
|
|
32
|
+
}
|
|
33
|
+
async function regular(file, privateFile = false) {
|
|
34
|
+
const stat = await fs.lstat(file);
|
|
35
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > RUNNER_LIMITS.maxExecutableBytes || await fs.realpath(file) !== file
|
|
36
|
+
|| privateFile && (stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1 || stat.size > RUNNER_LIMITS.maxControlBytes)) {
|
|
37
|
+
fail('ENTROROVE_ISOLATION_FILE', 'isolation input must be its canonical bounded regular file');
|
|
38
|
+
}
|
|
39
|
+
const bytes = await fs.readFile(file); return { bytes, sha256: isolationDigest17(bytes) };
|
|
40
|
+
}
|
|
41
|
+
async function privateControl(root, file) {
|
|
42
|
+
if (typeof file !== 'string' || !path.isAbsolute(file) || inside(root, file)) fail('ENTROROVE_ISOLATION_CONTROL', 'isolation controls must be outside the repository');
|
|
43
|
+
await directory(path.dirname(file), true); return regular(file, true);
|
|
44
|
+
}
|
|
45
|
+
const BOOTSTRAP = `import {readFileSync,writeFileSync} from 'node:fs';
|
|
46
|
+
import net from 'node:net';import {spawn} from 'node:child_process';
|
|
47
|
+
const [nonce,readTarget,writeTarget,port,executable,...args]=process.argv.slice(2);
|
|
48
|
+
const denied=work=>{try{work();return {denied:false,code:'ALLOWED'};}catch(e){return {denied:['EPERM','EACCES'].includes(e.code),code:e.code??'UNKNOWN'};}};
|
|
49
|
+
const probes={externalRead:denied(()=>readFileSync(readTarget)),externalWrite:denied(()=>writeFileSync(writeTarget,'DENIED17'))};
|
|
50
|
+
probes.network=await new Promise(resolve=>{const socket=net.connect({host:'127.0.0.1',port:Number(port)});let done=false;
|
|
51
|
+
const finish=result=>{if(done)return;done=true;clearTimeout(timer);socket.destroy();resolve(result)};
|
|
52
|
+
const timer=setTimeout(()=>finish({denied:false,code:'TIMEOUT'}),2000);
|
|
53
|
+
socket.once('connect',()=>finish({denied:false,code:'ALLOWED'}));socket.once('error',e=>finish({denied:['EPERM','EACCES'].includes(e.code),code:e.code??'UNKNOWN'}));});
|
|
54
|
+
process.stdout.write('${MARKER}'+JSON.stringify({nonce,probes})+'\\n');
|
|
55
|
+
if(Object.values(probes).some(p=>!p.denied)){process.exitCode=78;}else{
|
|
56
|
+
const child=spawn(executable,args,{cwd:process.cwd(),env:process.env,shell:false,stdio:['ignore','inherit','inherit']});
|
|
57
|
+
child.once('error',()=>{process.exitCode=79});child.once('close',(code,signal)=>{if(signal)process.kill(process.pid,signal);else process.exitCode=code??79;});}
|
|
58
|
+
`;
|
|
59
|
+
export const ISOLATION_BOOTSTRAP_SHA25617 = isolationDigest17(BOOTSTRAP);
|
|
60
|
+
function ancestors(files) {
|
|
61
|
+
const result = new Set();
|
|
62
|
+
for (const file of files) { let at = path.dirname(file); while (true) { result.add(at); if (at === '/') break; at = path.dirname(at); } }
|
|
63
|
+
return [...result].sort();
|
|
64
|
+
}
|
|
65
|
+
function profile(root, plan, node, bootstrapPath) {
|
|
66
|
+
const files = [...plan.files.map(file => path.resolve(root, file.path)), node, plan.policy.executable, bootstrapPath];
|
|
67
|
+
if (plan.files.some(file => !inside(root, path.resolve(root, file.path)) || !file.path || path.isAbsolute(file.path))) {
|
|
68
|
+
fail('ENTROROVE_ISOLATION_MANIFEST', 'isolation reads require the exact original repository manifest');
|
|
69
|
+
}
|
|
70
|
+
const literals = values => [...new Set(values)].sort().map(value => `(literal ${JSON.stringify(value)})`).join(' ');
|
|
71
|
+
return ['(version 1)', '(deny default)', '(allow process-fork)', `(allow process-exec ${literals([node, plan.policy.executable])})`,
|
|
72
|
+
'(allow signal (target self))', '(allow sysctl-read)', '(allow file-read-metadata)',
|
|
73
|
+
'(allow file-read-data (subpath "/System/Library") (subpath "/usr/lib") (subpath "/private/var/db/dyld")'
|
|
74
|
+
+ ` (literal "/dev/null") (literal "/dev/random") (literal "/dev/urandom") ${literals(files)})`,
|
|
75
|
+
`(allow file-read* ${literals(ancestors([...files, path.join(root, 'directory-entry')]))})`].join('\n');
|
|
76
|
+
}
|
|
77
|
+
function wrapped(control) {
|
|
78
|
+
const plan = structuredClone(control.originalPlan);
|
|
79
|
+
plan.policy = { ...plan.policy, executable: SANDBOX, executableSha256: control.sandboxSha256,
|
|
80
|
+
args: ['-f', control.profilePath, control.nodeExecutable, control.bootstrapPath,
|
|
81
|
+
control.nonce, control.readTarget, control.writeTarget, String(control.networkProbePort), control.originalPlan.policy.executable, ...control.originalPlan.policy.args] };
|
|
82
|
+
return plan;
|
|
83
|
+
}
|
|
84
|
+
const command = plan => [plan.policy.executable, ...plan.policy.args].map(value => JSON.stringify(value)).join(' ');
|
|
85
|
+
function guardEnvironment(environment) {
|
|
86
|
+
if (!environment || typeof environment !== 'object' || Object.keys(environment).some(key => /^(DYLD_|LD_|NODE_OPTIONS$|NODE_PATH$)/.test(key))) {
|
|
87
|
+
fail('ENTROROVE_ISOLATION_ENVIRONMENT', 'isolation cannot inherit loader injection or alternate Node startup policies');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function prepareValidatorIsolation17(input) {
|
|
91
|
+
platform(); await assertBudgetRunnerAllowed(input);
|
|
92
|
+
const fields = ['repositoryRoot', 'executionPlan', 'controlRoot', ...['networkProbePort', 'home', 'flowId'].filter(key => input[key] !== undefined)];
|
|
93
|
+
exact(input, fields, 'isolation preparation');
|
|
94
|
+
const root = await directory(input.repositoryRoot), controlRoot = await directory(input.controlRoot, true);
|
|
95
|
+
if (inside(root, controlRoot)) fail('ENTROROVE_ISOLATION_CONTROL', 'isolation control root must be external');
|
|
96
|
+
const originalPlan = structuredClone(input.executionPlan); guardEnvironment(originalPlan?.policy?.environment);
|
|
97
|
+
if (!Array.isArray(originalPlan?.files) || !originalPlan.files.length || typeof originalPlan.policy.executable !== 'string') {
|
|
98
|
+
fail('ENTROROVE_ISOLATION_PLAN', 'original frozen execution plan is required');
|
|
99
|
+
}
|
|
100
|
+
const nodeExecutable = await fs.realpath(process.execPath), node = await regular(nodeExecutable), sandbox = await regular(SANDBOX);
|
|
101
|
+
const original = await regular(originalPlan.policy.executable);
|
|
102
|
+
if (original.sha256 !== originalPlan.policy.executableSha256 || originalPlan.policy.executable === SANDBOX) {
|
|
103
|
+
fail('ENTROROVE_ISOLATION_EXECUTABLE', 'original approved executable must match its actual independent digest');
|
|
104
|
+
}
|
|
105
|
+
const port = input.networkProbePort ?? 9;
|
|
106
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) fail('ENTROROVE_ISOLATION_PROBE', 'network denial probe requires a bounded loopback port');
|
|
107
|
+
const owned = await fs.mkdtemp(path.join(controlRoot, 'isolation17-')), nonce = randomUUID();
|
|
108
|
+
const bootstrapPath = path.join(owned, 'bootstrap.mjs'), profilePath = path.join(owned, 'profile.sb');
|
|
109
|
+
const control = { schemaVersion: CONTROL_SCHEMA, repositoryRoot: root, originalPlan,
|
|
110
|
+
originalPlanSha256: isolationDigest17(isolationCanonical17(originalPlan) + '\n'), nonce,
|
|
111
|
+
nodeExecutable, nodeSha256: node.sha256, sandboxSha256: sandbox.sha256, profilePath, bootstrapPath,
|
|
112
|
+
producerSha256: (await regular(await fs.realpath(fileURLToPath(import.meta.url)))).sha256,
|
|
113
|
+
profile: profile(root, originalPlan, nodeExecutable, bootstrapPath),
|
|
114
|
+
readTarget: path.join(owned, 'outside-read.canary'), writeTarget: path.join(owned, 'outside-write.canary'), networkProbePort: port };
|
|
115
|
+
await fs.writeFile(bootstrapPath, BOOTSTRAP, { flag: 'wx', mode: 0o600 });
|
|
116
|
+
await fs.writeFile(profilePath, control.profile, { flag: 'wx', mode: 0o600 });
|
|
117
|
+
await fs.writeFile(control.readTarget, `CANARY17:${nonce}`, { flag: 'wx', mode: 0o600 });
|
|
118
|
+
const isolationControlPath = path.join(owned, 'control.json');
|
|
119
|
+
await fs.writeFile(isolationControlPath, isolationCanonical17(control) + '\n', { flag: 'wx', mode: 0o600 });
|
|
120
|
+
const executionPlan = wrapped(control);
|
|
121
|
+
if (command(executionPlan).length > 2000) fail('ENTROROVE_ISOLATION_COMMAND_LIMIT', 'wrapped command must fit the original exact native target limit');
|
|
122
|
+
return { executionPlan, command: command(executionPlan), isolationControlPath, originalPlanSha256: control.originalPlanSha256,
|
|
123
|
+
runnerIdentity: identity(control), approvalRequired: true };
|
|
124
|
+
}
|
|
125
|
+
export async function bindValidatorIsolation17(input) {
|
|
126
|
+
platform(); await assertBudgetRunnerAllowed(input);
|
|
127
|
+
exact(input, ['repositoryRoot', 'isolationControlPath', 'executionPlan', 'evidencePolicy',
|
|
128
|
+
...['home', 'flowId'].filter(key => input[key] !== undefined)], 'isolation evidence binding');
|
|
129
|
+
const loaded = await loadControl(input), policy = input.evidencePolicy;
|
|
130
|
+
exact(policy, ['kind', 'runner', 'flowId', 'stepId', 'localVerification'], 'isolation evidence policy');
|
|
131
|
+
exact(policy.localVerification, ['operation', 'requestedTarget', 'requirementsSha256'], 'native local verification');
|
|
132
|
+
if (!['test', 'security'].includes(policy.kind) || policy.runner !== 'sandbox'
|
|
133
|
+
|| !['sandbox-run', 'intrusive-test'].includes(policy.localVerification.operation)
|
|
134
|
+
|| policy.kind !== (policy.localVerification.operation === 'sandbox-run' ? 'test' : 'security')
|
|
135
|
+
|| policy.localVerification.requestedTarget !== command(input.executionPlan)
|
|
136
|
+
|| !/^[0-9a-f]{64}$/.test(policy.localVerification.requirementsSha256)
|
|
137
|
+
|| [policy.flowId, policy.stepId].some(value => typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value))) {
|
|
138
|
+
fail('ENTROROVE_ISOLATION_BINDING', 'binding must declare its exact actual wrapped native request');
|
|
139
|
+
}
|
|
140
|
+
const control = loaded.value; control.originalPlan.evidencePolicy = structuredClone(policy);
|
|
141
|
+
control.originalPlanSha256 = isolationDigest17(isolationCanonical17(control.originalPlan) + '\n');
|
|
142
|
+
const temporary = input.isolationControlPath + '.' + randomUUID() + '.tmp';
|
|
143
|
+
try { await fs.writeFile(temporary, isolationCanonical17(control) + '\n', { flag: 'wx', mode: 0o600 }); await fs.rename(temporary, input.isolationControlPath); }
|
|
144
|
+
finally { await fs.rm(temporary, { force: true }); }
|
|
145
|
+
const executionPlan = wrapped(control);
|
|
146
|
+
return { executionPlan, command: command(executionPlan), isolationControlPath: input.isolationControlPath,
|
|
147
|
+
originalPlanSha256: control.originalPlanSha256, runnerIdentity: identity(control), approvalRequired: true };
|
|
148
|
+
}
|
|
149
|
+
function identity(control) {
|
|
150
|
+
return { backend: 'macos-seatbelt', platform: 'darwin', sandboxExecutable: SANDBOX,
|
|
151
|
+
sandboxExecutableSha256: control.sandboxSha256, nodeExecutable: control.nodeExecutable, nodeExecutableSha256: control.nodeSha256,
|
|
152
|
+
profilePath: control.profilePath, profileSha256: isolationDigest17(control.profile), bootstrapPath: control.bootstrapPath,
|
|
153
|
+
bootstrapSha256: ISOLATION_BOOTSTRAP_SHA25617, producerSha256: control.producerSha256, originalPlanSha256: control.originalPlanSha256 };
|
|
154
|
+
}
|
|
155
|
+
async function loadControl(input) {
|
|
156
|
+
const root = await directory(input.repositoryRoot), file = await privateControl(root, input.isolationControlPath);
|
|
157
|
+
let value; try { value = JSON.parse(file.bytes.toString('utf8')); } catch { fail('ENTROROVE_ISOLATION_CONTROL', 'isolation control JSON is invalid'); }
|
|
158
|
+
exact(value, ['schemaVersion', 'repositoryRoot', 'originalPlan', 'originalPlanSha256', 'nonce', 'nodeExecutable', 'nodeSha256',
|
|
159
|
+
'sandboxSha256', 'profilePath', 'bootstrapPath', 'producerSha256', 'profile', 'readTarget', 'writeTarget', 'networkProbePort'], 'isolation control');
|
|
160
|
+
if (value.schemaVersion !== CONTROL_SCHEMA || value.repositoryRoot !== root || value.nodeExecutable !== await fs.realpath(process.execPath)
|
|
161
|
+
|| value.originalPlanSha256 !== isolationDigest17(isolationCanonical17(value.originalPlan) + '\n')
|
|
162
|
+
|| value.profile !== profile(root, value.originalPlan, value.nodeExecutable, value.bootstrapPath)
|
|
163
|
+
|| isolationCanonical17(input.executionPlan) !== isolationCanonical17(wrapped(value))
|
|
164
|
+
|| value.readTarget !== path.join(path.dirname(input.isolationControlPath), 'outside-read.canary')
|
|
165
|
+
|| value.writeTarget !== path.join(path.dirname(input.isolationControlPath), 'outside-write.canary')
|
|
166
|
+
|| value.bootstrapPath !== path.join(path.dirname(input.isolationControlPath), 'bootstrap.mjs')
|
|
167
|
+
|| value.profilePath !== path.join(path.dirname(input.isolationControlPath), 'profile.sb')) {
|
|
168
|
+
fail('ENTROROVE_ISOLATION_CHANGED', 'isolation command, profile, source plan or private original control changed');
|
|
169
|
+
}
|
|
170
|
+
guardEnvironment(value.originalPlan.policy.environment);
|
|
171
|
+
if ((await privateControl(root, value.bootstrapPath)).sha256 !== ISOLATION_BOOTSTRAP_SHA25617
|
|
172
|
+
|| (await privateControl(root, value.profilePath)).sha256 !== isolationDigest17(value.profile)
|
|
173
|
+
|| (await regular(await fs.realpath(fileURLToPath(import.meta.url)))).sha256 !== value.producerSha256) {
|
|
174
|
+
fail('ENTROROVE_ISOLATION_CHANGED', 'actual private bootstrap, profile or first-party producer source changed');
|
|
175
|
+
}
|
|
176
|
+
for (const [binary, expected] of [[SANDBOX, value.sandboxSha256], [value.nodeExecutable, value.nodeSha256],
|
|
177
|
+
[value.originalPlan.policy.executable, value.originalPlan.policy.executableSha256]]) {
|
|
178
|
+
if ((await regular(binary)).sha256 !== expected) fail('ENTROROVE_ISOLATION_EXECUTABLE', 'actual isolation executable identity changed');
|
|
179
|
+
}
|
|
180
|
+
if ((await privateControl(root, value.readTarget)).bytes.toString('utf8') !== `CANARY17:${value.nonce}`) {
|
|
181
|
+
fail('ENTROROVE_ISOLATION_PROBE', 'original private external read canary changed');
|
|
182
|
+
}
|
|
183
|
+
try { await fs.lstat(value.writeTarget); fail('ENTROROVE_ISOLATION_PROBE', 'external write canary must not exist'); }
|
|
184
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
185
|
+
return { value, sha256: file.sha256 };
|
|
186
|
+
}
|
|
187
|
+
async function signing(input) {
|
|
188
|
+
const root = input.repositoryRoot, file = await privateControl(root, input.signerConfigPath);
|
|
189
|
+
let config; try { config = JSON.parse(file.bytes.toString('utf8')); } catch { fail('ENTROROVE_ISOLATION_SIGNER', 'external signer config JSON is invalid'); }
|
|
190
|
+
exact(config, ['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs'], 'external signer');
|
|
191
|
+
const pinned = process.env.ENTROROVE_VALIDATOR_RECEIPT_PUBLIC_KEY?.trim();
|
|
192
|
+
if (!pinned || config.schemaVersion !== 'validator.runner-signer/1.0') fail('ENTROROVE_ISOLATION_SIGNER', 'trusted external Validator receipt public key is required');
|
|
193
|
+
let publicKey;
|
|
194
|
+
try { publicKey = createPublicKey(pinned.includes('BEGIN PUBLIC KEY') ? pinned : { key: Buffer.from(pinned, 'base64'), format: 'der', type: 'spki' }); }
|
|
195
|
+
catch { fail('ENTROROVE_ISOLATION_SIGNER', 'trusted Validator public key configuration is invalid'); }
|
|
196
|
+
if (publicKey.asymmetricKeyType !== 'ed25519' || config.keyId !== isolationDigest17(publicKey.export({ format: 'der', type: 'spki' }))) {
|
|
197
|
+
fail('ENTROROVE_ISOLATION_SIGNER', 'caller signer cannot replace the externally pinned Validator key');
|
|
198
|
+
}
|
|
199
|
+
const privateFile = await privateControl(root, config.privateKeyPath); let privateKey;
|
|
200
|
+
try { privateKey = createPrivateKey(privateFile.bytes); } finally { privateFile.bytes.fill(0); }
|
|
201
|
+
if (privateKey.asymmetricKeyType !== 'ed25519' || isolationDigest17(createPublicKey(privateKey).export({ format: 'der', type: 'spki' })) !== config.keyId) {
|
|
202
|
+
fail('ENTROROVE_ISOLATION_SIGNER', 'external signer private key differs from the configured trusted identity');
|
|
203
|
+
}
|
|
204
|
+
return { privateKey, publicKey, keyId: config.keyId, configSha256: file.sha256 };
|
|
205
|
+
}
|
|
206
|
+
function probes(result, control) {
|
|
207
|
+
const lines = result.process.stdout.split('\n').filter(line => line.startsWith(MARKER));
|
|
208
|
+
if (lines.length !== 1 || !result.process.stdout.startsWith(MARKER)) fail('ENTROROVE_ISOLATION_UNPROVEN', 'fixed isolation bootstrap did not produce one original denial result');
|
|
209
|
+
let value; try { value = JSON.parse(lines[0].slice(MARKER.length)); } catch { fail('ENTROROVE_ISOLATION_UNPROVEN', 'original isolation denial result is malformed'); }
|
|
210
|
+
exact(value, ['nonce', 'probes'], 'bootstrap result'); exact(value.probes, ['externalRead', 'externalWrite', 'network'], 'kernel probes');
|
|
211
|
+
if (value.nonce !== control.nonce) fail('ENTROROVE_ISOLATION_UNPROVEN', 'kernel probes lost their private original nonce');
|
|
212
|
+
for (const probe of Object.values(value.probes)) {
|
|
213
|
+
exact(probe, ['denied', 'code'], 'kernel denial');
|
|
214
|
+
if (probe.denied !== true || !['EPERM', 'EACCES'].includes(probe.code)) fail('ENTROROVE_ISOLATION_UNPROVEN', 'actual kernel did not deny every required external access');
|
|
215
|
+
}
|
|
216
|
+
return value.probes;
|
|
217
|
+
}
|
|
218
|
+
export async function runValidatorIsolation17(input) {
|
|
219
|
+
platform(); await assertBudgetRunnerAllowed(input);
|
|
220
|
+
exact(input, ['repositoryRoot', 'executionPlan', 'approvalPath', 'signerConfigPath', 'isolationControlPath',
|
|
221
|
+
...['home', 'flowId', 'executionDeadlineAt'].filter(key => input[key] !== undefined)], 'isolation execution');
|
|
222
|
+
const before = await loadControl(input), signer = await signing(input);
|
|
223
|
+
const { isolationControlPath: _control, ...runnerInput } = input;
|
|
224
|
+
const result = await runApprovedPlanObject(runnerInput), actualProbes = probes(result, before.value);
|
|
225
|
+
const after = await loadControl(input), afterSigner = await signing(input);
|
|
226
|
+
if (after.sha256 !== before.sha256 || afterSigner.configSha256 !== signer.configSha256) fail('ENTROROVE_ISOLATION_CHANGED', 'original isolation authority changed during execution');
|
|
227
|
+
const evidence = result.evidence[0], receipt = evidence?.receipt;
|
|
228
|
+
if (!receipt || receipt.keyId !== signer.keyId || receipt.subjectDigest !== evidence.subjectDigest || receipt.subjectDigest !== isolationDigest17(isolationCanonical17(result.subject))) {
|
|
229
|
+
fail('ENTROROVE_ISOLATION_UNPROVEN', 'actual approved execution has no original trusted bound receipt');
|
|
230
|
+
}
|
|
231
|
+
const { signature, ...original } = receipt;
|
|
232
|
+
if (!verify(null, Buffer.from(isolationCanonical17(original)), signer.publicKey, Buffer.from(signature, 'base64url'))) {
|
|
233
|
+
fail('ENTROROVE_ISOLATION_UNPROVEN', 'original approved execution signature is invalid');
|
|
234
|
+
}
|
|
235
|
+
const issuedAt = new Date().toISOString();
|
|
236
|
+
if (Date.parse(receipt.expiresAt) <= Date.parse(issuedAt)) fail('ENTROROVE_ISOLATION_EXPIRED', 'original approved receipt expired before isolation attestation');
|
|
237
|
+
const body = { schemaVersion: ISOLATION_SCHEMA17, keyId: signer.keyId, subjectDigest: evidence.subjectDigest,
|
|
238
|
+
evidenceReceiptDigest: isolationDigest17(isolationCanonical17(receipt)), evidenceReceiptNonce: receipt.nonce,
|
|
239
|
+
processDigest: isolationDigest17(isolationCanonical17(result.process)), planSha256: result.planSha256,
|
|
240
|
+
approvalSha256: result.approvalSha256, runnerIdentity: identity(before.value), probes: actualProbes, issuedAt, expiresAt: receipt.expiresAt };
|
|
241
|
+
return { ...result, isolationReceipt: { ...body, signature: sign(null, Buffer.from(isolationCanonical17(body)), signer.privateKey).toString('base64url') } };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const shaSchema = { type: 'string', pattern: '^[0-9a-f]{64}$' };
|
|
245
|
+
const stringSchema = { type: 'string', minLength: 1 };
|
|
246
|
+
const objectSchema = properties => ({ type: 'object', additionalProperties: false, required: Object.keys(properties), properties });
|
|
247
|
+
const denialSchema = objectSchema({ denied: { const: true }, code: { enum: ['EPERM', 'EACCES'] } });
|
|
248
|
+
export const VALIDATOR_ISOLATION_RECEIPT_SCHEMA17 = objectSchema({ schemaVersion: { const: ISOLATION_SCHEMA17 }, keyId: shaSchema,
|
|
249
|
+
subjectDigest: shaSchema, evidenceReceiptDigest: shaSchema, evidenceReceiptNonce: stringSchema, processDigest: shaSchema,
|
|
250
|
+
planSha256: shaSchema, approvalSha256: shaSchema, runnerIdentity: objectSchema({ backend: { const: 'macos-seatbelt' },
|
|
251
|
+
platform: { const: 'darwin' }, sandboxExecutable: { const: SANDBOX }, sandboxExecutableSha256: shaSchema,
|
|
252
|
+
nodeExecutable: stringSchema, nodeExecutableSha256: shaSchema, profilePath: stringSchema, profileSha256: shaSchema,
|
|
253
|
+
bootstrapPath: stringSchema, bootstrapSha256: shaSchema, producerSha256: shaSchema, originalPlanSha256: shaSchema }),
|
|
254
|
+
probes: objectSchema({ externalRead: denialSchema, externalWrite: denialSchema, network: denialSchema }),
|
|
255
|
+
issuedAt: { ...stringSchema, format: 'date-time' }, expiresAt: { ...stringSchema, format: 'date-time' },
|
|
256
|
+
signature: { type: 'string', pattern: '^[A-Za-z0-9_-]{86}$' } });
|
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entrorove/skill",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"entrorove": "cli.mjs"
|
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
"local-swarm-state.mjs",
|
|
25
25
|
"local-swarm-storage.mjs",
|
|
26
26
|
"local-swarm.mjs",
|
|
27
|
+
"local-autocoord17-authority.mjs",
|
|
28
|
+
"local-autocoord17-controller.mjs",
|
|
29
|
+
"local-autocoord17-peers.mjs",
|
|
30
|
+
"local-autocoord17-store.mjs",
|
|
31
|
+
"local-autocoord17-tasks.mjs",
|
|
32
|
+
"local-autocoord17-waits.mjs",
|
|
33
|
+
"local-validator-isolation17.mjs",
|
|
34
|
+
"local-calctool-composition17.mjs",
|
|
27
35
|
"local-mutation-files.mjs",
|
|
28
36
|
"local-mutation-snapshot.mjs",
|
|
29
37
|
"local-mutation-audit.mjs",
|
|
@@ -35,8 +43,19 @@
|
|
|
35
43
|
"local-mutation-batch-receipt.mjs",
|
|
36
44
|
"local-integrity.mjs",
|
|
37
45
|
"local-evidence-verification.mjs",
|
|
46
|
+
"local-error.mjs",
|
|
38
47
|
"local-formula.mjs",
|
|
39
48
|
"local-ledger.mjs",
|
|
49
|
+
"local-chain-bindings.mjs",
|
|
50
|
+
"local-chain-broker.mjs",
|
|
51
|
+
"local-chain-contracts.mjs",
|
|
52
|
+
"local-chain-controller.mjs",
|
|
53
|
+
"local-chain-human.mjs",
|
|
54
|
+
"local-chain-lock.mjs",
|
|
55
|
+
"local-chain-operations.mjs",
|
|
56
|
+
"local-chain-plan.mjs",
|
|
57
|
+
"local-chain-release-labels.mjs",
|
|
58
|
+
"local-chain-results.mjs",
|
|
40
59
|
"local-approved-plan.mjs",
|
|
41
60
|
"workflow-local-runner.mjs",
|
|
42
61
|
"workflow-local-client.mjs",
|
|
@@ -133,6 +152,7 @@
|
|
|
133
152
|
"local-formula-tests.mjs",
|
|
134
153
|
"local-calctool.mjs",
|
|
135
154
|
"local-calctool-cli.mjs",
|
|
155
|
+
"local-error.mjs",
|
|
136
156
|
"local-calctool-contracts.mjs",
|
|
137
157
|
"local-calctool-runtime.mjs",
|
|
138
158
|
"local-calctool-storage.mjs",
|
|
@@ -165,7 +185,7 @@
|
|
|
165
185
|
"local-final-gate16-release.mjs"
|
|
166
186
|
],
|
|
167
187
|
"scripts": {
|
|
168
|
-
"test": "node --test test/*.test.mjs",
|
|
188
|
+
"test": "node --test --test-concurrency=1 test/*.test.mjs",
|
|
169
189
|
"prepublishOnly": "node release.mjs",
|
|
170
190
|
"prepare:archguard": "node archguard-runtime-build.mjs",
|
|
171
191
|
"check:archguard": "node archguard-runtime-build.mjs --check",
|