@entrorove/skill 0.1.71 → 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 +10 -0
- package/IDE-EXECUTION.md +4 -0
- package/SKILL.md +2 -1
- package/local-probe.mjs +16 -7
- package/manifest.json +1 -1
- package/package.json +6 -2
- package/release-source-groups.mjs +7 -0
- package/release-state.json +23 -19
- package/release.mjs +3 -2
- package/releases.json +12 -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-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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.73 — 2026-10-20
|
|
4
|
+
|
|
5
|
+
- 修复通用范围连接在长时间扫描后的签名期限绑定:原 S 证明到期时间不晚于原云端挑战,保留原随机数、完整范围、独立验签和失败历史;已过期挑战仍明确拒绝。
|
|
6
|
+
- Bind ordinary scope signatures to their original cloud deadline after long scans. The original S proof expires no later than its challenge, preserving the nonce, complete scope, independent signatures and failure history; expired challenges still reject explicitly.
|
|
7
|
+
|
|
8
|
+
## 0.1.72 — 2026-10-20
|
|
9
|
+
|
|
10
|
+
- 将完整范围证据与签名控制分离:四类原 S 证明经有界独立通道上传,紧凑权威绑定不可变摘要与字节数;未知上传仅恢复原 GET,保留原路径、上下文、审批、独立 M 写入和全部旧失败记录。
|
|
11
|
+
- Separate full scope evidence from signed controls: upload four kinds of original S proof through a bounded data channel and bind immutable hashes and byte counts in compact authorities. Recover uncertain uploads with the original GET only, preserving complete paths, contexts, approvals, separate M writes and prior failures.
|
|
12
|
+
|
|
3
13
|
## 0.1.71 — 2026-10-20
|
|
4
14
|
|
|
5
15
|
- 通用分离源码依赖事实与未知构建行为:动态配置、宏及加载器保留真实哈希与只读来源,不执行项目代码,不把构建未知标为完整。原审批与独立 S/M 签名仅允许已知源码的非结构修改;隐私文件、路径、资源预算、未知目标及配置漂移继续明确拒绝,实际构建与测试仍需原流程验收。
|
package/IDE-EXECUTION.md
CHANGED
|
@@ -92,6 +92,10 @@ When intake returns `workflow-gate`, connect the current repository before invok
|
|
|
92
92
|
|
|
93
93
|
Scope inspection resolves aliases from actual `tsconfig.json` or `jsconfig.json`, including relative and npm `extends` and ordered inheritance arrays. Npm configuration lookup uses the pinned TypeScript JSON-config resolver over controlled metadata reads, including scoped packages, subpaths, package `tsconfig`, `exports` and `typesVersions`. Read-only candidate probes treat a child beneath an ordinary file as absent so normal TypeScript fallbacks continue; actual sources, control files and mutation targets retain strict validation. Rejected paths report the requested path, blocking ancestor, resolved target and reason, including broken or cyclic dependency links. Configurations and package metadata enter the signed hashes; dependencies are read only, never executed. Repository-local pnpm links use their actual configuration location. Read-only external dependency packages support parents and mappings inside their reference boundary; references requiring an unrepresentable external canonical ancestor fail explicitly rather than selecting a different same-name repository package. For plugin source compiled by a host project whose config is outside the source's ancestors, include `importContexts: [{"sourcePrefix":"<plugin source directory>","configPath":"<actual host tsconfig.json>"}]` in `scope-connect` input. Inspect the real build configuration first; never invent an alias or mark an unresolved import as external. These context paths remain inside the repository, their hashes enter the signed facts, and the local task binding freezes them.
|
|
94
94
|
|
|
95
|
+
Full scope observations travel through a separate authenticated evidence channel. The SDK keeps the complete original canonical UTF8 S proof in a private SHA256-derived sidecar, reserves an upload with the discovered `scope-evidence-prepare`, and sends its exact bytes once through that reservation's `PUT`. The fixed data limit is 16,000,000 bytes, with bounded structure, time and concurrency; ordinary request, signed-control and journal limits remain unchanged. Ordinary observations, context corrections, scope revisions and post-write refreshes have distinct evidence kinds and exact original identity bindings. An upload or reference grants no execution authority. Apply still verifies the original signatures, freshness, full scope, owner approval, nonce and revision CAS, including separate M consumption for changed writes.
|
|
96
|
+
|
|
97
|
+
An interrupted upload is recovered only with its original reservation's `GET`; a missing or pending result does not authorize another upload, nonce or changed input. Reuse the exact original connector command and input so its private journal performs that recovery. Compact v2 authorities sign the immutable evidence reference, digest, byte count and original task identities. Their revision digest hashes the signed compact record, never a hydrated projection. Readers verify the full sidecar before using the retained context history or amendment reference. Old v1 records and known failures retain their exact bytes; a known terminal rejection is archived before a fresh same-proposal attempt. Do not hand-edit a managed SDK, sidecar, binding or journal to bypass these checks.
|
|
98
|
+
|
|
95
99
|
### Correcting build contexts before the first scope gate
|
|
96
100
|
|
|
97
101
|
An unresolved alias can occur after the original local signer has been enrolled but before any scope facts or gate have been accepted. Ordinary `scope-connect` still rejects a changed `importContexts` list. Use the explicit `scope-context <original absolute repositoryRoot>` recovery on that same flow; do not edit `identity/binding.json`, replace its key, drop target paths or create a replacement flow.
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Execute a task selected for Entrorove through explicit SDK operatio
|
|
|
5
5
|
|
|
6
6
|
# entrorove · 熵巡
|
|
7
7
|
|
|
8
|
-
Version: 0.1.
|
|
8
|
+
Version: 0.1.73.
|
|
9
9
|
|
|
10
10
|
Entrorove applies only to the task for which it is selected; reading these instructions, discovering capabilities or installing the package never selects or governs another task. Hooks are optional. Reading this document or calling an entry once does not execute the task. On every later turn, including after compaction, continue the original task and its required next operation before dependent work. Keep the original goal, scope, account, flow, cloud task and failure records. Never describe planning, a reported step or model output as verified delivery.
|
|
11
11
|
|
|
@@ -46,6 +46,7 @@ When a new user requirement arrives while the original task is unfinished, store
|
|
|
46
46
|
- Invoke service operations with JSON stdin to `npx @entrorove/skill invoke` or a host-approved rewritten equivalent.
|
|
47
47
|
- Explicit domains use `native <domain> <operation>`. Domains: aimlock, blueprint, calctool, swarm, validator, mergeguard, archguard, confirm-protocol. Aimlock `brain-plan`, `brain-check`, `brain-validate`, `brain-status` use the original stored plan.
|
|
48
48
|
- At `workflow-gate`, connect the actual repository and exact source paths with `scope-connect`, then use its original `nextInput`. Scope inspection does not grant execution or writes. Dynamic references remain unknown; never set graph completeness manually.
|
|
49
|
+
- Complete S evidence uses a separate bounded upload and private hash-bound data file. Keep every original path, context and unknown origin. An interrupted upload permits only its original GET recovery through the unchanged connector input; a missing result never permits another PUT or challenge. Compact signed authorities bind the complete evidence reference while preserving the original control limits and v1 history.
|
|
49
50
|
- If a real missing build context blocks the first scope scan, use the same-flow `scope-context` review and original-owner answer described in [IDE-EXECUTION.md](IDE-EXECUTION.md#correcting-build-contexts-before-the-first-scope-gate). Apply its exact approved proposal with the original S signer and fresh actual snapshot, then retry the returned complete `scope-connect` input. Keep the original binding, all paths and failed attempts; ordinary reconnects cannot silently change contexts.
|
|
50
51
|
- If actual files were omitted before plan freeze, follow the discovered same-flow `workflow-scope-review`, original owner approval and `scope-amend` recovery in [IDE-EXECUTION.md](IDE-EXECUTION.md#correcting-omitted-scope-before-plan-freeze). Preserve each file's explicit write or read-only role and bind the resulting `scopeRevisionDigest` in the corrected plan; never widen ordinary `scope-connect` input to bypass its original binding.
|
|
51
52
|
- `local capabilities` describes scope, snapshot, budget, ownership, mutation, review and evidence contracts. Do not call a guessed operation or construct a local signature in place of cloud authority.
|
package/local-probe.mjs
CHANGED
|
@@ -50,7 +50,7 @@ async function handoffReadBudget(input) {
|
|
|
50
50
|
const loaded = await loadBudget(await budgetLocation(binding));
|
|
51
51
|
return loaded.state.enforced && loaded.state.grant?.purpose === 'handoff-read';
|
|
52
52
|
}
|
|
53
|
-
async function probeHandoffCandidate(input, root, candidate) {
|
|
53
|
+
async function probeHandoffCandidate(input, root, candidate, signingOptions) {
|
|
54
54
|
scopeBinding(input);
|
|
55
55
|
const native = await import('./local-task-handoff-read-authority.mjs');
|
|
56
56
|
const readHome = await native.assertTaskHandoffReadHome({ ...input, taskId: candidate.receiverTaskId, ownerId: candidate.ownerId });
|
|
@@ -67,13 +67,22 @@ async function probeHandoffCandidate(input, root, candidate) {
|
|
|
67
67
|
preservedSnapshotDigest: null, preservedSnapshotId: null, currentSnapshotFresh: true,
|
|
68
68
|
receiverOrigin: { flowId: candidate.flowId, taskId: candidate.receiverTaskId,
|
|
69
69
|
scopeDigest: candidate.receiverOriginalScopeDigest, checkpointDigest: candidate.receiverCheckpointDigest,
|
|
70
|
-
acceptanceReceiptId: candidate.receiverAcceptanceReceiptId, sourceCopiesVerified: false } });
|
|
70
|
+
acceptanceReceiptId: candidate.receiverAcceptanceReceiptId, sourceCopiesVerified: false } }, signingOptions);
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
function probeSigningOptions(options) {
|
|
73
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)
|
|
74
|
+
|| Object.keys(options).some(key => key !== 'maximumExpiresAt')
|
|
75
|
+
|| options.maximumExpiresAt !== undefined && (!Number.isSafeInteger(options.maximumExpiresAt) || options.maximumExpiresAt < 1)) {
|
|
76
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Internal probe signing options require only a finite exact deadline');
|
|
77
|
+
}
|
|
78
|
+
return { maximumExpiresAt: options.maximumExpiresAt };
|
|
79
|
+
}
|
|
80
|
+
export async function probe(input, options = {}) {
|
|
81
|
+
const signingOptions = probeSigningOptions(options);
|
|
73
82
|
rejectScopePrivileges(input);
|
|
74
83
|
const root = await scopeRoot(input.repositoryRoot);
|
|
75
84
|
const candidate = await verifiedScopeCandidate(input, root);
|
|
76
|
-
if (candidate && await handoffReadBudget(input)) return probeHandoffCandidate(input, root, candidate);
|
|
85
|
+
if (candidate && await handoffReadBudget(input)) return probeHandoffCandidate(input, root, candidate, signingOptions);
|
|
77
86
|
const hasBinding = ['flowId', 'stepId', 'snapshotId'].some(field => input[field] !== undefined);
|
|
78
87
|
const retained = hasBinding ? await retainedScope(input, root, true) : null;
|
|
79
88
|
const fresh = !!candidate || (!!retained && retained.stepId !== input.stepId);
|
|
@@ -97,15 +106,15 @@ export async function probe(input) {
|
|
|
97
106
|
classification: { ...result.classification, mode: candidate ? result.classification.mode : retained.mode }, requiredMode: result.classification.mode,
|
|
98
107
|
originalBinding: { flowId: retained.flowId, stepId: retained.stepId, snapshotId: retained.originalSnapshot.snapshotId },
|
|
99
108
|
snapshotDigest: result.facts.snapshotEvidence.snapshotDigest, preservedSnapshotDigest: retained.originalSnapshot.digest,
|
|
100
|
-
preservedSnapshotId: retained.originalSnapshot.snapshotId, currentSnapshotFresh: true });
|
|
109
|
+
preservedSnapshotId: retained.originalSnapshot.snapshotId, currentSnapshotFresh: true }, signingOptions);
|
|
101
110
|
}
|
|
102
111
|
const saved = await initializeScope(input, result.repositoryRoot, result.facts, result.classification);
|
|
103
112
|
return attestScopeFacts(input, { ...result, revision: saved.state.revision, statePath: saved.statePath,
|
|
104
113
|
classification: { ...result.classification, mode: saved.state.mode }, requiredMode: result.classification.mode,
|
|
105
114
|
snapshotDigest: result.facts.snapshotEvidence.snapshotDigest, preservedSnapshotDigest: saved.state.originalSnapshot.digest,
|
|
106
|
-
preservedSnapshotId: saved.state.originalSnapshot.snapshotId, currentSnapshotFresh: true });
|
|
115
|
+
preservedSnapshotId: saved.state.originalSnapshot.snapshotId, currentSnapshotFresh: true }, signingOptions);
|
|
107
116
|
}
|
|
108
|
-
return attestScopeFacts(input, result);
|
|
117
|
+
return attestScopeFacts(input, result, signingOptions);
|
|
109
118
|
}
|
|
110
119
|
function observationReassessment(input, result) {
|
|
111
120
|
const previousMode = input.currentMode === undefined ? result.classification.mode : input.currentMode;
|
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.73",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"entrorove": "cli.mjs"
|
|
@@ -325,7 +325,11 @@
|
|
|
325
325
|
"local-scope-build-io.mjs",
|
|
326
326
|
"local-scope-build-facts.mjs",
|
|
327
327
|
"local-scope-build-data.mjs",
|
|
328
|
-
"local-scope-build-readset.mjs"
|
|
328
|
+
"local-scope-build-readset.mjs",
|
|
329
|
+
"scope-evidence-wire.mjs",
|
|
330
|
+
"scope-evidence-local.mjs",
|
|
331
|
+
"scope-evidence-remote.mjs",
|
|
332
|
+
"scope-runner-journal.mjs"
|
|
329
333
|
],
|
|
330
334
|
"scripts": {
|
|
331
335
|
"test": "node test/run.mjs",
|
|
@@ -37,3 +37,10 @@ export const memberContinuationSourceFiles = Object.freeze([
|
|
|
37
37
|
'workflow-observation-state.mjs',
|
|
38
38
|
'workflow-observation-wire.mjs',
|
|
39
39
|
])
|
|
40
|
+
export const scopeEvidenceSourceFiles = Object.freeze([
|
|
41
|
+
'scope-evidence-wire.mjs', 'scope-evidence-local.mjs', 'scope-evidence-remote.mjs', 'scope-runner-journal.mjs',
|
|
42
|
+
])
|
|
43
|
+
export const scopeEvidenceReleaseNote = {
|
|
44
|
+
zh: '将完整范围证据与签名控制分离:四类原 S 证明经有界独立通道上传,紧凑权威绑定不可变摘要与字节数;未知上传仅恢复原 GET,保留原路径、上下文、审批、独立 M 写入和全部旧失败记录。',
|
|
45
|
+
en: 'Separate full scope evidence from signed controls: upload four kinds of original S proof through a bounded data channel and bind immutable hashes and byte counts in compact authorities. Recover uncertain uploads with the original GET only, preserving complete paths, contexts, approvals, separate M writes and prior failures.',
|
|
46
|
+
}
|
package/release-state.json
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "entrorove.release-state/1.0",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"digest": "
|
|
3
|
+
"version": "0.1.73",
|
|
4
|
+
"digest": "06693e8422b76b4037a70c0be657104b918234d74fa4c5dd1e29a394d5c83e0e",
|
|
5
5
|
"files": {
|
|
6
|
-
"release-source-groups.mjs": "
|
|
6
|
+
"release-source-groups.mjs": "876b8c8bcf459d3b469bf4f2021d4206de5b199add545597a5c8f6dea7385b38",
|
|
7
7
|
"local-scope-build-read.mjs": "361d4b461cfa2c77b4ec22bc59a0a1c78fed2cfb0ea2e05a8da5c2551ecd88ac",
|
|
8
8
|
"local-scope-build-io.mjs": "8af69722738fa094780f9a2dfcddaf12993fc3b936a75c56c4b0d4f90cb20fd1",
|
|
9
9
|
"local-scope-build-facts.mjs": "8b0bd19347d06d1cb66d8cd2d6784103af9d00ea691d7f4acea523d994b874fb",
|
|
10
10
|
"local-scope-build-data.mjs": "b6e272ca4385629326b4517a53408c561272f5f1a01e5b701f17eaa4c5e64b2e",
|
|
11
11
|
"local-scope-build-readset.mjs": "fd1cb1470c54b3902a4612056281746e947cf96d1f5c03a09ea0becbcd79cc45",
|
|
12
|
-
"scope-
|
|
12
|
+
"scope-evidence-wire.mjs": "d283bbfd002b034e1deb539633b4970b430a97065388288ab3398e2bc0285633",
|
|
13
|
+
"scope-evidence-local.mjs": "25b22ba2cc5a17978d2422e290ee015299f05d639b4b4b9551001afefab55c0d",
|
|
14
|
+
"scope-evidence-remote.mjs": "27077bb8238f3d11e6e18453e2ea8a6e096a104bbee5ee43a82b8c099d253c1e",
|
|
15
|
+
"scope-runner-journal.mjs": "cddb2f20a2812e8bcc3268d8ebdd486c22d0c7f5740e958d0b35cd2cc8819f92",
|
|
16
|
+
"scope-context-client.mjs": "115d82022f71396bab137e9af06421a6d0aa9671838f0df64e6aa83d9d7fb039",
|
|
13
17
|
"scope-context-observer.mjs": "25a84339512c1e80711aca53baed20da556fa2ae5d84e83fa95d58dcdca7cf7d",
|
|
14
18
|
"scope-context-observation.mjs": "d92f2fca3c923caa6f0c5c9cc60b4f12bdae212071da64da6692e3e99b251969",
|
|
15
|
-
"scope-context-remote.mjs": "
|
|
16
|
-
"scope-context-state.mjs": "
|
|
17
|
-
"scope-context-wire.mjs": "
|
|
18
|
-
"scope-context-journal.mjs": "
|
|
19
|
+
"scope-context-remote.mjs": "cbcef3fb0b9e1cf11b68f877c227ac9b0c617432221dc6e661bce9bc4667b87a",
|
|
20
|
+
"scope-context-state.mjs": "dc47da57e411ef11677560a767f2e6d50f8a2fae17706b44715d8cd115ffcf8e",
|
|
21
|
+
"scope-context-wire.mjs": "1aa8c86223e54927a209de59d3514393b4c905ee2fc520d49819aec0f1e97162",
|
|
22
|
+
"scope-context-journal.mjs": "0d9aa1315204d3600f62075fe9a4b0b958b21a04c3a50a743eb5dce1eb16e18a",
|
|
19
23
|
"scope-context-release.mjs": "3b6f5d3bf7abe25e1a1edd0521053f4c1b1e3487b7f37d5f7c6080d32bc0c044",
|
|
20
24
|
"cloud-locale.mjs": "0627ac75b98b439563b61ab217aacb55a099c1d0c8ed3b118776d2007c52dd70",
|
|
21
25
|
"cloud-locale.d.mts": "aff4bced9bbac140f73fd0de24681287349389be5750d89d96abe0f8a2167664",
|
|
@@ -82,7 +86,7 @@
|
|
|
82
86
|
"runtime/validator-runtime-v7.0.44.d.mts": "7797edbfe2f7de24da370204dce4dfd22822a1bd80df93514fff900b98afe36f",
|
|
83
87
|
"snapshot-retention.mjs": "ca46aeb36b1a5a24fe7e967031a71b9a3d33f40cb715d344402cbe0dc9c425cb",
|
|
84
88
|
"snapshot-cleanup-journal.mjs": "76a9684cc95b4eb076e1298d78a9dff37948b00d9607b8f546c599072d3ed331",
|
|
85
|
-
"IDE-EXECUTION.md": "
|
|
89
|
+
"IDE-EXECUTION.md": "3da7cbfafc1a4e7d4059c6fe8e69d14dacd2c72e300fe8e056581faea7f23aeb",
|
|
86
90
|
"REUSE-DISCIPLINE.md": "a2452911c2b50c4762b4a1374f1958059eb03efd59f63e1b50e1650c52b0dc98",
|
|
87
91
|
"install-identity.mjs": "41b8c8d8b3e265d2057a07bf7dc98066c34d6b05c65bf7159ca9609c01ee4646",
|
|
88
92
|
"package-archive.mjs": "e06db8d709db8710bead254ee950b78928088be01c58dcea383beaa5eb574e59",
|
|
@@ -158,13 +162,13 @@
|
|
|
158
162
|
"install-cleanup.mjs": "8a8e1a7dfe37427f86cd4d6e317890d0829894a9949d4216674a716b36edbc54",
|
|
159
163
|
"review-client.mjs": "3491eef68ed533174b31db3c53febb6519b8f088f71a9964644714a9a4c03c77",
|
|
160
164
|
"version.mjs": "85284d4cd5e3314b449e6ae124243f96397bcc274817b5983a79cfdd903d46fd",
|
|
161
|
-
"SKILL.md": "
|
|
165
|
+
"SKILL.md": "1c1509924526711c92df7fd0600e4165353cc602663d83b225e083676501bba3",
|
|
162
166
|
"CAPABILITY-MAP.md": "517809bb20750e5de89b2dab5705c902a27fa68cf4f3f392a5d8cf2f19818888",
|
|
163
167
|
"AGREEMENT.md": "f93a68e92c457200dd024610e65f74e478b8f60860f951da818b70561f1b9bf8",
|
|
164
168
|
"DELIVERY-BOUNDARIES.md": "cb23ce71b2197085c8441ba7c5b6c05fb21f2a954bf66b4d60cfef8b15895b8b",
|
|
165
|
-
"package.json": "
|
|
169
|
+
"package.json": "9a3bb032185a679b7b0146cc6bd1b46ed44e59d16cbab236c5e90fd4f167ad7c",
|
|
166
170
|
"manifest.json": "ab06e307795bc9da5add926752b6c4dbdaf18625090261a035aa356a679c504f",
|
|
167
|
-
"release.mjs": "
|
|
171
|
+
"release.mjs": "c3a5af873db43872e5a6bdc16eb3d859e8d69f6b21bd1c3b754f4a2b6c305360",
|
|
168
172
|
"local-cli.mjs": "058ac9a4653bc12e0d031b72034ebfe6a72b91b9b23353b9fee04881c7d2e098",
|
|
169
173
|
"local-state-lock.mjs": "1dbf64264642a0851faf043e07ee9f66d2ad0508e9f799b0626246e42fae893e",
|
|
170
174
|
"local-execution.mjs": "721ed7d29bbe4ce6d088909cd84ffc440978c3c9eb0946c49a4281ee685fe3aa",
|
|
@@ -187,7 +191,7 @@
|
|
|
187
191
|
"local-chain-results.mjs": "0455f72cad574cfef87c2c6c0928d6432bba7e5dbaff521a2b62a9575299ca10",
|
|
188
192
|
"local-approved-plan.mjs": "c2c88ab4085ff35a1811fe43e9a28699cfc9387eda54bfeafae3e7dd9e8cdb19",
|
|
189
193
|
"local-budget-renewal.mjs": "6b436358066fe33b2f6d871fc90d8e7c551edd6255efd73c0d255d0fd35d5ef8",
|
|
190
|
-
"local-probe.mjs": "
|
|
194
|
+
"local-probe.mjs": "398f5dd4973503ccad280e0224e4f567c9763d21555a0816afdd83c75987d33a",
|
|
191
195
|
"local-budget-grants.mjs": "c5f6542f5d2c3bfe07f3ca5b5bc849bf05d886b06014f720322d530510775d3d",
|
|
192
196
|
"local-budget-handoff.mjs": "85a8f006dad9c0153843c7b7748ee7fcd756fdba159c97c3905d7fe0c4f604af",
|
|
193
197
|
"local-budget-handoff-contracts.mjs": "bb53ebf135ec7ecfbecee858cfb354f03fee9cfb3940fa5d967d45008bf9aac5",
|
|
@@ -241,16 +245,16 @@
|
|
|
241
245
|
"local-scope-state.mjs": "7a6d275d6267a4d0bb55582e1bcc7654c502ecba9221b84b2149a98f0ee7518f",
|
|
242
246
|
"local-scope-workspaces.mjs": "8a75ce7b6639b5682370b5738c5c2a90408e3130729608e008f03053cd50351f",
|
|
243
247
|
"local-task-routing.mjs": "6e2e506b2fb0e2e8d8c5147d7df32849b57a4e94a23634f01a355d45735858e4",
|
|
244
|
-
"remote-client.mjs": "
|
|
248
|
+
"remote-client.mjs": "6cf62706f8ac5f69368d951cd697f912040b2e58ac480dfb151bddb18b09b20d",
|
|
245
249
|
"cloud-task-client.mjs": "f9085c5e6af6f179282670d2e872b85bfabac2657d3d232b1246dc23f5a6ccdd",
|
|
246
|
-
"scope-runner-client.mjs": "
|
|
247
|
-
"scope-amendment-client.mjs": "
|
|
248
|
-
"scope-amendment-state.mjs": "
|
|
250
|
+
"scope-runner-client.mjs": "cc4fe202d5f777a0ec4c46f9b41a18aaf024c0d2e769a0b26bc6505f2f69f460",
|
|
251
|
+
"scope-amendment-client.mjs": "022ec88ecf12a98dd6e9e7915158fc9111cdadfda12cf714804948c8027a57d3",
|
|
252
|
+
"scope-amendment-state.mjs": "9f88cc7029f62eed3b1a0cc1c03b6a98c5713b467a2112278692de90c973b2be",
|
|
249
253
|
"scope-amendment-wire.mjs": "3c4823e23bbdf330616bb675f07f9fcad7423266bd50b92e1134d226060e9b83",
|
|
250
254
|
"scope-amendment-remote.mjs": "15a0b3126248f3101a52e3d7742f9a816dd3d8e58f21bac33e99e810e86cc4fd",
|
|
251
255
|
"scope-amendment-observer.mjs": "de796f2aca2f3bc7f33ae3323b07d98096c0f16b1ffde70e3055610d70d18020",
|
|
252
256
|
"scope-amendment-mutation.mjs": "fa24191df290efad29d41eca3d1a081981fefde02b7dc4a11c75c6118a69106e",
|
|
253
|
-
"scope-amendment-reference.mjs": "
|
|
257
|
+
"scope-amendment-reference.mjs": "dc1768131beb7db224dd2ff8522dbf6361f73e01f466c9334cdfa24118720078",
|
|
254
258
|
"native-client.mjs": "6f5575909bae9daba643287f876ffa0f9ff1c53a367b32a40145d740456d6605",
|
|
255
259
|
"native-notification.mjs": "8859a26c6f13d222711f763acf49ed8546b3306cf71f990643ac31a5ccaf1ba1",
|
|
256
260
|
"local-peer-status.mjs": "a971bcc69d60bb32b875ca5bb4297d948631d5d2821b812c279613ea059a8b4f",
|
|
@@ -261,7 +265,7 @@
|
|
|
261
265
|
"task-entry-client.mjs": "940fb068afe28a2a34fe96e285e149afdd9ba79649c0c65cde37ec4ed71ae39d",
|
|
262
266
|
"task-identity.mjs": "e0e6cf1f48bfa5ee3c5cb86a3c21adfce6d66b4220f4a164ca099263c7a09fb4",
|
|
263
267
|
"task-identity.d.mts": "55472e1ffc81b9d5e5c5bd56c0bf1d18ecde03ee5170a58a15ce9caf222d8761",
|
|
264
|
-
"remote-transport.mjs": "
|
|
268
|
+
"remote-transport.mjs": "e9ecb3b9cd4d3bcc7abe1f7097069a07a15319ca0d5641fee752d094eb039194",
|
|
265
269
|
"local-credential.mjs": "2a302a7f67b5ea8dc73453fc96ab33db349b59ca11fa9456e48ca1d9669d877c",
|
|
266
270
|
"local-invocations.mjs": "f6ac4c41c1a5ec34de358c4cca210ca8e403b3a913150a43bb55ec9be55d9998",
|
|
267
271
|
"local-task-handoff-fence.mjs": "5532a359ee29b885f7ce0cf7178f76dba0fc9245bdd92ef460982275baf0d557",
|
package/release.mjs
CHANGED
|
@@ -9,10 +9,10 @@ import { fileURLToPath } from 'node:url'
|
|
|
9
9
|
import { chainReleaseLabels } from './local-chain-release-labels.mjs'
|
|
10
10
|
|
|
11
11
|
const directory = path.dirname(fileURLToPath(import.meta.url))
|
|
12
|
-
import { swarmSourceFiles, coordination17SourceFiles, preservedMutationSourceFiles, memberContinuationSourceFiles } from './release-source-groups.mjs'
|
|
12
|
+
import { swarmSourceFiles, coordination17SourceFiles, preservedMutationSourceFiles, memberContinuationSourceFiles, scopeEvidenceSourceFiles, scopeEvidenceReleaseNote } from './release-source-groups.mjs'
|
|
13
13
|
const sourceFiles = [
|
|
14
14
|
'release-source-groups.mjs', 'local-scope-build-read.mjs', 'local-scope-build-io.mjs', 'local-scope-build-facts.mjs', 'local-scope-build-data.mjs', 'local-scope-build-readset.mjs',
|
|
15
|
-
...SCOPE_CONTEXT_SOURCE_FILES, 'cloud-locale.mjs', 'cloud-locale.d.mts', 'cloud-task-images.mjs', 'model-images.mjs', 'model-images.d.mts', 'cli-input.mjs',
|
|
15
|
+
...scopeEvidenceSourceFiles, ...SCOPE_CONTEXT_SOURCE_FILES, 'cloud-locale.mjs', 'cloud-locale.d.mts', 'cloud-task-images.mjs', 'model-images.mjs', 'model-images.d.mts', 'cli-input.mjs',
|
|
16
16
|
'scope-amendment-retirement.mjs', 'scope-amendment-consumption.mjs', 'mutation-connection-state.mjs', 'mutation-challenge-refresh.mjs',
|
|
17
17
|
'local-scope-font.mjs', 'local-scope-zip.mjs', 'member-producer-repair-authority.mjs',
|
|
18
18
|
'local-mutation-completion-evidence.mjs', 'local-mutation-environment-observation.mjs', 'local-mutation-finder-observation.mjs', 'local-finder-store.mjs', 'local-finder-plist.mjs',
|
|
@@ -273,6 +273,7 @@ function releaseNote(changed) {
|
|
|
273
273
|
if (suppliedZh && suppliedEn) return {
|
|
274
274
|
zh: suppliedZh.replaceAll(/\s+/g, ' '), en: suppliedEn.replaceAll(/\s+/g, ' '),
|
|
275
275
|
}
|
|
276
|
+
if (changed.some(file => scopeEvidenceSourceFiles.includes(file))) return scopeEvidenceReleaseNote
|
|
276
277
|
if (changed.some(file => SCOPE_CONTEXT_SOURCE_FILES.includes(file))) return scopeContextReleaseNote
|
|
277
278
|
if (changed.some(file => file.startsWith('scope-amendment-'))) return {
|
|
278
279
|
zh: '新增同一任务的精确范围补正:原账号审核文件哈希与写入/只读角色,原S签名真实事实并重评原合同;计划、写入和结算绑定不可变范围修订,改后刷新保留原M消费证据与失败历史。',
|
package/releases.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.1.73",
|
|
4
|
+
"date": "2026-10-20",
|
|
5
|
+
"zh": "修复通用范围连接在长时间扫描后的签名期限绑定:原 S 证明到期时间不晚于原云端挑战,保留原随机数、完整范围、独立验签和失败历史;已过期挑战仍明确拒绝。",
|
|
6
|
+
"en": "Bind ordinary scope signatures to their original cloud deadline after long scans. The original S proof expires no later than its challenge, preserving the nonce, complete scope, independent signatures and failure history; expired challenges still reject explicitly."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.1.72",
|
|
10
|
+
"date": "2026-10-20",
|
|
11
|
+
"zh": "将完整范围证据与签名控制分离:四类原 S 证明经有界独立通道上传,紧凑权威绑定不可变摘要与字节数;未知上传仅恢复原 GET,保留原路径、上下文、审批、独立 M 写入和全部旧失败记录。",
|
|
12
|
+
"en": "Separate full scope evidence from signed controls: upload four kinds of original S proof through a bounded data channel and bind immutable hashes and byte counts in compact authorities. Recover uncertain uploads with the original GET only, preserving complete paths, contexts, approvals, separate M writes and prior failures."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.1.71",
|
|
4
16
|
"date": "2026-10-20",
|
package/remote-client.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export function buildInvocation(operation, input) {
|
|
|
39
39
|
'message-status', 'message-delivery-start', 'message-delivery-report', 'message-resolve', 'message-complete',
|
|
40
40
|
'handoff-prepare', 'handoff-release', 'handoff-claim', 'handoff-status', 'handoff-resume', 'handoff-execution-authority', 'mutation-snapshot-challenge', 'mutation-pass-issue', 'mutation-pass-settle',
|
|
41
41
|
'member-observation-admit', 'member-observation-report', 'member-observation-status',
|
|
42
|
-
'scope-runner-prepare', 'scope-runner-register', 'scope-runner-challenge', 'scope-runner-attest',
|
|
42
|
+
'scope-runner-prepare', 'scope-runner-register', 'scope-runner-challenge', 'scope-runner-attest', 'scope-evidence-prepare',
|
|
43
43
|
'scope-runner-context-review', 'scope-runner-context-status', 'scope-runner-context-challenge', 'scope-runner-context-apply',
|
|
44
44
|
'start', 'continue'].includes(operation)) throw new Error('Invalid entrorove operation');
|
|
45
45
|
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Input must be a JSON object');
|
package/remote-transport.mjs
CHANGED
|
@@ -108,6 +108,20 @@ export async function requestTransport(origin, pathname, options = {}) {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// Evidence is a separate bounded data channel. A persisted upload reservation
|
|
112
|
+
// is attempted once; every uncertain outcome must use its original GET.
|
|
113
|
+
export async function putScopeEvidence(origin, uploadId, body, headers = {}) {
|
|
114
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(uploadId ?? '') || !Buffer.isBuffer(body)
|
|
115
|
+
|| body.length < 1 || body.length > 16_000_000) throw new Error('Invalid Entrorove scope evidence upload');
|
|
116
|
+
const url = new URL(`/api/skill/evidence/${uploadId}`, trustedOrigin(origin));
|
|
117
|
+
const response = await attempt(url, { method: 'PUT', body, timeoutMs: 30_000,
|
|
118
|
+
headers: { ...headers, 'content-type': 'application/json; charset=utf-8', 'content-length': body.length, 'accept-encoding': 'gzip' } });
|
|
119
|
+
if (response.status >= 300 && response.status < 400) {
|
|
120
|
+
throw Object.assign(new Error('Entrorove API redirects are refused'), { code: 'ENTROROVE_REDIRECT', established: true });
|
|
121
|
+
}
|
|
122
|
+
return { ...response, attempts: 1 };
|
|
123
|
+
}
|
|
124
|
+
|
|
111
125
|
export function parseTransportResponse(response, label = 'Entrorove') {
|
|
112
126
|
try { return JSON.parse(response.body); }
|
|
113
127
|
catch { throw Object.assign(new Error(`Invalid ${label} response`), { code: 'ENTROROVE_RESPONSE_INVALID' }); }
|
|
@@ -9,6 +9,7 @@ import { scopeAmendmentLocation, readScopeAmendmentState, saveScopeAmendmentStat
|
|
|
9
9
|
import { amendmentRemote, scopeAmendmentStatus } from './scope-amendment-remote.mjs';
|
|
10
10
|
import { observeScopeAmendment } from './scope-amendment-observer.mjs';
|
|
11
11
|
import { retainScopeAmendmentReference } from './scope-amendment-reference.mjs';
|
|
12
|
+
import { saveLocalScopeEvidence, ensureScopeEvidence, validateAppliedEvidence } from './scope-evidence-remote.mjs';
|
|
12
13
|
|
|
13
14
|
const schemaVersion = 'entrorove.scope-amendment-connection/1.0';
|
|
14
15
|
function validateProposal(status, input) {
|
|
@@ -17,8 +18,9 @@ function validateProposal(status, input) {
|
|
|
17
18
|
|| !['pending', 'approved', 'declined'].includes(proposal.approval)) amendmentFail('Current account has no matching original scope proposal');
|
|
18
19
|
return proposal;
|
|
19
20
|
}
|
|
20
|
-
function validateApplied(output, state, location) {
|
|
21
|
-
const effective = output.effectiveAuthority,
|
|
21
|
+
async function validateApplied(output, state, location) {
|
|
22
|
+
const effective = output.effectiveAuthority, version = effective?.record?.schemaVersion === 'entrorove.scope-revision-authority/2.0' ? '2.0' : '1.0';
|
|
23
|
+
const record = scopeRevisionAuthority(effective?.record, `entrorove.scope-revision-authority/${version}`);
|
|
22
24
|
const challenge = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: true }), refresh = challenge.purpose === 'scope-revision-refresh';
|
|
23
25
|
if (output.status !== 'succeeded' || output.grantsExecutionAuthority !== false || output.claimsCompletion !== false
|
|
24
26
|
|| output.scopeRevisionDigest !== sha256(canonical(record)) || effective.revisionDigest !== output.scopeRevisionDigest
|
|
@@ -30,21 +32,26 @@ function validateApplied(output, state, location) {
|
|
|
30
32
|
|| canonical(record.files.map(file => ({ ...file, sha256: null }))) !== canonical(challenge.files.map(file => ({ ...file, sha256: null })))) {
|
|
31
33
|
amendmentFail('Applied scope amendment lost its signed original authority or exact roles');
|
|
32
34
|
}
|
|
33
|
-
const
|
|
35
|
+
const factVersion = effective?.fact?.schemaVersion === 'entrorove.scope-revision-facts/2.0' ? '2.0' : '1.0';
|
|
36
|
+
const fact = refresh ? scopeRevisionAuthority(effective.fact, `entrorove.scope-revision-facts/${factVersion}`) : record;
|
|
37
|
+
const compact = fact.schemaVersion.endsWith('/2.0');
|
|
38
|
+
const attestation = compact ? await validateAppliedEvidence(fact, state, location, challenge,
|
|
39
|
+
refresh ? 'scope-refresh' : 'scope-revision') : state.attestation;
|
|
34
40
|
if (refresh && (fact.scopeRevisionDigest !== output.scopeRevisionDigest || fact.factRevision !== challenge.expectedFactRevision
|
|
35
41
|
|| ['flowId', 'ownerId', 'taskDigest', 'chainDigest'].some(field => fact[field] !== challenge[field]))
|
|
36
|
-
|| canonical(fact.candidateAttestation) !== canonical(
|
|
37
|
-
|| fact.scope?.attestationDigest !== sha256(canonical(
|
|
42
|
+
|| !compact && canonical(fact.candidateAttestation) !== canonical(attestation)
|
|
43
|
+
|| fact.scope?.attestationDigest !== sha256(canonical(attestation.scopeAttestation))
|
|
44
|
+
|| compact && effective.candidateAttestationDigest !== sha256(canonical(attestation))
|
|
38
45
|
|| effective.factRevision !== (refresh ? fact.factRevision : 0) || output.factRevision !== effective.factRevision) {
|
|
39
46
|
amendmentFail('Applied scope proof differs from this exact actual signed candidate');
|
|
40
47
|
}
|
|
41
48
|
return { schemaVersion, status: 'succeeded', flowId: challenge.flowId, proposalId: challenge.proposalId,
|
|
42
49
|
proposalDigest: challenge.proposalDigest, scopeRevisionDigest: output.scopeRevisionDigest, revision: output.revision,
|
|
43
50
|
factRevision: effective.factRevision, grantsExecutionAuthority: false, claimsCompletion: false,
|
|
44
|
-
...(refresh &&
|
|
51
|
+
...(refresh && attestation.memberMutation ? { localAction: {
|
|
45
52
|
command: 'npx @entrorove/skill mutation-settle <absolute repositoryRoot>', input: {
|
|
46
|
-
flowId: challenge.flowId, taskId:
|
|
47
|
-
stepId:
|
|
53
|
+
flowId: challenge.flowId, taskId: attestation.memberMutation.consumptionReceipt.taskId,
|
|
54
|
+
stepId: attestation.memberMutation.consumptionReceipt.stepId, gatePassPath: location.identity.gatePassPath } } } : {}),
|
|
48
55
|
nextStep: output.nextStep, ...(state.feedback ? { feedback: state.feedback } : {}) };
|
|
49
56
|
}
|
|
50
57
|
async function archiveAttempt(location, state) {
|
|
@@ -56,23 +63,28 @@ async function archiveAttempt(location, state) {
|
|
|
56
63
|
await saveScopeAmendmentState(location, state);
|
|
57
64
|
}
|
|
58
65
|
async function finish(location, state, dependencies) {
|
|
59
|
-
if (!state.
|
|
60
|
-
state.attestation
|
|
61
|
-
|
|
66
|
+
if (!state.evidenceDescriptor && !state.applied) {
|
|
67
|
+
const attestation = state.attestation ?? await observeScopeAmendment(location, state);
|
|
68
|
+
const challenge = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: true });
|
|
69
|
+
await saveLocalScopeEvidence(attestation, challenge.purpose === 'scope-revision-refresh' ? 'scope-refresh' : 'scope-revision',
|
|
70
|
+
challenge, state, location, saveScopeAmendmentState);
|
|
71
|
+
}
|
|
72
|
+
if (!state.applied) {
|
|
73
|
+
const attestationRef = await ensureScopeEvidence(state, location, dependencies, state.challenged.challenge, saveScopeAmendmentState);
|
|
74
|
+
await amendmentRemote('applied', 'workflow-scope-apply', { proposalId: location.identity.proposalId,
|
|
75
|
+
proposalDigest: location.identity.proposalDigest, attestationRef }, state, location, dependencies);
|
|
62
76
|
}
|
|
63
|
-
const applied = state.applied
|
|
64
|
-
proposalId: location.identity.proposalId, proposalDigest: location.identity.proposalDigest,
|
|
65
|
-
attestation: state.attestation }, state, location, dependencies);
|
|
66
|
-
const result = validateApplied(applied, state, location);
|
|
77
|
+
const applied = state.applied, result = await validateApplied(applied, state, location);
|
|
67
78
|
await retainScopeAmendmentReference(location, applied); return result;
|
|
68
79
|
}
|
|
69
80
|
async function amend(location, dependencies) {
|
|
70
81
|
const state = await readScopeAmendmentState(location);
|
|
82
|
+
if (state.evidencePending || state.pending?.operation === 'scope-evidence-prepare') return finish(location, state, dependencies);
|
|
71
83
|
if (state.pending) {
|
|
72
84
|
const pending = state.pending;
|
|
73
85
|
await amendmentRemote(pending.stage, pending.input.operation, pending.input.payload, state, location, dependencies);
|
|
74
86
|
if (pending.stage === 'applied') {
|
|
75
|
-
const result = validateApplied(state.applied, state, location);
|
|
87
|
+
const result = await validateApplied(state.applied, state, location);
|
|
76
88
|
await retainScopeAmendmentReference(location, state.applied); return result;
|
|
77
89
|
}
|
|
78
90
|
return finish(location, state, dependencies);
|
|
@@ -95,7 +107,7 @@ async function amend(location, dependencies) {
|
|
|
95
107
|
const challenge = scopeAmendmentChallenge(challenged.challenge, location);
|
|
96
108
|
if (status.effective ? challenge.purpose !== 'scope-revision-refresh'
|
|
97
109
|
|| challenge.authorityRevisionDigest !== status.effective.revisionDigest || challenge.expectedFactRevision !== status.effective.factRevision + 1
|
|
98
|
-
|| challenge.currentScopeArtifactDigest !== sha256(canonical(status.effective.scope))
|
|
110
|
+
|| challenge.currentScopeArtifactDigest !== (status.effective.scopeArtifactDigest ?? sha256(canonical(status.effective.scope)))
|
|
99
111
|
: challenge.purpose !== 'scope-revision' || challenge.expectedRevision !== proposal.expectedRevision
|
|
100
112
|
|| challenge.previousDigest !== proposal.previousDigest) amendmentFail('Scope amendment challenge has a different original authority head');
|
|
101
113
|
return finish(location, state, dependencies);
|
|
@@ -115,9 +127,9 @@ export async function runScopeAmend(repositoryRoot, input, dependencies) {
|
|
|
115
127
|
message: ['ENTROROVE_SCOPE_AMENDMENT_BINDING', 'ENTROROVE_SCOPE_AMENDMENT_STATE', 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN',
|
|
116
128
|
'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', 'ENTROROVE_SCOPE_AMENDMENT_MUTATION_PENDING'].includes(error?.code) ? error.message
|
|
117
129
|
: 'Scope amendment stopped; preserve the original proposal, signer, journal and actual evidence.',
|
|
118
|
-
...(requestId ? { requestId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
130
|
+
...(requestId ? { requestId } : {}), ...(error?.uploadId ? { uploadId: error.uploadId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
119
131
|
...(error?.feedback ? { feedback: error.feedback } : {}),
|
|
120
|
-
...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-amend', requestId,
|
|
132
|
+
...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-amend', ...(requestId ? { requestId } : { uploadId: error.uploadId }),
|
|
121
133
|
method: 'GET', reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
122
134
|
}
|
|
123
135
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
-
import { mutationHome14, privateMutationDirectory14, readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
|
|
3
|
+
import { mutationHome14, mutationFile14, privateMutationDirectory14, readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
|
|
4
4
|
import { scopeRevisionAuthority, amendmentFail } from './scope-amendment-wire.mjs';
|
|
5
|
+
import { readScopeEvidence } from './scope-evidence-local.mjs';
|
|
6
|
+
import { evidenceReference } from './scope-evidence-wire.mjs';
|
|
7
|
+
import { trustedOrigin } from './remote-transport.mjs';
|
|
5
8
|
|
|
6
9
|
async function referenceLocation(root, home, flowId, revisionDigest) {
|
|
7
10
|
const stateRoot = path.join(await mutationHome14(home), '.entrorove');
|
|
@@ -11,21 +14,36 @@ async function referenceLocation(root, home, flowId, revisionDigest) {
|
|
|
11
14
|
export async function retainScopeAmendmentReference(location, output) {
|
|
12
15
|
const effective = output.effectiveAuthority, target = await referenceLocation(location.root, location.home,
|
|
13
16
|
location.identity.flowId, output.scopeRevisionDigest);
|
|
14
|
-
const
|
|
17
|
+
const compact = effective.record.schemaVersion === 'entrorove.scope-revision-authority/2.0';
|
|
18
|
+
if (compact) await readScopeEvidence(location, evidenceReference(effective.record.candidateAttestationRef, undefined, location));
|
|
19
|
+
const value = { schemaVersion: compact ? 'entrorove.scope-amendment-reference/2.0' : 'entrorove.scope-amendment-reference/1.0',
|
|
20
|
+
...(compact ? { apiOrigin: location.apiOrigin } : {}), input: {
|
|
15
21
|
flowId: location.identity.flowId, proposalId: location.identity.proposalId, proposalDigest: location.identity.proposalDigest }, record: effective.record };
|
|
16
22
|
await privateMutationDirectory14(target.directory, { privateFrom: target.stateRoot });
|
|
17
23
|
try { if (canonical(await readMutationJson14(target.file)) !== canonical(value)) amendmentFail('Scope amendment original authority reference changed'); }
|
|
18
24
|
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(target.file, value, { privateFrom: target.stateRoot }); }
|
|
19
25
|
}
|
|
26
|
+
async function readReferenceEvidence(reference, input) {
|
|
27
|
+
const origin = trustedOrigin(reference.apiOrigin), stateRoot = path.join(await mutationHome14(input.home), '.entrorove');
|
|
28
|
+
const base = path.join(stateRoot, 'scope-runners', sha256(`${origin}:${reference.record.flowId}:${input.repositoryRoot}`));
|
|
29
|
+
const file = path.join(base, 'identity', 'binding.json'), item = await mutationFile14(file);
|
|
30
|
+
if (item.stat.size > 1_000_000 || item.stat.nlink !== 1) amendmentFail('Original amendment signer binding is invalid');
|
|
31
|
+
const binding = await readMutationJson14(file);
|
|
32
|
+
if (binding.repositoryRoot !== input.repositoryRoot || binding.apiOrigin !== origin || binding.flowId !== reference.record.flowId) amendmentFail('Original amendment signer binding changed');
|
|
33
|
+
const location = { base, binding };
|
|
34
|
+
await readScopeEvidence(location, evidenceReference(reference.record.candidateAttestationRef, undefined, location));
|
|
35
|
+
}
|
|
20
36
|
export async function scopeAmendmentRefreshAction(input, pass) {
|
|
21
37
|
if (!pass.scopeRevisionDigest) return undefined;
|
|
22
38
|
try {
|
|
23
39
|
const target = await referenceLocation(input.repositoryRoot, input.home, pass.flowId, pass.scopeRevisionDigest);
|
|
24
40
|
const reference = await readMutationJson14(target.file);
|
|
25
|
-
const
|
|
26
|
-
|
|
41
|
+
const compact = reference.schemaVersion === 'entrorove.scope-amendment-reference/2.0';
|
|
42
|
+
const record = scopeRevisionAuthority(reference.record, `entrorove.scope-revision-authority/${compact ? '2.0' : '1.0'}`);
|
|
43
|
+
if (!['entrorove.scope-amendment-reference/1.0', 'entrorove.scope-amendment-reference/2.0'].includes(reference.schemaVersion) || sha256(canonical(record)) !== pass.scopeRevisionDigest
|
|
27
44
|
|| record.flowId !== pass.flowId || record.taskDigest !== pass.taskDigest || record.scope.keyId !== pass.memberScopeKeyId
|
|
28
45
|
|| ['flowId', 'proposalId', 'proposalDigest'].some(field => reference.input[field] !== record[field])) amendmentFail('Stored refresh reference has another original authority');
|
|
46
|
+
if (compact) await readReferenceEvidence(reference, input);
|
|
29
47
|
return { command: 'npx @entrorove/skill scope-amend <absolute repositoryRoot>',
|
|
30
48
|
input: { ...reference.input, gatePassPath: input.gatePassPath }, resumeOriginalFlow: true, requiredBefore: 'mutation-settle' };
|
|
31
49
|
} catch {
|
|
@@ -80,7 +80,7 @@ export async function scopeAmendmentLocation(repositoryRoot, input, dependencies
|
|
|
80
80
|
const directory = path.join(base, 'amendments', sha256(canonical(input)));
|
|
81
81
|
await privateMutationDirectory14(directory, { privateFrom: stateRoot });
|
|
82
82
|
const contextAuthority = await boundScopeContexts(base, binding, dependencies);
|
|
83
|
-
return { root, home, stateRoot, directory, file: path.join(directory, 'journal.json'), identity,
|
|
83
|
+
return { root, home, apiOrigin, base, stateRoot, directory, file: path.join(directory, 'journal.json'), identity,
|
|
84
84
|
binding, key, signerConfigPath, original, contextAuthority };
|
|
85
85
|
}
|
|
86
86
|
export async function readScopeAmendmentState(location) {
|
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);
|
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
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
|
+
import { readMutationJson14, atomicMutationJson14, mutationFile14 } from './local-mutation-files.mjs';
|
|
5
|
+
import { evidenceFail } from './scope-evidence-wire.mjs';
|
|
6
|
+
|
|
7
|
+
export async function saveScopeRunnerJournal(location, state) {
|
|
8
|
+
if (Buffer.byteLength(canonical(state)) + 1 > 8_000_000) evidenceFail('Scope connection journal exceeds its unchanged bounded control limit');
|
|
9
|
+
await atomicMutationJson14(location.file, state, { privateFrom: location.stateRoot, replace: true });
|
|
10
|
+
}
|
|
11
|
+
export async function scopeRunnerJournal(location, identity) {
|
|
12
|
+
let state;
|
|
13
|
+
try {
|
|
14
|
+
const item = await mutationFile14(location.file);
|
|
15
|
+
if (item.stat.size > 8_000_000 || item.stat.nlink !== 1) evidenceFail('Scope connection journal is invalid');
|
|
16
|
+
state = await readMutationJson14(location.file);
|
|
17
|
+
if (state.schemaVersion !== 'entrorove.scope-connection-journal/1.0' || canonical(state.identity) !== canonical(identity)) {
|
|
18
|
+
evidenceFail('Scope connection journal replaced its original task, repository or complete input');
|
|
19
|
+
}
|
|
20
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
21
|
+
if (state && !state.pending && !state.evidencePending && (state.completed || state.rejection)) {
|
|
22
|
+
const archive = path.join(location.directory, `${state.attemptId}-${sha256(canonical(state))}.json`);
|
|
23
|
+
try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) evidenceFail('Scope connection history changed'); }
|
|
24
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
|
|
25
|
+
state = undefined;
|
|
26
|
+
}
|
|
27
|
+
if (!state) {
|
|
28
|
+
state = { schemaVersion: 'entrorove.scope-connection-journal/1.0', identity, attemptId: randomUUID() };
|
|
29
|
+
await saveScopeRunnerJournal(location, state);
|
|
30
|
+
}
|
|
31
|
+
return state;
|
|
32
|
+
}
|