@entrorove/skill 0.1.70 → 0.1.73
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/CHANGELOG.md +15 -0
- package/IDE-EXECUTION.md +4 -0
- package/SKILL.md +3 -2
- package/local-budget-reads.mjs +7 -2
- package/local-budget.mjs +3 -2
- package/local-probe.mjs +16 -7
- package/local-scope-build-data.mjs +73 -0
- package/local-scope-build-facts.mjs +33 -0
- package/local-scope-build-io.mjs +57 -0
- package/local-scope-build-read.mjs +22 -0
- package/local-scope-build-readset.mjs +49 -0
- package/local-scope-files.mjs +20 -7
- package/local-scope-repository.mjs +47 -37
- package/local-scope-signature.mjs +6 -4
- package/local-scope-state.mjs +7 -5
- package/local-scope-uni-build.mjs +69 -119
- package/local-scope-uni-conditional.mjs +8 -6
- package/local-scope-uni-directives.mjs +5 -5
- package/local-scope-uni-expressions.mjs +10 -5
- package/local-scope-workspaces.mjs +3 -3
- package/manifest.json +1 -1
- package/package.json +12 -2
- package/release-source-groups.mjs +46 -0
- package/release-state.json +40 -30
- package/release.mjs +6 -40
- package/releases.json +18 -0
- package/remote-client.mjs +1 -1
- package/remote-transport.mjs +14 -0
- package/scope-amendment-client.mjs +31 -19
- package/scope-amendment-reference.mjs +22 -4
- package/scope-amendment-state.mjs +1 -1
- package/scope-context-client.mjs +17 -8
- package/scope-context-journal.mjs +5 -4
- package/scope-context-observer.mjs +3 -2
- package/scope-context-remote.mjs +5 -5
- package/scope-context-state.mjs +4 -1
- package/scope-context-wire.mjs +9 -1
- package/scope-evidence-local.mjs +52 -0
- package/scope-evidence-remote.mjs +78 -0
- package/scope-evidence-wire.mjs +120 -0
- package/scope-runner-client.mjs +65 -35
- package/scope-runner-journal.mjs +32 -0
package/scope-context-client.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { scopeContextLocation, effectiveScopeContexts, retainContextAuthority,
|
|
|
9
9
|
import { contextRemote } from './scope-context-remote.mjs';
|
|
10
10
|
import { inspectContextEvidence, observeScopeContext, prepareScopeContext } from './scope-context-observer.mjs';
|
|
11
11
|
import { contextJournal, renewContextChallenge } from './scope-context-journal.mjs';
|
|
12
|
+
import { saveLocalScopeEvidence, ensureScopeEvidence, validateAppliedEvidence } from './scope-evidence-remote.mjs';
|
|
12
13
|
|
|
13
14
|
const schemaVersion = 'entrorove.scope-context-connection/1.0';
|
|
14
15
|
function proposalFor(status, input, location) {
|
|
@@ -56,8 +57,10 @@ async function review(location, state, dependencies) {
|
|
|
56
57
|
async function applied(location, state) {
|
|
57
58
|
const effective = state.applied.contextAuthority;
|
|
58
59
|
const challenge = contextChallenge((state.signingChallenge ?? state.challenged).challenge, location, state.proposal, { allowExpired: true });
|
|
60
|
+
const compact = effective?.record?.schemaVersion === 'entrorove.scope-context-authority/2.0';
|
|
61
|
+
if (compact) await validateAppliedEvidence(effective.record, state, location, challenge, 'scope-context');
|
|
59
62
|
if (state.applied.grantsExecutionAuthority !== false || !effective?.record
|
|
60
|
-
|| canonical(effective.record.attestation) !== canonical(state.attestation)
|
|
63
|
+
|| !compact && canonical(effective.record.attestation) !== canonical(state.attestation)
|
|
61
64
|
|| ['proposalId', 'proposalDigest', 'ownerId', 'taskDigest', 'chainDigest'].some(field => effective.record[field] !== challenge[field])
|
|
62
65
|
|| canonical(effective.record.importContexts) !== canonical(challenge.importContexts)
|
|
63
66
|
|| canonical(effective.record.evidence) !== canonical(challenge.evidence)) contextFail('Applied context authority differs from this exact actual candidate');
|
|
@@ -76,14 +79,20 @@ async function apply(location, state, dependencies) {
|
|
|
76
79
|
state.proposal = proposal; await saveContextJournal(location, state);
|
|
77
80
|
if (!state.challenged) await contextRemote('challenged', 'scope-runner-context-challenge', { flowId: location.binding.flowId,
|
|
78
81
|
proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest, attemptId: state.attemptId }, state, location, dependencies);
|
|
79
|
-
if (!state.
|
|
82
|
+
if (!state.evidenceDescriptor) {
|
|
83
|
+
let attestation = state.attestation;
|
|
84
|
+
if (!attestation) {
|
|
80
85
|
const prepared = await prepareScopeContext(location, state, proposal);
|
|
81
|
-
|
|
86
|
+
attestation = await observeScopeContext(location, state, proposal, prepared,
|
|
82
87
|
() => renewContextChallenge(location, state, proposal, dependencies));
|
|
83
|
-
|
|
88
|
+
}
|
|
89
|
+
await saveLocalScopeEvidence(attestation, 'scope-context', (state.signingChallenge ?? state.challenged).challenge,
|
|
90
|
+
state, location, saveContextJournal);
|
|
84
91
|
}
|
|
92
|
+
const attestationRef = await ensureScopeEvidence(state, location, dependencies,
|
|
93
|
+
(state.signingChallenge ?? state.challenged).challenge, saveContextJournal);
|
|
85
94
|
await contextRemote('applied', 'scope-runner-context-apply', { flowId: location.binding.flowId,
|
|
86
|
-
proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest,
|
|
95
|
+
proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest, attestationRef }, state, location, dependencies);
|
|
87
96
|
return applied(location, state);
|
|
88
97
|
}
|
|
89
98
|
async function run(location, input, dependencies) {
|
|
@@ -96,7 +105,7 @@ async function run(location, input, dependencies) {
|
|
|
96
105
|
state.completed = true; await saveContextJournal(location, state);
|
|
97
106
|
return { schemaVersion, ...status, status: 'succeeded', flowId: input.flowId, grantsExecutionAuthority: false, claimsCompletion: false };
|
|
98
107
|
} catch (error) {
|
|
99
|
-
if (!state.pending) { state.rejection ??= { code: error.code, message: error.message }; await saveContextJournal(location, state); }
|
|
108
|
+
if (!state.pending && !state.evidencePending) { state.rejection ??= { code: error.code, message: error.message }; await saveContextJournal(location, state); }
|
|
100
109
|
throw error;
|
|
101
110
|
}
|
|
102
111
|
}
|
|
@@ -111,8 +120,8 @@ export async function runScopeContext(repositoryRoot, input, dependencies) {
|
|
|
111
120
|
return { schemaVersion, status: 'blocked', flowId: input?.flowId,
|
|
112
121
|
code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_CONTEXT_PREREQUISITE',
|
|
113
122
|
message: error?.code?.startsWith('ENTROROVE_') ? error.message : 'Context recovery stopped; preserve original signer, binding, journal and evidence.',
|
|
114
|
-
...(error?.requestId ? { requestId: error.requestId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
123
|
+
...(error?.requestId ? { requestId: error.requestId } : {}), ...(error?.uploadId ? { uploadId: error.uploadId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
115
124
|
...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-context', method: 'GET',
|
|
116
|
-
requestId: error.requestId, reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
125
|
+
...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
117
126
|
}
|
|
118
127
|
}
|
|
@@ -13,16 +13,17 @@ export async function archiveContextAttempt(location, state) {
|
|
|
13
13
|
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(file, state, { privateFrom: location.stateRoot }); }
|
|
14
14
|
}
|
|
15
15
|
function reusableSnapshot(state, input) {
|
|
16
|
-
return input.action === 'apply' && state.snapshot && state.challenged && !state.pending && !state.completed && !state.applied
|
|
16
|
+
return input.action === 'apply' && state.snapshot && state.challenged && !state.pending && !state.evidencePending && !state.completed && !state.applied
|
|
17
17
|
&& state.rejection && !state.rejection.requestId && !state.rejection.output;
|
|
18
18
|
}
|
|
19
19
|
export async function contextJournal(location, input) {
|
|
20
20
|
let state = await readContextJournal(location, input);
|
|
21
21
|
if (!state.previous && reusableSnapshot(state, input)) {
|
|
22
22
|
await archiveContextAttempt(location, state);
|
|
23
|
-
for (const field of ['rejection', 'checked', 'attestation', 'signingChallenge', 'signingAttemptId'
|
|
23
|
+
for (const field of ['rejection', 'checked', 'attestation', 'signingChallenge', 'signingAttemptId',
|
|
24
|
+
'evidenceDescriptor', 'evidenceSignature', 'evidencePrepared', 'attestationRef', 'evidenceResult']) delete state[field];
|
|
24
25
|
await saveContextJournal(location, state);
|
|
25
|
-
} else if (state.previous || !state.pending && !state.applied && (state.rejection
|
|
26
|
+
} else if (state.previous || !state.pending && !state.evidencePending && !state.applied && (state.rejection
|
|
26
27
|
|| input.action === 'status' && state.completed || input.action === 'apply' && state.completed)) {
|
|
27
28
|
await archiveContextAttempt(location, state.previous ?? state); state = {};
|
|
28
29
|
}
|
|
@@ -33,7 +34,7 @@ export async function contextJournal(location, input) {
|
|
|
33
34
|
return state;
|
|
34
35
|
}
|
|
35
36
|
export async function renewContextChallenge(location, state, proposal, dependencies) {
|
|
36
|
-
if (state.pending) contextFail('Recover the original pending request before any challenge renewal');
|
|
37
|
+
if (state.pending || state.evidencePending) contextFail('Recover the original pending request before any challenge renewal');
|
|
37
38
|
await archiveContextAttempt(location, state);
|
|
38
39
|
delete state.signingChallenge;
|
|
39
40
|
state.signingAttemptId = randomUUID(); await saveContextJournal(location, state);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID, sign } from 'node:crypto';
|
|
2
2
|
import { canonical } from './local-integrity.mjs';
|
|
3
|
-
import {
|
|
3
|
+
import { scopeBuildSourceRead, scopeTarget } from './local-scope-files.mjs';
|
|
4
4
|
import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
|
|
5
5
|
import { collectScopeFacts } from './local-scope-repository.mjs';
|
|
6
6
|
import { readScopeSnapshot, verifyFreshScopeSnapshot } from './local-scope-snapshot.mjs';
|
|
@@ -20,7 +20,8 @@ export async function inspectContextEvidence(location, contexts, evidencePaths)
|
|
|
20
20
|
for (const relative of [...paths].sort()) {
|
|
21
21
|
const target = await scopeTarget(location.root, relative);
|
|
22
22
|
if (!target.stat.isFile() || target.stat.size > 1_048_576) contextFail('Build evidence must be an actual ordinary bounded repository file');
|
|
23
|
-
const read = await
|
|
23
|
+
const read = await scopeBuildSourceRead(location.root, relative, { repositoryRoot: location.root });
|
|
24
|
+
if (read.bytes > 1_048_576) contextFail('Build evidence exceeds its actual 1 MiB inspection bound');
|
|
24
25
|
evidence.push({ path: relative, sha256: read.sha256 });
|
|
25
26
|
}
|
|
26
27
|
return evidence;
|
package/scope-context-remote.mjs
CHANGED
|
@@ -30,18 +30,18 @@ async function notSent(pending, home) {
|
|
|
30
30
|
return !rows.length || rows.at(-1).event === 'not-sent';
|
|
31
31
|
} catch (error) { return error.code === 'ENOENT'; }
|
|
32
32
|
}
|
|
33
|
-
export async function contextRemote(stage, operation, input, state, location, dependencies) {
|
|
33
|
+
export async function contextRemote(stage, operation, input, state, location, dependencies, save = saveContextJournal) {
|
|
34
34
|
const requestId = buildInvocation(operation, input).requestId; let response, result;
|
|
35
35
|
if (state.pending) {
|
|
36
36
|
if (state.pending.stage !== stage || state.pending.requestId !== requestId || state.pending.operation !== operation
|
|
37
37
|
|| canonical(state.pending.input) !== canonical(input)) contextFail('Recover the original context POST before another operation');
|
|
38
38
|
({ response, result } = await recover(state.pending, dependencies));
|
|
39
39
|
} else {
|
|
40
|
-
state.pending = { stage, operation, input, requestId }; await
|
|
40
|
+
state.pending = { stage, operation, input, requestId }; await save(location, state);
|
|
41
41
|
try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
|
|
42
42
|
catch (error) {
|
|
43
43
|
if (await notSent(state.pending, dependencies.home)) {
|
|
44
|
-
delete state.pending; await
|
|
44
|
+
delete state.pending; await save(location, state);
|
|
45
45
|
throw Object.assign(new Error('Context request was not transmitted; repair its prerequisite and reuse the original input'),
|
|
46
46
|
{ code: 'ENTROROVE_SCOPE_CONTEXT_NOT_SENT', knownBeforeTransmission: true, requestId });
|
|
47
47
|
}
|
|
@@ -51,10 +51,10 @@ export async function contextRemote(stage, operation, input, state, location, de
|
|
|
51
51
|
if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
|
|
52
52
|
if (result.feedback) state.feedback = result.feedback;
|
|
53
53
|
if (!response.ok || !['succeeded', 'needs-input'].includes(result.output.status)) {
|
|
54
|
-
state.rejection = { requestId, output: result.output }; delete state.pending; await
|
|
54
|
+
state.rejection = { requestId, output: result.output }; delete state.pending; await save(location, state);
|
|
55
55
|
throw Object.assign(new Error('Original context request was rejected; preserve its exact evidence and history'),
|
|
56
56
|
{ code: result.output.code ?? 'ENTROROVE_SCOPE_CONTEXT_REJECTED', requestId, output: result.output });
|
|
57
57
|
}
|
|
58
|
-
state[stage] = result.output.result ?? result.output; delete state.pending; await
|
|
58
|
+
state[stage] = result.output.result ?? result.output; delete state.pending; await save(location, state);
|
|
59
59
|
return state[stage];
|
|
60
60
|
}
|
package/scope-context-state.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { mutationHome14, mutationFile14, readMutationJson14, atomicMutationJson1
|
|
|
8
8
|
import { trustedOrigin } from './remote-transport.mjs';
|
|
9
9
|
import { contextExact, contextFail, contextList, validateContextAuthority } from './scope-context-wire.mjs';
|
|
10
10
|
import { hydrateCloudMutationAuthority, bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
|
|
11
|
+
import { readScopeEvidence } from './scope-evidence-local.mjs';
|
|
11
12
|
|
|
12
13
|
export async function contextPrivate(file, final = 'file', maximumBytes = 8_000_000) {
|
|
13
14
|
const item = await mutationFile14(file, { final });
|
|
@@ -53,6 +54,7 @@ export async function effectiveScopeContexts(location) {
|
|
|
53
54
|
for (const name of names) {
|
|
54
55
|
const file = path.join(directory, name); await contextPrivate(file, 'file', 1_000_000);
|
|
55
56
|
const saved = await readMutationJson14(file), checked = validateContextAuthority(saved.record, location, current);
|
|
57
|
+
if (checked.record.attestationRef) await readScopeEvidence(location, checked.record.attestationRef);
|
|
56
58
|
if (saved.revisionDigest !== checked.revisionDigest || name !== `${String(checked.record.revision).padStart(6, '0')}-${checked.revisionDigest}.json`) contextFail('Context history checksum or order changed');
|
|
57
59
|
current = checked;
|
|
58
60
|
}
|
|
@@ -68,6 +70,7 @@ export async function retainContextAuthority(location, effective) {
|
|
|
68
70
|
const current = await effectiveScopeContexts(location);
|
|
69
71
|
if (current?.revisionDigest === effective?.revisionDigest && canonical(current.record) === canonical(effective.record)) return current;
|
|
70
72
|
const checked = validateContextAuthority(effective?.record, location, current);
|
|
73
|
+
if (checked.record.attestationRef) await readScopeEvidence(location, checked.record.attestationRef);
|
|
71
74
|
if (effective.revisionDigest !== checked.revisionDigest) contextFail('Applied context authority checksum changed');
|
|
72
75
|
const directory = path.join(location.base, 'context-authority'), file = path.join(directory, `${String(checked.record.revision).padStart(6, '0')}-${checked.revisionDigest}.json`);
|
|
73
76
|
await atomicMutationJson14(file, checked, { privateFrom: location.stateRoot }); return checked;
|
|
@@ -77,7 +80,7 @@ export async function readContextJournal(location, input) {
|
|
|
77
80
|
await contextPrivate(location.file);
|
|
78
81
|
const value = await readMutationJson14(location.file);
|
|
79
82
|
if (value.schemaVersion !== 'entrorove.scope-context-journal/1.0' || value.localBindingDigest !== location.localBindingDigest) contextFail('Context journal cannot replace its original identity');
|
|
80
|
-
if (value.pending && canonical(value.input) !== canonical(input)) contextFail('Recover the original pending context request before another operation');
|
|
83
|
+
if ((value.pending || value.evidencePending) && canonical(value.input) !== canonical(input)) contextFail('Recover the original pending context request before another operation');
|
|
81
84
|
return canonical(value.input) === canonical(input) ? value : { previous: value };
|
|
82
85
|
} catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
|
|
83
86
|
}
|
package/scope-context-wire.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
2
2
|
import { scopeFail, scopePath } from './local-scope-files.mjs';
|
|
3
3
|
import { scopeRevisionAuthority } from './scope-amendment-wire.mjs';
|
|
4
|
+
import { evidenceReference } from './scope-evidence-wire.mjs';
|
|
4
5
|
|
|
5
6
|
const id = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
6
7
|
const hash = /^[a-f0-9]{64}$/;
|
|
@@ -51,7 +52,8 @@ export function contextInput(input) {
|
|
|
51
52
|
export const originalContextDigest = location => sha256(canonical({ localBindingDigest: location.localBindingDigest,
|
|
52
53
|
importContexts: location.binding.importContexts }));
|
|
53
54
|
export function validateContextAuthority(value, location, previous) {
|
|
54
|
-
const
|
|
55
|
+
const version = value?.schemaVersion === 'entrorove.scope-context-authority/2.0' ? '2.0' : '1.0';
|
|
56
|
+
const record = scopeRevisionAuthority(value, `entrorove.scope-context-authority/${version}`);
|
|
55
57
|
const expectedRevision = (previous?.record.revision ?? 0) + 1;
|
|
56
58
|
if (['flowId', 'runnerId', 'repositoryId', 'keyId'].some(field => record[field] !== location.binding[field])
|
|
57
59
|
|| record.localBindingDigest !== location.localBindingDigest || record.revision !== expectedRevision
|
|
@@ -64,6 +66,12 @@ export function validateContextAuthority(value, location, previous) {
|
|
|
64
66
|
contextEvidence(record.evidence);
|
|
65
67
|
if (previous && ['ownerId', 'taskDigest', 'chainDigest'].some(field => record[field] !== previous.record[field])
|
|
66
68
|
|| record.importContexts.some(context => !record.evidence.some(file => file.path === context.configPath))) contextFail('Context authority lost its original task or actual configuration evidence');
|
|
69
|
+
if (version === '2.0') {
|
|
70
|
+
const reference = evidenceReference(record.attestationRef, undefined, location);
|
|
71
|
+
if (reference.kind !== 'scope-context' || Object.hasOwn(record, 'attestation')
|
|
72
|
+
|| ['flowId', 'ownerId', 'taskDigest', 'chainDigest', 'proposalId', 'proposalDigest', 'challengeId'].some(field => record[field] !== reference[field])
|
|
73
|
+
|| !hash.test(record.challengeDigest ?? '')) contextFail('Compact context authority lost its exact evidence binding');
|
|
74
|
+
}
|
|
67
75
|
return { record, revisionDigest: sha256(canonical(record)) };
|
|
68
76
|
}
|
|
69
77
|
export function contextChallenge(value, location, proposal, { allowExpired = false } = {}) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { mutationFile14, writeMutationFile14 } from './local-mutation-files.mjs';
|
|
4
|
+
import { maximumScopeEvidenceBytes, evidenceFail, verifyEvidenceData } from './scope-evidence-wire.mjs';
|
|
5
|
+
|
|
6
|
+
function evidencePath(location, descriptor) {
|
|
7
|
+
if (!/^[a-f0-9]{64}$/.test(descriptor?.sha256 ?? '')) evidenceFail('Scope evidence requires its exact SHA256 derived private data file');
|
|
8
|
+
return path.join(location.base, 'evidence-data', `${descriptor.sha256}.json`);
|
|
9
|
+
}
|
|
10
|
+
async function privateEvidence(file) {
|
|
11
|
+
const item = await mutationFile14(file);
|
|
12
|
+
if (process.getuid && item.stat.uid !== process.getuid() || process.platform !== 'win32' && (item.stat.mode & 0o777) !== 0o600
|
|
13
|
+
|| item.stat.nlink !== 1 || item.stat.size < 1 || item.stat.size > maximumScopeEvidenceBytes) evidenceFail('Scope evidence must remain a bounded private single-link data file');
|
|
14
|
+
const directory = await mutationFile14(path.dirname(file), { final: 'directory' });
|
|
15
|
+
if (process.getuid && directory.stat.uid !== process.getuid()
|
|
16
|
+
|| process.platform !== 'win32' && (directory.stat.mode & 0o777) !== 0o700) evidenceFail('Scope evidence directory must remain private');
|
|
17
|
+
return item;
|
|
18
|
+
}
|
|
19
|
+
export async function readScopeEvidence(location, descriptor) {
|
|
20
|
+
const file = evidencePath(location, descriptor), before = await privateEvidence(file);
|
|
21
|
+
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
22
|
+
try {
|
|
23
|
+
const opened = await handle.stat();
|
|
24
|
+
const changed = stat => ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs', 'mode', 'nlink', 'uid'].some(field => stat[field] !== before.stat[field]);
|
|
25
|
+
if (changed(opened) || opened.size !== descriptor.canonicalBytes) evidenceFail('Scope evidence changed before its bounded private read');
|
|
26
|
+
const buffer = Buffer.alloc(opened.size + 1); let length = 0;
|
|
27
|
+
while (length < buffer.length) {
|
|
28
|
+
const chunk = await handle.read(buffer, length, buffer.length - length, length);
|
|
29
|
+
if (!chunk.bytesRead) break; length += chunk.bytesRead;
|
|
30
|
+
}
|
|
31
|
+
const bytes = buffer.subarray(0, length), after = await handle.stat(), latest = await privateEvidence(file);
|
|
32
|
+
if ([opened, after, latest.stat].some(stat => ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs', 'mode', 'nlink', 'uid']
|
|
33
|
+
.some(field => stat[field] !== before.stat[field])) || latest.ancestors.length !== before.ancestors.length
|
|
34
|
+
|| latest.ancestors.some((item, index) => item.path !== before.ancestors[index].path
|
|
35
|
+
|| item.stat.dev !== before.ancestors[index].stat.dev || item.stat.ino !== before.ancestors[index].stat.ino)) evidenceFail('Scope evidence changed during its stable private read');
|
|
36
|
+
let value;
|
|
37
|
+
try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
|
|
38
|
+
catch { evidenceFail('Scope evidence data has invalid UTF8 JSON'); }
|
|
39
|
+
if (!verifyEvidenceData(value, descriptor, location).equals(bytes)) evidenceFail('Scope evidence data lost its exact canonical bytes');
|
|
40
|
+
return value;
|
|
41
|
+
} finally { await handle.close(); }
|
|
42
|
+
}
|
|
43
|
+
export async function retainScopeEvidence(location, descriptor, value) {
|
|
44
|
+
const bytes = verifyEvidenceData(value, descriptor, location), file = evidencePath(location, descriptor);
|
|
45
|
+
try { await readScopeEvidence(location, descriptor); }
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error.code !== 'ENOENT') throw error;
|
|
48
|
+
await writeMutationFile14(file, bytes, { privateFrom: location.stateRoot });
|
|
49
|
+
await readScopeEvidence(location, descriptor);
|
|
50
|
+
}
|
|
51
|
+
return descriptor;
|
|
52
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
2
|
+
import { contextRemote } from './scope-context-remote.mjs';
|
|
3
|
+
import { readScopeEvidence, retainScopeEvidence } from './scope-evidence-local.mjs';
|
|
4
|
+
import { describeScopeEvidence, evidenceBytes, evidenceFail, evidenceUpload, evidenceReference } from './scope-evidence-wire.mjs';
|
|
5
|
+
import { requestTransport, putScopeEvidence, parseTransportResponse } from './remote-transport.mjs';
|
|
6
|
+
import { ideClientHeaders } from './ide-presentation.mjs';
|
|
7
|
+
|
|
8
|
+
const unknown = uploadId => Object.assign(new Error('Scope evidence upload outcome is unknown; recover the original reservation with GET only'),
|
|
9
|
+
{ code: 'ENTROROVE_SCOPE_EVIDENCE_UNKNOWN', outcomeUnknown: true, uploadId });
|
|
10
|
+
export async function saveLocalScopeEvidence(value, kind, challenge, state, location, save) {
|
|
11
|
+
const described = describeScopeEvidence(kind, value, challenge, location);
|
|
12
|
+
await retainScopeEvidence(location, described.descriptor, value);
|
|
13
|
+
state.evidenceDescriptor = described.descriptor; state.evidenceSignature = described.signature;
|
|
14
|
+
state.attestationDigest = described.descriptor.sha256;
|
|
15
|
+
delete state.attestation;
|
|
16
|
+
await save(location, state);
|
|
17
|
+
}
|
|
18
|
+
function resultReference(value, upload, descriptor, location) {
|
|
19
|
+
if (value?.schemaVersion !== 'entrorove.scope-evidence-upload-status/1.0' || value.uploadId !== upload.uploadId
|
|
20
|
+
|| value.grantsExecutionAuthority !== false || value.expiresAt !== upload.expiresAt
|
|
21
|
+
|| !['reserved', 'receiving', 'stored', 'rejected'].includes(value.status)) throw unknown(upload.uploadId);
|
|
22
|
+
if (value.status !== 'stored') return null;
|
|
23
|
+
const reference = evidenceReference(value.evidenceReference, descriptor, location);
|
|
24
|
+
if (['ownerId', 'taskDigest', 'chainDigest'].some(field => reference[field] !== upload[field])) evidenceFail('Stored scope evidence replaced its original account or task');
|
|
25
|
+
return reference;
|
|
26
|
+
}
|
|
27
|
+
async function acceptResult(response, upload, state, location, save) {
|
|
28
|
+
let value;
|
|
29
|
+
try { value = parseTransportResponse(response, 'scope evidence upload'); }
|
|
30
|
+
catch { throw unknown(upload.uploadId); }
|
|
31
|
+
const reference = resultReference(value, upload, state.evidenceDescriptor, location);
|
|
32
|
+
if (reference) {
|
|
33
|
+
state.attestationRef = reference; state.evidenceResult = value; delete state.evidencePending;
|
|
34
|
+
await save(location, state); return reference;
|
|
35
|
+
}
|
|
36
|
+
if (value.status === 'rejected' && /^ENTROROVE_EVIDENCE_[A-Z_]+$/.test(value.code ?? '')) {
|
|
37
|
+
state.evidenceResult = value; state.rejection = { uploadId: upload.uploadId, output: value };
|
|
38
|
+
delete state.evidencePending; await save(location, state);
|
|
39
|
+
throw Object.assign(new Error('The original evidence reservation was rejected; preserve it before another signed attempt'),
|
|
40
|
+
{ code: value.code, uploadId: upload.uploadId, output: value });
|
|
41
|
+
}
|
|
42
|
+
throw unknown(upload.uploadId);
|
|
43
|
+
}
|
|
44
|
+
export async function ensureScopeEvidence(state, location, dependencies, challenge, save) {
|
|
45
|
+
const descriptor = state.evidenceDescriptor;
|
|
46
|
+
if (!descriptor || !state.evidenceSignature) evidenceFail('Original signed evidence descriptor is unavailable');
|
|
47
|
+
const value = await readScopeEvidence(location, descriptor);
|
|
48
|
+
if (state.attestationRef) return evidenceReference(state.attestationRef, descriptor, location);
|
|
49
|
+
if (!state.evidencePrepared) await contextRemote('evidencePrepared', 'scope-evidence-prepare', {
|
|
50
|
+
flowId: descriptor.flowId, descriptor, signature: state.evidenceSignature }, state, location, dependencies, save);
|
|
51
|
+
const upload = evidenceUpload(state.evidencePrepared.upload, descriptor, location, challenge);
|
|
52
|
+
const token = await dependencies.readCredential(dependencies.home), headers = { authorization: `Bearer ${token}`, ...ideClientHeaders() };
|
|
53
|
+
const origin = dependencies.apiOrigin();
|
|
54
|
+
if (state.evidencePending) {
|
|
55
|
+
if (state.evidencePending.uploadId !== upload.uploadId || state.evidencePending.sha256 !== descriptor.sha256
|
|
56
|
+
|| state.evidencePending.canonicalBytes !== descriptor.canonicalBytes) evidenceFail('Recover the exact original evidence reservation first');
|
|
57
|
+
let response;
|
|
58
|
+
try { response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers }); }
|
|
59
|
+
catch { throw unknown(upload.uploadId); }
|
|
60
|
+
return acceptResult(response, upload, state, location, save);
|
|
61
|
+
}
|
|
62
|
+
state.evidencePending = { uploadId: upload.uploadId, sha256: descriptor.sha256, canonicalBytes: descriptor.canonicalBytes };
|
|
63
|
+
await save(location, state);
|
|
64
|
+
let response;
|
|
65
|
+
try {
|
|
66
|
+
if (Date.parse(upload.expiresAt) <= Date.now()) response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers });
|
|
67
|
+
else response = await putScopeEvidence(origin, upload.uploadId, evidenceBytes(value), headers);
|
|
68
|
+
} catch { throw unknown(upload.uploadId); }
|
|
69
|
+
return acceptResult(response, upload, state, location, save);
|
|
70
|
+
}
|
|
71
|
+
export async function validateAppliedEvidence(record, state, location, challenge, kind) {
|
|
72
|
+
const reference = evidenceReference(kind === 'scope-context' ? record.attestationRef : record.candidateAttestationRef,
|
|
73
|
+
state.evidenceDescriptor, location);
|
|
74
|
+
if (reference.kind !== kind || canonical(reference) !== canonical(state.attestationRef)
|
|
75
|
+
|| record.challengeId !== challenge.challengeId || record.challengeDigest !== sha256(canonical(challenge))
|
|
76
|
+
|| ['ownerId', 'taskDigest', 'chainDigest'].some(field => reference[field] !== challenge[field])) evidenceFail('Applied authority differs from its original uploaded S evidence');
|
|
77
|
+
return readScopeEvidence(location, reference);
|
|
78
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createPublicKey, sign, verify } from 'node:crypto';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { scopeRevisionAuthority } from './scope-amendment-wire.mjs';
|
|
4
|
+
|
|
5
|
+
export const maximumScopeEvidenceBytes = 16_000_000;
|
|
6
|
+
const hash = /^[a-f0-9]{64}$/, id = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
7
|
+
const common = ['kind', 'flowId', 'challengeId', 'runnerId', 'repositoryId', 'keyId', 'sha256', 'canonicalBytes'];
|
|
8
|
+
export const evidenceFail = message => { throw Object.assign(new Error(message), { code: 'ENTROROVE_SCOPE_EVIDENCE_BINDING' }); };
|
|
9
|
+
function exact(value, fields) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
11
|
+
|| Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) evidenceFail('Scope evidence fields are invalid');
|
|
12
|
+
}
|
|
13
|
+
function validString(value) {
|
|
14
|
+
if (value.includes('\0') || Buffer.byteLength(value) > maximumScopeEvidenceBytes) evidenceFail('Scope evidence string exceeds its valid bounded UTF8 representation');
|
|
15
|
+
for (let index = 0; index < value.length; index++) {
|
|
16
|
+
const code = value.charCodeAt(index);
|
|
17
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
18
|
+
const next = value.charCodeAt(++index);
|
|
19
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) evidenceFail('Scope evidence contains an invalid Unicode surrogate');
|
|
20
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) evidenceFail('Scope evidence contains an invalid Unicode surrogate');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function evidenceBytes(value) {
|
|
24
|
+
const stack = [[value, 0, false]], ancestors = new Set(); let nodes = 0;
|
|
25
|
+
while (stack.length) {
|
|
26
|
+
const [item, depth, leaving] = stack.pop();
|
|
27
|
+
if (leaving) { ancestors.delete(item); continue; }
|
|
28
|
+
if (++nodes > 250_000 || depth > 64) evidenceFail('Scope evidence exceeds its bounded structure');
|
|
29
|
+
if (typeof item === 'string') { validString(item); continue; }
|
|
30
|
+
if (item === null || typeof item === 'boolean') continue;
|
|
31
|
+
if (typeof item === 'number' && Number.isFinite(item)) continue;
|
|
32
|
+
if (!item || typeof item !== 'object' || ancestors.has(item)
|
|
33
|
+
|| !Array.isArray(item) && ![Object.prototype, null].includes(Object.getPrototypeOf(item))) evidenceFail('Scope evidence requires finite acyclic JSON');
|
|
34
|
+
ancestors.add(item); stack.push([item, depth, true]);
|
|
35
|
+
const fields = Object.getOwnPropertyDescriptors(item), keys = Reflect.ownKeys(fields);
|
|
36
|
+
if (Array.isArray(item) ? item.length > 100_000 : keys.length > 1024) evidenceFail('Scope evidence collection exceeds its bounded structure');
|
|
37
|
+
for (const key of keys) {
|
|
38
|
+
if (Array.isArray(item) && key === 'length') continue;
|
|
39
|
+
const field = fields[key];
|
|
40
|
+
if (typeof key !== 'string' || !Object.hasOwn(field, 'value') || !field.enumerable) evidenceFail('Scope evidence refuses accessors and non-JSON properties');
|
|
41
|
+
validString(key);
|
|
42
|
+
stack.push([field.value, depth + 1, false]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const bytes = Buffer.from(canonical(value));
|
|
46
|
+
if (!bytes.length || bytes.length > maximumScopeEvidenceBytes) evidenceFail('Scope evidence exceeds its separate sixteen million byte data limit');
|
|
47
|
+
return bytes;
|
|
48
|
+
}
|
|
49
|
+
export function evidenceFields(value) {
|
|
50
|
+
if (!['scope-context', 'scope-revision', 'scope-refresh', 'scope-observation'].includes(value?.kind)) evidenceFail('Scope evidence kind is invalid');
|
|
51
|
+
return [...common, ...(value.kind === 'scope-observation' ? ['stepId'] : ['proposalId', 'proposalDigest'])];
|
|
52
|
+
}
|
|
53
|
+
export function validateEvidenceIdentity(value, expected, location) {
|
|
54
|
+
for (const field of evidenceFields(value)) {
|
|
55
|
+
if (field === 'canonicalBytes') {
|
|
56
|
+
if (!Number.isSafeInteger(value[field]) || value[field] < 1 || value[field] > maximumScopeEvidenceBytes) evidenceFail('Scope evidence byte length is invalid');
|
|
57
|
+
} else if (!(['sha256', 'keyId', 'proposalDigest'].includes(field) ? hash : id).test(value[field] ?? '')) evidenceFail('Scope evidence identity is invalid');
|
|
58
|
+
if (expected && value[field] !== expected[field]) evidenceFail('Scope evidence reference replaced its exact original data identity');
|
|
59
|
+
}
|
|
60
|
+
if (location && ['flowId', 'runnerId', 'repositoryId', 'keyId'].some(field => value[field] !== location.binding[field])) {
|
|
61
|
+
evidenceFail('Scope evidence replaced its original local signer binding');
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
function signature(value, key) {
|
|
66
|
+
const { signature: encoded, ...core } = value ?? {};
|
|
67
|
+
if (typeof encoded !== 'string' || !/^[A-Za-z0-9_-]{86}$/.test(encoded)
|
|
68
|
+
|| Buffer.from(encoded, 'base64url').toString('base64url') !== encoded
|
|
69
|
+
|| !verify(null, Buffer.from(canonical(core)), key, Buffer.from(encoded, 'base64url'))) evidenceFail('Original scope evidence S signature changed');
|
|
70
|
+
}
|
|
71
|
+
export function verifyEvidenceData(value, descriptor, location) {
|
|
72
|
+
validateEvidenceIdentity(descriptor, undefined, location);
|
|
73
|
+
const bytes = evidenceBytes(value);
|
|
74
|
+
if (sha256(bytes) !== descriptor.sha256 || bytes.length !== descriptor.canonicalBytes) evidenceFail('Scope evidence hash or canonical UTF8 length changed');
|
|
75
|
+
const key = createPublicKey({ key: Buffer.from(location.binding.publicKey, 'base64'), type: 'spki', format: 'der' });
|
|
76
|
+
if (sha256(key.export({ type: 'spki', format: 'der' })) !== descriptor.keyId) evidenceFail('Scope evidence original public key changed');
|
|
77
|
+
signature(value, key);
|
|
78
|
+
const observation = descriptor.kind === 'scope-observation', inner = observation ? value : value.scopeAttestation;
|
|
79
|
+
if (!observation) signature(inner, key);
|
|
80
|
+
if (inner?.schemaVersion !== 'entrorove.scope-facts/1.0'
|
|
81
|
+
|| ['flowId', 'repositoryId', 'keyId'].some(field => inner[field] !== descriptor[field])
|
|
82
|
+
|| observation && (inner.challenge !== descriptor.challengeId || inner.stepId !== descriptor.stepId)
|
|
83
|
+
|| !observation && value.proposalDigest !== descriptor.proposalDigest
|
|
84
|
+
|| descriptor.kind === 'scope-context' && (value.schemaVersion !== 'entrorove.scope-context-attestation/1.0' || value.challengeId !== descriptor.challengeId)
|
|
85
|
+
|| ['scope-revision', 'scope-refresh'].includes(descriptor.kind) && (value.schemaVersion !== 'entrorove.scope-revision-attestation/1.0'
|
|
86
|
+
|| value.proposalId !== descriptor.proposalId || value.purpose !== (descriptor.kind === 'scope-refresh' ? 'scope-revision-refresh' : 'scope-revision'))
|
|
87
|
+
|| ['ownerId', 'taskDigest', 'chainDigest'].some(field => descriptor[field] !== undefined && inner[field] !== descriptor[field])) {
|
|
88
|
+
evidenceFail('Scope evidence has another original kind, task or challenge');
|
|
89
|
+
}
|
|
90
|
+
return bytes;
|
|
91
|
+
}
|
|
92
|
+
export function describeScopeEvidence(kind, value, challenge, location) {
|
|
93
|
+
const bytes = evidenceBytes(value), observation = kind === 'scope-observation';
|
|
94
|
+
const descriptor = { schemaVersion: 'entrorove.scope-evidence-descriptor/1.0', kind, flowId: challenge.flowId,
|
|
95
|
+
...(observation ? { stepId: challenge.stepId } : { proposalId: challenge.proposalId, proposalDigest: challenge.proposalDigest }),
|
|
96
|
+
challengeId: observation ? challenge.challenge : challenge.challengeId, runnerId: location.binding.runnerId,
|
|
97
|
+
repositoryId: location.binding.repositoryId, keyId: location.binding.keyId, sha256: sha256(bytes), canonicalBytes: bytes.length };
|
|
98
|
+
verifyEvidenceData(value, descriptor, location);
|
|
99
|
+
const inner = observation ? value : value.scopeAttestation;
|
|
100
|
+
if (['flowId', 'ownerId', 'taskDigest', 'chainDigest', 'repositoryId', 'stepId', 'challenge'].some(field => inner[field] !== challenge[field])
|
|
101
|
+
|| Date.parse(inner.expiresAt) > Date.parse(challenge.expiresAt)) evidenceFail('Scope evidence differs from its exact original challenge');
|
|
102
|
+
return { descriptor, signature: sign(null, Buffer.from(canonical(descriptor)), location.key).toString('base64url') };
|
|
103
|
+
}
|
|
104
|
+
export function evidenceReference(value, expected, location) {
|
|
105
|
+
exact(value, ['schemaVersion', 'evidenceId', ...evidenceFields(value), 'ownerId', 'taskDigest', 'chainDigest']);
|
|
106
|
+
if (value.schemaVersion !== 'entrorove.scope-evidence-reference/1.0' || !id.test(value.evidenceId ?? '')
|
|
107
|
+
|| !id.test(value.ownerId ?? '') || !hash.test(value.taskDigest ?? '') || !hash.test(value.chainDigest ?? '')) evidenceFail('Scope evidence reference is invalid');
|
|
108
|
+
return validateEvidenceIdentity(value, expected, location);
|
|
109
|
+
}
|
|
110
|
+
export function evidenceUpload(value, descriptor, location, challenge) {
|
|
111
|
+
const upload = scopeRevisionAuthority(value, 'entrorove.scope-evidence-upload/1.0');
|
|
112
|
+
exact(upload, ['schemaVersion', ...evidenceFields(upload), 'uploadId', 'ownerId', 'taskDigest', 'chainDigest', 'issuedAt', 'expiresAt', 'grantsExecutionAuthority', 'authorityReceipt']);
|
|
113
|
+
validateEvidenceIdentity(upload, descriptor, location);
|
|
114
|
+
const issued = Date.parse(upload.issuedAt), expires = Date.parse(upload.expiresAt);
|
|
115
|
+
if (!id.test(upload.uploadId ?? '') || upload.grantsExecutionAuthority !== false
|
|
116
|
+
|| ['ownerId', 'taskDigest', 'chainDigest'].some(field => upload[field] !== challenge[field])
|
|
117
|
+
|| !Number.isFinite(issued) || !Number.isFinite(expires) || expires <= issued || expires - issued > 90_000
|
|
118
|
+
|| expires > Date.parse(challenge.expiresAt)) evidenceFail('Scope evidence upload replaced its original challenge or authority');
|
|
119
|
+
return upload;
|
|
120
|
+
}
|
package/scope-runner-client.mjs
CHANGED
|
@@ -6,7 +6,9 @@ import { scopeFail, scopePath, scopeRoot, scopeTarget } from './local-scope-file
|
|
|
6
6
|
import { withStateLock } from './local-state-lock.mjs';
|
|
7
7
|
import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
|
|
8
8
|
import { probe } from './local-probe.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import { contextRemote } from './scope-context-remote.mjs';
|
|
10
|
+
import { scopeRunnerJournal, saveScopeRunnerJournal } from './scope-runner-journal.mjs';
|
|
11
|
+
import { saveLocalScopeEvidence, ensureScopeEvidence } from './scope-evidence-remote.mjs';
|
|
10
12
|
import { boundScopeContexts } from './scope-context-state.mjs';
|
|
11
13
|
|
|
12
14
|
async function privateDirectory(directory) {
|
|
@@ -54,38 +56,7 @@ async function signer(directory, identity, dependencies) {
|
|
|
54
56
|
}
|
|
55
57
|
return { binding, key, signerConfigPath: path.join(controls, 'signer.json'), contextAuthority };
|
|
56
58
|
}
|
|
57
|
-
async function
|
|
58
|
-
const { response, result } = await invokeRemoteResult({ operation, input }, dependencies);
|
|
59
|
-
if (!response.ok || result.output.status !== 'succeeded') {
|
|
60
|
-
throw Object.assign(new Error(result.output.message ?? `Scope connection blocked at ${operation}`),
|
|
61
|
-
{ code: result.output.code ?? 'ENTROROVE_SCOPE_BLOCKED', output: result.output });
|
|
62
|
-
}
|
|
63
|
-
return result.output;
|
|
64
|
-
}
|
|
65
|
-
async function connect(identity, directory, dependencies) {
|
|
66
|
-
const { binding, key, signerConfigPath, contextAuthority } = await signer(directory, identity, dependencies);
|
|
67
|
-
const prepared = await remote('scope-runner-prepare', { flowId: identity.flowId, attemptId: randomUUID(), runnerId: binding.runnerId,
|
|
68
|
-
repositoryId: binding.repositoryId, publicKey: binding.publicKey, targetPaths: identity.targetPaths }, dependencies);
|
|
69
|
-
const enrollment = prepared.enrollment;
|
|
70
|
-
if (enrollment.flowId !== identity.flowId || enrollment.runnerId !== binding.runnerId || enrollment.keyId !== binding.keyId
|
|
71
|
-
|| canonical(enrollment.targetPaths) !== canonical(identity.targetPaths)) scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud enrollment differs from the original local task signer');
|
|
72
|
-
await remote('scope-runner-register', { flowId: identity.flowId, enrollmentId: enrollment.enrollmentId,
|
|
73
|
-
signature: sign(null, Buffer.from(canonical(enrollment)), key).toString('base64url') }, dependencies);
|
|
74
|
-
const issued = await remote('scope-runner-challenge', { flowId: identity.flowId, receiverId: binding.runnerId, attemptId: randomUUID() }, dependencies);
|
|
75
|
-
const challenge = issued.challenge;
|
|
76
|
-
if (challenge.flowId !== identity.flowId || challenge.ownerId !== enrollment.ownerId || challenge.runnerId !== binding.runnerId
|
|
77
|
-
|| challenge.repositoryId !== binding.repositoryId || challenge.taskDigest !== enrollment.taskDigest
|
|
78
|
-
|| challenge.chainDigest !== enrollment.chainDigest || canonical(challenge.targetPaths) !== canonical(identity.targetPaths)) {
|
|
79
|
-
scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud scope challenge differs from the original account and accepted task');
|
|
80
|
-
}
|
|
81
|
-
const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
|
|
82
|
-
stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: `snapshot-${randomUUID()}`, importContexts: identity.importContexts,
|
|
83
|
-
...(contextAuthority ? { contextRevisionDigest: contextAuthority.revisionDigest, contextEvidence: contextAuthority.record.evidence } : {}) };
|
|
84
|
-
const planned = await snapshotPlan(input), verified = await snapshotVerify(input);
|
|
85
|
-
if (verified.verified !== true || verified.receipt.snapshotDigest !== planned.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
|
|
86
|
-
const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,
|
|
87
|
-
chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge, signerConfigPath });
|
|
88
|
-
const accepted = await remote('scope-runner-attest', { flowId: identity.flowId, attestation: observed.attestation }, dependencies);
|
|
59
|
+
async function scopeConnectionResult(identity, binding, accepted) {
|
|
89
60
|
const next = accepted.nextStep ?? { operation: 'workflow-gate', payload: { targetFiles: [...identity.targetPaths] } };
|
|
90
61
|
if (next.localOperation === 'scope-connect') return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'succeeded',
|
|
91
62
|
flowId: identity.flowId, runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
|
|
@@ -97,6 +68,63 @@ async function connect(identity, directory, dependencies) {
|
|
|
97
68
|
runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
|
|
98
69
|
nextOperation: next.operation, ...(resumeOriginalRequest ? { resumeOriginalRequest: true } : { nextInput }), nextStep: next };
|
|
99
70
|
}
|
|
71
|
+
async function observeConnection(identity, location, state, challenge, contextAuthority, dependencies) {
|
|
72
|
+
if (state.evidenceDescriptor) return;
|
|
73
|
+
const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
|
|
74
|
+
stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: state.snapshotId, importContexts: identity.importContexts,
|
|
75
|
+
...(contextAuthority ? { contextRevisionDigest: contextAuthority.revisionDigest, contextEvidence: contextAuthority.record.evidence } : {}) };
|
|
76
|
+
if (!state.snapshotStarted) {
|
|
77
|
+
state.snapshotStarted = true; await saveScopeRunnerJournal(location, state);
|
|
78
|
+
try { await snapshotPlan(input); }
|
|
79
|
+
catch (error) { delete state.snapshotStarted; await saveScopeRunnerJournal(location, state); throw error; }
|
|
80
|
+
}
|
|
81
|
+
const verified = await snapshotVerify(input);
|
|
82
|
+
if (verified.verified !== true) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
|
|
83
|
+
const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,
|
|
84
|
+
chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge, signerConfigPath: location.signerConfigPath },
|
|
85
|
+
{ maximumExpiresAt: Date.parse(challenge.expiresAt) });
|
|
86
|
+
if (observed.attestation.snapshotDigest !== verified.receipt.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual S proof replaced its original verified snapshot');
|
|
87
|
+
await saveLocalScopeEvidence(observed.attestation, 'scope-observation', challenge, state, location, saveScopeRunnerJournal);
|
|
88
|
+
}
|
|
89
|
+
async function connect(identity, directory, dependencies) {
|
|
90
|
+
const local = await signer(directory, identity, dependencies), { binding, key, contextAuthority } = local;
|
|
91
|
+
const location = { ...local, root: identity.repositoryRoot, home: dependencies.home, base: directory,
|
|
92
|
+
stateRoot: path.join(dependencies.home, '.entrorove'), directory: path.join(directory, 'scope-connection'),
|
|
93
|
+
file: path.join(directory, 'scope-connection', 'journal.json') };
|
|
94
|
+
const state = await scopeRunnerJournal(location, identity);
|
|
95
|
+
const remote = (stage, operation, input) => contextRemote(stage, operation, input, state, location, dependencies, saveScopeRunnerJournal);
|
|
96
|
+
try {
|
|
97
|
+
if (state.pending) await remote(state.pending.stage, state.pending.operation, state.pending.input);
|
|
98
|
+
const prepared = state.prepared ?? await remote('prepared', 'scope-runner-prepare', { flowId: identity.flowId,
|
|
99
|
+
attemptId: state.attemptId, runnerId: binding.runnerId, repositoryId: binding.repositoryId, publicKey: binding.publicKey, targetPaths: identity.targetPaths });
|
|
100
|
+
const enrollment = prepared.enrollment;
|
|
101
|
+
if (enrollment.flowId !== identity.flowId || enrollment.runnerId !== binding.runnerId || enrollment.keyId !== binding.keyId
|
|
102
|
+
|| canonical(enrollment.targetPaths) !== canonical(identity.targetPaths)) scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud enrollment differs from the original local task signer');
|
|
103
|
+
if (!state.registered) await remote('registered', 'scope-runner-register', { flowId: identity.flowId, enrollmentId: enrollment.enrollmentId,
|
|
104
|
+
signature: sign(null, Buffer.from(canonical(enrollment)), key).toString('base64url') });
|
|
105
|
+
const issued = state.challenged ?? await remote('challenged', 'scope-runner-challenge', { flowId: identity.flowId,
|
|
106
|
+
receiverId: binding.runnerId, attemptId: state.attemptId });
|
|
107
|
+
const challenge = issued.challenge;
|
|
108
|
+
if (challenge.flowId !== identity.flowId || challenge.ownerId !== enrollment.ownerId || challenge.runnerId !== binding.runnerId
|
|
109
|
+
|| challenge.repositoryId !== binding.repositoryId || challenge.taskDigest !== enrollment.taskDigest
|
|
110
|
+
|| challenge.chainDigest !== enrollment.chainDigest || canonical(challenge.targetPaths) !== canonical(identity.targetPaths)) {
|
|
111
|
+
scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud scope challenge differs from the original account and accepted task');
|
|
112
|
+
}
|
|
113
|
+
if (!state.snapshotId) { state.snapshotId = `snapshot-${randomUUID()}`; await saveScopeRunnerJournal(location, state); }
|
|
114
|
+
if (!state.accepted) {
|
|
115
|
+
await observeConnection(identity, location, state, challenge, contextAuthority, dependencies);
|
|
116
|
+
const attestationRef = await ensureScopeEvidence(state, location, dependencies, challenge, saveScopeRunnerJournal);
|
|
117
|
+
await remote('accepted', 'scope-runner-attest', { flowId: identity.flowId, attestationRef });
|
|
118
|
+
}
|
|
119
|
+
state.completed = true; await saveScopeRunnerJournal(location, state);
|
|
120
|
+
return scopeConnectionResult(identity, binding, state.accepted);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!state.pending && !state.evidencePending) {
|
|
123
|
+
state.rejection ??= { code: error.code, message: error.message }; await saveScopeRunnerJournal(location, state);
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
100
128
|
export async function connectScopeRunner(repositoryRoot, input, dependencies) {
|
|
101
129
|
if (!input || Object.keys(input).some(field => !['flowId', 'paths', 'importContexts'].includes(field))
|
|
102
130
|
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(input.flowId ?? '') || !Array.isArray(input.paths) || !input.paths.length) {
|
|
@@ -129,8 +157,10 @@ export async function runScopeConnect(repositoryRoot, input, dependencies) {
|
|
|
129
157
|
if (!await dependencies.agreementAccepted(dependencies.home)) return dependencies.agreementRequired(dependencies.home);
|
|
130
158
|
try { return await connectScopeRunner(repositoryRoot, input, dependencies); }
|
|
131
159
|
catch (error) {
|
|
132
|
-
if (
|
|
160
|
+
if (!error.code?.startsWith('ENTROROVE_')) throw error;
|
|
133
161
|
return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'blocked', flowId: input?.flowId,
|
|
134
|
-
code: error.code, message: error.message, ...(error.output ? { output: error.output } : {})
|
|
162
|
+
code: error.code, message: error.message, ...(error.output ? { output: error.output } : {}),
|
|
163
|
+
...(error.outcomeUnknown ? { outcomeUnknown: true, nextAction: { method: 'GET', operation: 'scope-connect',
|
|
164
|
+
...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
135
165
|
}
|
|
136
166
|
}
|