@entrorove/skill 0.1.57 → 0.1.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY-MAP.md +2 -0
- package/CHANGELOG.md +10 -0
- package/IDE-EXECUTION.md +13 -1
- package/SKILL.md +2 -1
- package/cli.mjs +1 -1
- package/connected-cli.mjs +2 -0
- package/ide-command.mjs +1 -1
- package/ide-hook.mjs +25 -8
- package/local-mutation-authority.mjs +5 -1
- package/local-mutation-wire.mjs +4 -1
- package/local-scope-signature.mjs +6 -2
- package/manifest.json +1 -1
- package/mutation-client.mjs +3 -2
- package/package.json +8 -1
- package/release-state.json +24 -17
- package/release.mjs +7 -0
- package/releases.json +12 -0
- package/scope-amendment-client.mjs +123 -0
- package/scope-amendment-mutation.mjs +26 -0
- package/scope-amendment-observer.mjs +63 -0
- package/scope-amendment-reference.mjs +35 -0
- package/scope-amendment-remote.mjs +74 -0
- package/scope-amendment-state.mjs +94 -0
- package/scope-amendment-wire.mjs +83 -0
- package/workflow-local-contracts.mjs +2 -0
- package/workflow-local-runner.mjs +5 -3
package/CAPABILITY-MAP.md
CHANGED
|
@@ -93,6 +93,8 @@ Entrorove 只治理通过技能入口执行的动作:路径、通行证、快
|
|
|
93
93
|
|
|
94
94
|
`scope-connect <absolute repositoryRoot>` 是 CLI 编排入口,不改变这 100 项本地操作。它串联远端 `scope-runner-prepare/register/challenge/attest` 与已有真实快照、签名检查,为原账号与任务提交范围证据;不授予执行证据签名或写入权限。全部远端操作以实时 discovery 为准。
|
|
95
95
|
|
|
96
|
+
`scope-amend <original repositoryRoot>` 使用原 S 签名人完成同一 flow 的精确范围补正。先通过 `workflow-scope-review` 审核完整文件、哈希与写入/只读角色,再提供 `{flowId,proposalId,proposalDigest}`。真实审批、原合同和剩余预算仍有效;成功后计划绑定返回的 `scopeRevisionDigest`,原入口与失败历史保留。详见 [IDE-EXECUTION.md](IDE-EXECUTION.md#correcting-omitted-scope-before-plan-freeze)。
|
|
97
|
+
|
|
96
98
|
当前增量操作(不改写上述历史分母)为:`peer-status`、`discipline-check`、`discipline-review`、`flow-cancel`、`flow-abandon`、`guarded-delete`、`guarded-move`、`mutation-delegate-prepare`、`mutation-consumption-attest`。它们计入当前 109 项公开 schema。
|
|
97
99
|
|
|
98
100
|
| 分区 | 精确操作名 | 证据位置 |
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.59 — 2026-10-17
|
|
4
|
+
|
|
5
|
+
- 新增同一任务的精确范围补正:原账号审核文件哈希与写入/只读角色,原S签名真实事实并重评原合同;计划、写入和结算绑定不可变范围修订,改后刷新保留原M消费证据与失败历史。
|
|
6
|
+
- Add same-task exact scope amendment with original owner review of file hashes and write/read-only roles, original S observations and native contract reassessment; bind plans, writes and settlement to immutable scope revisions and retain actual M consumption and failure history during post-write refresh.
|
|
7
|
+
|
|
8
|
+
## 0.1.58 — 2026-10-17
|
|
9
|
+
|
|
10
|
+
- 修复截图等多模态 PostToolUse 事件超过旧输入上限时被误拦:完整读取并严格校验最多 32 MiB 的 UTF-8 JSON 对象,超限或无效事件返回固定安全诊断;保留原授权、待回执调用及 O 证据,私有状态文件仍限 1,000,000 字节。
|
|
11
|
+
- Fixed multimodal PostToolUse events such as screenshots being rejected by the old input limit. Read and strictly validate the complete UTF-8 JSON object up to 32 MiB, returning fixed safe diagnostics for oversized or invalid events while preserving original authority, pending calls and O evidence. Private state files retain their 1,000,000-byte limit.
|
|
12
|
+
|
|
3
13
|
## 0.1.57 — 2026-10-17
|
|
4
14
|
|
|
5
15
|
- 云端关闭时以原本地 IDE 执行与实际测试完成本地验收,补齐签名步骤续跑、最终验收、钩子与队列恢复;保留原范围、审批和 S/M 证据,明确未做独立验证。修复原规划字段诊断与沙箱模式恢复。
|
package/IDE-EXECUTION.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
Install or update the managed copy with `npx @entrorove/skill install`. In Codex, use `npx @entrorove/skill install --host codex` to configure Codex only; this leaves Claude Code hook settings and skill copies alone. The installer keeps one global managed entrorove skill, replaces its older pristine version, removes only pristine duplicate installations for the selected host, and preserves hand-edited files. An older package cannot overwrite a newer managed version.
|
|
2
2
|
|
|
3
|
+
Host hook events, including multimodal `PostToolUse` screenshot results, accept at most 32 MiB (33,554,432 UTF-8 bytes). The complete bounded event is decoded once and follows the original session, tool-call and signature checks; image results do not grant permissions or erase a pending call. Oversized events return `ENTROROVE_IDE_INPUT_TOO_LARGE`; invalid UTF-8 or JSON objects return `ENTROROVE_IDE_INPUT_INVALID_JSON`. These fixed diagnostics do not include the event contents. Private IDE state JSON files retain their separate 1,000,000-byte limit. A hook error after a tool action does not prove the action was unexecuted: inspect the original result or status before considering another attempt.
|
|
4
|
+
|
|
3
5
|
Register or sign in by email at https://entrorove.com. Enter the account key once with `npx @entrorove/skill configure` and `{"token":"..."}` on standard input. The credential persists in the current OS user's restricted local store across skill upgrades. Before asking again, use the stored credential; a missing or invalid credential must be reported explicitly. Never place the key in arguments, URLs, project files or logs. entrorove accounts and keys are independent of other services.
|
|
4
6
|
|
|
5
7
|
If the IDE guard itself is the blocked step, run `entrorove unlock` directly from the affected IDE terminal. The command is offline and records a 15-minute lease bound to that IDE session; it does not call a hook, read a credential, accept the agreement, inspect the protocol or authorize project writes. The host session ID must be present in the environment or supplied as `ENTROROVE_SESSION`. Under the lease, `flow-cancel` and `flow-abandon` recover the original flow; successful `flow-abandon` releases its task binding in both the flow state and the IDE session record, even without a hook callback. Queued requests remain, and other IDE sessions stay guarded.
|
|
@@ -74,6 +76,16 @@ When intake returns `workflow-gate`, connect the current repository before invok
|
|
|
74
76
|
|
|
75
77
|
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.
|
|
76
78
|
|
|
79
|
+
### Correcting omitted scope before plan freeze
|
|
80
|
+
|
|
81
|
+
If the original selection omitted actual components or inspection files, use the discovered `workflow-scope-review` on the **same flow**, with its original gate digest, current effective revision (`0` before the first amendment), and the complete sorted exact file map. Each entry declares its real `path`, `sha256`, `access` (`write` or `read-only`), original `requirementRefs` and reason. Include all original entries even when reducing their access to read-only. Existing dependencies may become writable only through this exact new review; a directory prefix or manifest inclusion does not grant that permission.
|
|
82
|
+
|
|
83
|
+
Present the returned scope-revision question and submit the original owner's actual answer through its bound `interaction-answer`. Approval is specific to that complete proposal; it does not approve a plan or perform a write. Then run `npx @entrorove/skill scope-amend <original repositoryRoot>` with `{flowId,proposalId,proposalDigest}` on stdin. It loads the **existing** S key and original repository/entry/import-context binding, obtains a W challenge, scans the real complete file set into a separate candidate snapshot and submits signed evidence. It never creates a replacement signer, answers approval itself, or overwrites the original scope history.
|
|
84
|
+
|
|
85
|
+
Only a successful native gate and the original approved contracts and remaining budgets can activate the immutable scope revision. Keep the original Blueprint, plan identities, task order and failure receipts. Submit the complete corrected plan with the returned `scopeRevisionDigest` under its canonical new request identity. Exact read-only files remain read-only, even if included in the manifest or a broad write contract. An unknown request outcome is recovered with the original GET; do not replay an uncertain POST or restart the flow.
|
|
86
|
+
|
|
87
|
+
After an amended task's actual guarded writes, follow its returned `scope-amend` refresh action, which adds the original stored `gatePassPath` to the same `{flowId,proposalId,proposalDigest}`. The connector preserves the real M consumption receipt and observes new S facts without another write. It appends facts under the same approved role revision, then the original `mutation-settle` and local observation can continue. Changed read-only files, a different pass or extra writes remain blocked. A proof refresh cannot change permissions or reset a consumed budget. Live `workflow-scope-status` observes the current head; an uncertain mutating request is still recovered only through its original GET.
|
|
88
|
+
|
|
77
89
|
Keep snapshot before-images distinct from a frozen execution plan's expected final file hashes. Controlled writes consume the original approved authority; the runner then verifies the actual planned final bytes. Never edit a signed plan or silently substitute a newer snapshot after an unknown or failed gate. Existing changes outside that authority require explicit revalidation of the same original flow.
|
|
78
90
|
|
|
79
91
|
Computed runtime imports remain explicitly unresolved with their actual source paths and hashes. A member's scope-only signer may submit these observations to enter restricted planning; the cloud returns `planningOnly: true` and `requires-complete-import-graph-before-mutation`. Do not treat this as full dependency verification, execution permission, or business completion. Obtain the limited original-task editing delegation below when eligible, or resolve and verify the dependency contract. Never set `complete` manually or replace the original signer or paths.
|
|
@@ -88,7 +100,7 @@ The connector creates a separate local editing key, requests the cloud's origina
|
|
|
88
100
|
|
|
89
101
|
Use the returned `gatePassPath` with the discovered `local guarded-write` contract and preserve its exact flow, step and repository. Only existing declared modify targets are eligible. Runtime loader sources and observed project configuration stay protected by their original hashes and physical file identity. The local preflight rejects changed imports, removed definitions, public contract changes, new unknown dependencies and unsupported source formats. Unknown dependency coverage remains unknown after an eligible edit.
|
|
90
102
|
|
|
91
|
-
After the pass's actual writes finish,
|
|
103
|
+
After the pass's actual writes finish, follow its scope refresh action: an amended scope uses `scope-amend` with the original stored `gatePassPath`; an unchanged original scope uses its complete original `scope-connect` input, including `importContexts`. Run `npx @entrorove/skill mutation-settle <original repositoryRoot>` with `{taskId,flowId,stepId,gatePassPath}`. The local M signer observes the real completed consumption, and the service requires a fresh S scan captured after those writes. This settlement preserves both before and after hashes, unchanged modes, pass nonces and the original lease. It never creates an architecture verification event. Further edits use the returned `localAction`, whose `previousPassId` identifies that exact settled pass; do not resend an already consumed write.
|
|
92
104
|
|
|
93
105
|
If settlement reports `ENTROROVE_SCOPE_AFTER_WRITE_REQUIRED`, refresh only the original scope and retry the same consumption request and receipt. A lost network response is different: rerunning the identical connector restores its original GET result without a replacement POST or signature.
|
|
94
106
|
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Execute an engineering task through Entrorove, preserving its origi
|
|
|
5
5
|
|
|
6
6
|
# entrorove · 熵巡
|
|
7
7
|
|
|
8
|
-
Version: 0.1.
|
|
8
|
+
Version: 0.1.59.
|
|
9
9
|
|
|
10
10
|
Entrorove applies to the whole task once selected. 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
|
|
|
@@ -39,6 +39,7 @@ When a new user requirement arrives while the original task is unfinished, store
|
|
|
39
39
|
- Invoke service operations with JSON stdin to `npx @entrorove/skill invoke` or a host-approved rewritten equivalent.
|
|
40
40
|
- 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.
|
|
41
41
|
- 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.
|
|
42
|
+
- 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.
|
|
42
43
|
- `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.
|
|
43
44
|
- Cloud events and homepage progress share the same account-bound stream. Show public entering, allocation, model activity, output and termination as they occur. Show the server-selected promotion and execution notice separately. Deduplicate task/event/receipt IDs; never invent progress or model reasoning.
|
|
44
45
|
- `status`, `cloud-status` and `feedback` recover the original identity with GET only. Never repeat a paid POST to recover an uncertain result.
|
package/cli.mjs
CHANGED
|
@@ -477,7 +477,7 @@ async function main() {
|
|
|
477
477
|
} else if (command === 'local') {
|
|
478
478
|
await runLocal(args);
|
|
479
479
|
} else {
|
|
480
|
-
throw new Error('Usage: entrorove unlock | uninstall | install [--host codex] | configure | agreement | invoke | queue | native <domain> <operation> | notifications configure|status|test | scope-connect <repositoryRoot> | mutation-connect <repositoryRoot> | mutation-settle <repositoryRoot> | workflow-observe-step <repositoryRoot> | host-message [adapterModulePath] | cloud-task | cloud-watch | cloud-status | feedback <requestId> [generation] | status <requestId> | flows | usage [flowId] | billing | recurring list|claim|finish|runs | review | local (JSON on stdin)');
|
|
480
|
+
throw new Error('Usage: entrorove unlock | uninstall | install [--host codex] | configure | agreement | invoke | queue | native <domain> <operation> | notifications configure|status|test | scope-connect <repositoryRoot> | scope-amend <repositoryRoot> | mutation-connect <repositoryRoot> | mutation-settle <repositoryRoot> | workflow-observe-step <repositoryRoot> | host-message [adapterModulePath] | cloud-task | cloud-watch | cloud-status | feedback <requestId> [generation] | status <requestId> | flows | usage [flowId] | billing | recurring list|claim|finish|runs | review | local (JSON on stdin)');
|
|
481
481
|
}
|
|
482
482
|
}
|
|
483
483
|
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { const { captureIdeResults } = await import('./ide-results.mjs');
|
package/connected-cli.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { runScopeConnect } from './scope-runner-client.mjs';
|
|
2
2
|
import { runMutationConnect, runMutationSettle } from './mutation-client.mjs';
|
|
3
3
|
import { runWorkflowObserveStep } from './workflow-observation-client.mjs';
|
|
4
|
+
import { runScopeAmend } from './scope-amendment-client.mjs';
|
|
4
5
|
|
|
5
6
|
const commands = Object.freeze({
|
|
6
7
|
'scope-connect': runScopeConnect,
|
|
8
|
+
'scope-amend': runScopeAmend,
|
|
7
9
|
'mutation-connect': runMutationConnect,
|
|
8
10
|
'mutation-settle': runMutationSettle,
|
|
9
11
|
'workflow-observe-step': runWorkflowObserveStep,
|
package/ide-command.mjs
CHANGED
|
@@ -88,7 +88,7 @@ export function sdkCommand(parsed, runtime) {
|
|
|
88
88
|
if (args[0] === 'usage' && args.length <= 2 && !parsed.input
|
|
89
89
|
&& (args[1] === undefined || /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(args[1]))) return { args };
|
|
90
90
|
if (args[0] === 'local' && /^[a-z][a-z0-9-]+$/u.test(args[1] ?? '') && [2,3].includes(args.length) && parsed.input) return { args, input: parsed.input };
|
|
91
|
-
if (['scope-connect', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(args[0]) && args.length === 2 && parsed.input) return { args, input: parsed.input };
|
|
91
|
+
if (['scope-connect', 'scope-amend', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(args[0]) && args.length === 2 && parsed.input) return { args, input: parsed.input };
|
|
92
92
|
if (args[0] === 'native' && args.length === 3 && parsed.input) return { args, input: parsed.input };
|
|
93
93
|
return null;
|
|
94
94
|
}
|
package/ide-hook.mjs
CHANGED
|
@@ -16,6 +16,11 @@ import { registerIdeRequirement, ideQueueContext, unclassifiedIdeRequirements,
|
|
|
16
16
|
|
|
17
17
|
const output = (event, extra = {}) => ({ hookSpecificOutput: { hookEventName: event, ...extra } });
|
|
18
18
|
const deny = reason => output('PreToolUse', { permissionDecision: 'deny', permissionDecisionReason: reason });
|
|
19
|
+
export const IDE_HOOK_MAX_INPUT_BYTES = 32 * 1024 * 1024;
|
|
20
|
+
const inputErrors = {
|
|
21
|
+
ENTROROVE_IDE_INPUT_TOO_LARGE: 'ENTROROVE_IDE_INPUT_TOO_LARGE:Entrorove hook 事件超过 32 MiB UTF-8 字节上限;本次事件已拒绝。',
|
|
22
|
+
ENTROROVE_IDE_INPUT_INVALID_JSON: 'ENTROROVE_IDE_INPUT_INVALID_JSON:Entrorove hook 事件不是有效的 UTF-8 JSON 对象;本次事件已拒绝。',
|
|
23
|
+
};
|
|
19
24
|
const publicId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,200}$/u.test(value);
|
|
20
25
|
const readTools = new Set(['wait', 'view_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource', 'web_search', 'update_plan', 'request_user_input', 'request_user_input_async', 'get_goal']);
|
|
21
26
|
const unlockCommand = 'entrorove unlock';
|
|
@@ -178,11 +183,11 @@ async function beforeTool(event, state, home) {
|
|
|
178
183
|
const observer = discovery || ['status','feedback','cloud-status','configure','agreement','install','queue','flows','usage','billing','recurring'].includes(call.args[0]);
|
|
179
184
|
if (unclassifiedIdeRequirements(state) && !observer && !(unlocked && isManagementCall(call))) return denyBlocked(state, event, 'Entrorove:新需求已保存,先用 queue status 读取并按轻重缓急 route 分类;禁止丢弃新需求或覆盖原任务。');
|
|
180
185
|
if (newStart && !checkQueuedStart(state,call)) return denyBlocked(state, event, 'Entrorove:新任务目标必须与实际进入队列的用户原文一致。');
|
|
181
|
-
if (['local', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(call.args[0]) && !discovery && !state.flowId) return denyBlocked(state, event, 'Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
|
|
186
|
+
if (['local', 'scope-amend', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(call.args[0]) && !discovery && !state.flowId) return denyBlocked(state, event, 'Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
|
|
182
187
|
if (state.pendingCall && !observer && !(unlocked && isManagementCall(call))) return denyBlocked(state, event, 'Entrorove:原调用尚未收到结束回执;先只读恢复其状态,禁止并发重发。');
|
|
183
188
|
const input = call.args[0] === 'invoke' ? call.input?.input : call.input;
|
|
184
189
|
if (input?.flowId && state.flowId && input.flowId !== state.flowId || input?.taskId && state.taskId && ['cloud-watch','cloud-status'].includes(call.args[0]) && input.taskId !== state.taskId) return denyBlocked(state, event, 'Entrorove:调用与本 IDE 任务的原编号不一致。');
|
|
185
|
-
if (['scope-connect', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(call.args[0]) && call.args[1] !== state.repositoryRoot
|
|
190
|
+
if (['scope-connect', 'scope-amend', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(call.args[0]) && call.args[1] !== state.repositoryRoot
|
|
186
191
|
|| call.args[0] === 'local' && (input?.repositoryRoot && input.repositoryRoot !== state.repositoryRoot || call.args[2] && call.args[2] !== state.repositoryRoot)) return denyBlocked(state, event, 'Entrorove:调用不能替换本任务的实际仓库。');
|
|
187
192
|
let requestId;
|
|
188
193
|
if (newStart) state.taskGoal = call.args[0] === 'cloud-task' ? call.input?.goal : call.input?.input?.task?.goal;
|
|
@@ -191,7 +196,7 @@ async function beforeTool(event, state, home) {
|
|
|
191
196
|
requestId = buildInvocation(call.input.operation, call.input.input).requestId;
|
|
192
197
|
}
|
|
193
198
|
const pending = { toolUseId: event.tool_use_id, inputDigest: ideDigest(call), command: call.args[0],
|
|
194
|
-
operation: operationOf(call), observer, managementOnly: observer || isManagementCall(call)
|
|
199
|
+
operation: operationOf(call), observer, managementOnly: observer || call.args[0] === 'scope-amend' || isManagementCall(call)
|
|
195
200
|
&& !['start', 'continue', 'cloud-task'].includes(operationOf(call)) || operationOf(call) === 'task-checkpoint',
|
|
196
201
|
...(requestId ? { requestId } : {}) };
|
|
197
202
|
originalLocalCall(pending, call, input);
|
|
@@ -320,17 +325,29 @@ export async function runIdeHook(event, { home } = {}) {
|
|
|
320
325
|
}, home);
|
|
321
326
|
}
|
|
322
327
|
let incomingEvent;
|
|
328
|
+
async function readHookEvent() {
|
|
329
|
+
const chunks = []; let bytes = 0;
|
|
330
|
+
for await (const chunk of process.stdin) {
|
|
331
|
+
const buffer = Buffer.from(chunk); bytes += buffer.length;
|
|
332
|
+
if (bytes > IDE_HOOK_MAX_INPUT_BYTES) throw Object.assign(new Error('Hook event byte limit exceeded'), { code: 'ENTROROVE_IDE_INPUT_TOO_LARGE' });
|
|
333
|
+
chunks.push(buffer);
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const value = JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes)));
|
|
337
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
|
|
338
|
+
return value;
|
|
339
|
+
} catch { throw Object.assign(new Error('Hook event JSON is invalid'), { code: 'ENTROROVE_IDE_INPUT_INVALID_JSON' }); }
|
|
340
|
+
}
|
|
323
341
|
async function main() {
|
|
324
|
-
|
|
325
|
-
for await (const chunk of process.stdin) { raw += chunk; if (raw.length > 1_000_000) throw new Error('Host hook input is too large'); }
|
|
326
|
-
const event = JSON.parse(raw); incomingEvent = event.hook_event_name;
|
|
342
|
+
const event = await readHookEvent(); incomingEvent = event.hook_event_name;
|
|
327
343
|
const result = await runIdeHook(event);
|
|
328
344
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
329
345
|
}
|
|
330
|
-
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(
|
|
346
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(cause => {
|
|
331
347
|
// A non-zero generic exit may fail open in the host. Always return a supported
|
|
332
348
|
// explicit denial, without including arguments, prompts, credentials or paths.
|
|
333
|
-
const reason =
|
|
349
|
+
const reason = Object.hasOwn(inputErrors, cause?.code) ? inputErrors[cause.code]
|
|
350
|
+
: `Entrorove 安全检查失败;本次工具调用已阻止。可复制解锁命令:${unlockCommand}`;
|
|
334
351
|
const result = incomingEvent === 'Stop' ? { decision: 'block', reason } : incomingEvent === 'PreToolUse' ? deny(reason)
|
|
335
352
|
: { continue: false, stopReason: reason, systemMessage: reason };
|
|
336
353
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
@@ -16,6 +16,7 @@ import { readDisciplineSnapshot } from './local-discipline-snapshot.mjs';
|
|
|
16
16
|
import { assertDisciplineRepository } from './local-discipline-repository.mjs';
|
|
17
17
|
import { withPreservedMutationFence } from './local-preserved-mutation-state.mjs';
|
|
18
18
|
import { assertPreservedMutationProtection } from './local-preserved-mutation-protection.mjs';
|
|
19
|
+
import { scopeAmendmentRefreshAction } from './scope-amendment-reference.mjs';
|
|
19
20
|
export { beginMutationBatch14, finishMutationBatch14, failMutationBatch14 } from './local-mutation-batch.mjs';
|
|
20
21
|
|
|
21
22
|
const SCHEMA = 'entrorove.local-execution/1.0';
|
|
@@ -117,7 +118,10 @@ async function performWrite(input, state, value) {
|
|
|
117
118
|
await privateMutationDirectory14(path.join(stateRoot, 'ledgers', pass.repositoryDigest), { privateFrom: stateRoot });
|
|
118
119
|
await appendLedgerRecord(input, 'guarded-write', { flowId: pass.flowId, stepId: pass.stepId, targetPath: target,
|
|
119
120
|
sha256: written.sha256, passId: pass.passId });
|
|
120
|
-
|
|
121
|
+
const nextAction = pass.paths.every(file => completed.writes[file]?.status === 'written')
|
|
122
|
+
? await scopeAmendmentRefreshAction({ ...input, repositoryRoot: binding.root }, pass) : undefined;
|
|
123
|
+
return { schemaVersion: SCHEMA, status: 'verified', written: true, targetPath: target, sha256: written.sha256,
|
|
124
|
+
...(nextAction ? { nextAction } : {}) };
|
|
121
125
|
});
|
|
122
126
|
}
|
|
123
127
|
export async function guardedWrite14(input) {
|
package/local-mutation-wire.mjs
CHANGED
|
@@ -40,6 +40,9 @@ function pinnedAuthority() {
|
|
|
40
40
|
} catch { mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Pinned cloud mutation authority is unavailable or invalid'); }
|
|
41
41
|
}
|
|
42
42
|
function identity(payload) {
|
|
43
|
+
if (Object.hasOwn(payload, 'scopeRevisionDigest') && !SHA.test(payload.scopeRevisionDigest)) {
|
|
44
|
+
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority scope revision is invalid');
|
|
45
|
+
}
|
|
43
46
|
const local = payload.verificationMode === 'member-local';
|
|
44
47
|
if (local ? payload.runnerKeyId !== null || !payload.writeDelegation
|
|
45
48
|
|| !SHA.test(payload.memberScopeKeyId ?? '') || !SHA.test(payload.memberScopeBindingDigest ?? '')
|
|
@@ -103,7 +106,7 @@ export function mutationChallenge14(value, options) {
|
|
|
103
106
|
function authorityFields(value, fields) {
|
|
104
107
|
const original = Object.hasOwn(value ?? {}, 'writeDelegation') ? [...fields.filter(field =>
|
|
105
108
|
!['executionAuthorityId', 'executionAuthorityDigest', 'executionAttemptNonce'].includes(field)), 'writeDelegation'] : fields;
|
|
106
|
-
return [...original, ...['previousPassId', ...localRoleFields].filter(field => Object.hasOwn(value ?? {}, field))];
|
|
109
|
+
return [...original, ...['previousPassId', 'scopeRevisionDigest', ...localRoleFields].filter(field => Object.hasOwn(value ?? {}, field))];
|
|
107
110
|
}
|
|
108
111
|
export function mutationPass14(value, options) {
|
|
109
112
|
const fields = [...authorityFields(value, PASS_FIELDS), ...['disciplinePolicy', 'parallelPaths'].filter(key => Object.hasOwn(value ?? {}, key))];
|
|
@@ -33,7 +33,10 @@ function binding(input, result) {
|
|
|
33
33
|
for (const field of ['taskDigest', 'chainDigest']) if (!digest.test(String(input[field] ?? ''))) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature requires original task and chain digests');
|
|
34
34
|
if (!digest.test(String(result.snapshotDigest ?? ''))) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature requires a verified preserved snapshot digest');
|
|
35
35
|
}
|
|
36
|
-
export async function attestScopeFacts(input, result) {
|
|
36
|
+
export async function attestScopeFacts(input, result, { maximumExpiresAt } = {}) {
|
|
37
|
+
if (maximumExpiresAt !== undefined && (!Number.isSafeInteger(maximumExpiresAt) || maximumExpiresAt < 1)) {
|
|
38
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature deadline must be a finite exact bound');
|
|
39
|
+
}
|
|
37
40
|
rejectScopePrivileges(input);
|
|
38
41
|
if (input.signerConfigPath === undefined || input.signerConfigPath === null) return result;
|
|
39
42
|
binding(input, result);
|
|
@@ -58,7 +61,8 @@ export async function attestScopeFacts(input, result) {
|
|
|
58
61
|
scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'scope signer configuration or signing key could not be read');
|
|
59
62
|
}
|
|
60
63
|
const now = Date.now();
|
|
61
|
-
const expiresAt = Math.min(now + config.receiptTtlMs, candidate ? Date.parse(candidate.expiresAt) : Infinity
|
|
64
|
+
const expiresAt = Math.min(now + config.receiptTtlMs, candidate ? Date.parse(candidate.expiresAt) : Infinity,
|
|
65
|
+
maximumExpiresAt ?? Infinity);
|
|
62
66
|
if (expiresAt <= now) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'candidate challenge expired before its actual scope signature');
|
|
63
67
|
const core = { schemaVersion: 'entrorove.scope-facts/1.0', flowId: input.flowId, stepId: input.stepId,
|
|
64
68
|
taskDigest: input.taskDigest, ownerId: input.ownerId, chainDigest: input.chainDigest, repositoryId: input.repositoryId,
|
package/manifest.json
CHANGED
package/mutation-client.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { ideClientHeaders } from './ide-presentation.mjs';
|
|
|
16
16
|
import { appendInvocation, readInvocations } from './local-invocations.mjs';
|
|
17
17
|
import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
|
|
18
18
|
import { attestMutationConsumption14, preservedMutationConsumptionState } from './local-mutation-consumption.mjs';
|
|
19
|
+
import { scopeAmendmentRefreshAction } from './scope-amendment-reference.mjs';
|
|
19
20
|
|
|
20
21
|
const schema = 'entrorove.mutation-connection-state/1.0';
|
|
21
22
|
const fields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', 'runnerId', 'paths'];
|
|
@@ -111,7 +112,7 @@ function passBinding(pass, challenge, attestation, options) {
|
|
|
111
112
|
mutationPass14(pass, options);
|
|
112
113
|
if (pass.challengeId !== challenge.challengeId || pass.challengeDigest !== sha256(canonical(challenge))
|
|
113
114
|
|| pass.attestationDigest !== sha256(canonical(attestation)) || pass.snapshotDigest !== attestation.manifest.snapshotDigest
|
|
114
|
-
|| [...fields, 'previousPassId', 'verificationMode', 'runnerKeyId', 'memberScopeKeyId', 'memberScopeBindingDigest', 'scopeBindingDigest']
|
|
115
|
+
|| [...fields, 'previousPassId', 'scopeRevisionDigest', 'verificationMode', 'runnerKeyId', 'memberScopeKeyId', 'memberScopeBindingDigest', 'scopeBindingDigest']
|
|
115
116
|
.some(field => canonical(pass[field]) !== canonical(challenge[field]))
|
|
116
117
|
|| canonical(pass.writeDelegation) !== canonical(challenge.writeDelegation)) fail('Mutation pass differs from its original signed challenge or actual M snapshot');
|
|
117
118
|
}
|
|
@@ -169,7 +170,7 @@ async function connect(identity, local, mutator, location, dependencies) {
|
|
|
169
170
|
if (consumption.complete) return { schemaVersion: 'entrorove.mutation-connection/1.0', status: 'blocked', flowId: identity.flowId,
|
|
170
171
|
code: 'ENTROROVE_MUTATION_SETTLEMENT_REQUIRED', gatePassPath: state.gatePassPath,
|
|
171
172
|
message: 'Original physical writes are consumed; settle their actual M receipt with fresh S evidence before requesting the next restricted pass',
|
|
172
|
-
localAction: { command: 'npx @entrorove/skill mutation-settle <absolute repositoryRoot>',
|
|
173
|
+
localAction: await scopeAmendmentRefreshAction({ ...input, gatePassPath: state.gatePassPath }, stored) ?? { command: 'npx @entrorove/skill mutation-settle <absolute repositoryRoot>',
|
|
173
174
|
input: { taskId: stored.taskId, flowId: stored.flowId, stepId: stored.stepId, gatePassPath: state.gatePassPath } } };
|
|
174
175
|
passBinding(state.issued.pass, challenge, state.attestation);
|
|
175
176
|
return connected(identity, state);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entrorove/skill",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"entrorove": "cli.mjs"
|
|
@@ -89,6 +89,13 @@
|
|
|
89
89
|
"local-task-handoff-write-receipts.mjs",
|
|
90
90
|
"remote-client.mjs",
|
|
91
91
|
"scope-runner-client.mjs",
|
|
92
|
+
"scope-amendment-client.mjs",
|
|
93
|
+
"scope-amendment-state.mjs",
|
|
94
|
+
"scope-amendment-wire.mjs",
|
|
95
|
+
"scope-amendment-remote.mjs",
|
|
96
|
+
"scope-amendment-observer.mjs",
|
|
97
|
+
"scope-amendment-mutation.mjs",
|
|
98
|
+
"scope-amendment-reference.mjs",
|
|
92
99
|
"cloud-task-client.mjs",
|
|
93
100
|
"task-entry-client.mjs",
|
|
94
101
|
"task-identity.mjs",
|
package/release-state.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "entrorove.release-state/1.0",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"digest": "
|
|
3
|
+
"version": "0.1.59",
|
|
4
|
+
"digest": "f5f213108d54b2dae1b49d7a399e4476d2edca251ab76f013b40cf38344617f7",
|
|
5
5
|
"files": {
|
|
6
|
-
"connected-cli.mjs": "
|
|
6
|
+
"connected-cli.mjs": "79c3359303957a9ebb8987e3dfb181f189d380fa02ef4462683b786aedfc7802",
|
|
7
7
|
"local-approved-process.mjs": "442856df788e63f38757439ba0657d1c689aaf797631a955fae3dd639e844e6d",
|
|
8
8
|
"local-approved-supervisor.mjs": "87142a4c13d27c8265cc1fd7fc9088988da9bbb02134164e1a7c5417da926ac7",
|
|
9
9
|
"local-mutation-consumption.mjs": "cd8650b9be894639ceb35af88d23c8002b4acff27afad2e64174eb21a9424cfb",
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
"local-preserved-mutation-state.mjs": "337a172b2d0a03b0d5a89b8483afdd9a043a4999fe38f87679633c98c450126b",
|
|
20
20
|
"local-preserved-mutation-wire.mjs": "2f2f623e768d49af59dbb9bb8245627cb7e72184e5a5d3239d91f395152cf9bf",
|
|
21
21
|
"local-delegated-modify-analysis.mjs": "f87148ddb023b79e241ca97db582991da8d0e1a51b26b1d9bc2799a76efd7297",
|
|
22
|
-
"mutation-client.mjs": "
|
|
22
|
+
"mutation-client.mjs": "6455c35b483d6d22e42e7be708630db07d9fd1d3cdf639294b126b69db2eae2f",
|
|
23
23
|
"cloud-mutation-authority.mjs": "c7226e882a4f3be4d2f55728f515d5a815c0aa1f21aa5e4689314e6e6a8c66a2",
|
|
24
24
|
"ide-code-mode.mjs": "f8ba4de33632ab4c6df52dab52f392414b4632c7e94b218b8065677970c075c7",
|
|
25
|
-
"ide-command.mjs": "
|
|
26
|
-
"ide-hook.mjs": "
|
|
25
|
+
"ide-command.mjs": "2cde4856647b755cf491de0958855abc109bf74009f39aea7ce90e7b94414ff1",
|
|
26
|
+
"ide-hook.mjs": "24c63f14744886016d50686c9460e4e153a5ba9f7807183fdf04f22db5b8d0af",
|
|
27
27
|
"ide-install.mjs": "6322f76d8c0f5c864d8c61f4ee90ae029ccf4bbf1e31fbca7590b78b6c7eaa78",
|
|
28
28
|
"ide-session.mjs": "6df04bca3231954e8b83f6b1b535befff16b6c62c51ca65ae3aa2559180ad7c2",
|
|
29
29
|
"break-glass.mjs": "6ec9f97c5d68bb33121b2374c06b9d4c8eb3419b52d00849fec7124bc63e4582",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"runtime/validator-runtime-v7.0.44.d.mts": "7797edbfe2f7de24da370204dce4dfd22822a1bd80df93514fff900b98afe36f",
|
|
51
51
|
"snapshot-retention.mjs": "ca46aeb36b1a5a24fe7e967031a71b9a3d33f40cb715d344402cbe0dc9c425cb",
|
|
52
52
|
"snapshot-cleanup-journal.mjs": "76a9684cc95b4eb076e1298d78a9dff37948b00d9607b8f546c599072d3ed331",
|
|
53
|
-
"IDE-EXECUTION.md": "
|
|
53
|
+
"IDE-EXECUTION.md": "4d91d679019165ec08ece4f647f0f0c8a5f665654ce6e9c1da8c90badaef3836",
|
|
54
54
|
"REUSE-DISCIPLINE.md": "5b214cf313e7f715f52d54e28093a2d038f0f196ca64b15d1c49587b9d8eecf8",
|
|
55
55
|
"reuse-discipline-release.mjs": "7f55ae10ec3396ed3bc5430a0c64f94c33c2dfbc57b32084caab92fd5fd1586f",
|
|
56
56
|
"local-final-gate16.mjs": "083aec470670e6cdb7ad2ab27b7a51b53a9ec3a8b4121cb9cbfa17d9940892c2",
|
|
@@ -94,17 +94,17 @@
|
|
|
94
94
|
"local-autocoord17-waits.mjs": "2b0a713987822548a94e1ed3a9a891532db45383797dfc5d5bc8d1e35593cf25",
|
|
95
95
|
"local-validator-isolation17.mjs": "48e94604e28b8fddd567dfb35ff60b73fd094a5803bd49f076388b7f310f92aa",
|
|
96
96
|
"local-calctool-composition17.mjs": "ac89fcb41ab0d0d9b47c1b5696945281b0894eb064820775e36a011c76314e6a",
|
|
97
|
-
"cli.mjs": "
|
|
97
|
+
"cli.mjs": "f5f8aae8cc741c13482226659bddf9f530b1b7d06f6921a7cb99a201172d38c9",
|
|
98
98
|
"install-cleanup.mjs": "8a8e1a7dfe37427f86cd4d6e317890d0829894a9949d4216674a716b36edbc54",
|
|
99
99
|
"review-client.mjs": "3491eef68ed533174b31db3c53febb6519b8f088f71a9964644714a9a4c03c77",
|
|
100
100
|
"version.mjs": "1f392147d72c40af4b03b307ea607075fb815af5174fa07e254b95fe12dbf3e7",
|
|
101
|
-
"SKILL.md": "
|
|
102
|
-
"CAPABILITY-MAP.md": "
|
|
101
|
+
"SKILL.md": "a521b28c856304811eb6fd017561c5af578aa926a03e534fb5463a9696ed1741",
|
|
102
|
+
"CAPABILITY-MAP.md": "e420bbc202c27d983635ae33b823b39dd445cf5cdf40ffe88e210a755cb7424d",
|
|
103
103
|
"AGREEMENT.md": "f93a68e92c457200dd024610e65f74e478b8f60860f951da818b70561f1b9bf8",
|
|
104
104
|
"DELIVERY-BOUNDARIES.md": "9b9db50ed1bcce9e77ff6527b6bb7eaf12f3b913162ea9cb7fe90db06b393a4f",
|
|
105
|
-
"package.json": "
|
|
105
|
+
"package.json": "7dcd7e205c8890fc5f711dd77c59ae549150a548c8e26a6c6285e912407ea6ea",
|
|
106
106
|
"manifest.json": "ab06e307795bc9da5add926752b6c4dbdaf18625090261a035aa356a679c504f",
|
|
107
|
-
"release.mjs": "
|
|
107
|
+
"release.mjs": "ab0f359f54e005b3ce5f76d6c4bc1d63dd983ba7467344e71ea2a084255f3f4b",
|
|
108
108
|
"local-cli.mjs": "41e2a737c8587cb5ce469a7661f6d14b5ea4cd073f8286822fc4dc96b67f56a1",
|
|
109
109
|
"local-state-lock.mjs": "1dbf64264642a0851faf043e07ee9f66d2ad0508e9f799b0626246e42fae893e",
|
|
110
110
|
"local-execution.mjs": "185f34e917440a360b3c4bafd3af245243807d964ae3820b3cffaaa2e6348cc6",
|
|
@@ -169,7 +169,7 @@
|
|
|
169
169
|
"local-budget-git-system-attributes.mjs": "367a4e5a88490c4d4c3717bc893a271970fe70d7705f0c1c72d6f76a442d5d21",
|
|
170
170
|
"runtime/git-native-linux-v1.mjs": "cf5cb91426a105e62c0f07a8a230dec592dba46f85a3f708b4c4e8cfcd1126c6",
|
|
171
171
|
"runtime/git-native-darwin-v1.mjs": "f88aab72dc86f94eb1115784872957cb8ed0f74205ae70e1ecd1939e7790e310",
|
|
172
|
-
"local-scope-signature.mjs": "
|
|
172
|
+
"local-scope-signature.mjs": "c4c1551c66bb276bf44c66d8e96f3cacc6edac48f6599ad964cd67f5e18110ac",
|
|
173
173
|
"local-scope-config-imports.mjs": "a399407227bbdd14468633bb6d25586df7c6a30a1ef0d1a80ab7c7b499d3fadf",
|
|
174
174
|
"local-scope-config-extends.mjs": "3ab4150b846b247e9e0851221123f220c5574699af651830e751f026ab33659e",
|
|
175
175
|
"local-scope-snapshot.mjs": "c0f851470b98863a1d187825ed61ee6072d1c057b178fdc85ae29af02a3b250f",
|
|
@@ -179,6 +179,13 @@
|
|
|
179
179
|
"remote-client.mjs": "0d1ec6506fe53a1e33c31c5ba19d14e30e00e85e11eaf7ff8277ea798b9f36c9",
|
|
180
180
|
"cloud-task-client.mjs": "11acb1a033c94cdfbdfb177a3402ad97f6a5226f577138c8cf6395dc3ebb1a39",
|
|
181
181
|
"scope-runner-client.mjs": "2afcbb8bc8a26d03f26787cb2ffd9994c4377757d16d54aa0c83752a29e833ea",
|
|
182
|
+
"scope-amendment-client.mjs": "2161daaee131cc209d7ab75cde91a920cbca98153e7e7fd058ba5aacdf6789a3",
|
|
183
|
+
"scope-amendment-state.mjs": "045375115cdca8c265f1bfd14d5cb052fcd24a97c5cc1e50a44a6f6725319698",
|
|
184
|
+
"scope-amendment-wire.mjs": "bc4c80fda3154b7151006f7bed6cc0884050868e7ba936309b6516ed5c20512f",
|
|
185
|
+
"scope-amendment-remote.mjs": "e73bc3e67b8adb663d679cdcc46f0f1a18529d0bf14dd1f6cdf21ad87ead7c71",
|
|
186
|
+
"scope-amendment-observer.mjs": "6c0539e4ac0f738939a169826400d822d68c1e70ca2d87a9fe10a26945158770",
|
|
187
|
+
"scope-amendment-mutation.mjs": "9b120b60aef4d04790a75d599595c5251649b843996f66ebe02e9ac40ffb99b2",
|
|
188
|
+
"scope-amendment-reference.mjs": "0537e53a1bea4b695f4804c5051261778c88f05b1ae4aff86c709dcfe83aa148",
|
|
182
189
|
"native-client.mjs": "6f5575909bae9daba643287f876ffa0f9ff1c53a367b32a40145d740456d6605",
|
|
183
190
|
"native-notification.mjs": "8859a26c6f13d222711f763acf49ed8546b3306cf71f990643ac31a5ccaf1ba1",
|
|
184
191
|
"local-peer-status.mjs": "a971bcc69d60bb32b875ca5bb4297d948631d5d2821b812c279613ea059a8b4f",
|
|
@@ -203,8 +210,8 @@
|
|
|
203
210
|
"local-task-handoff-read-authority.mjs": "7d2991cfac4562039232566d25441aa98106ce27bc9b7bcf6df4ff28d738e167",
|
|
204
211
|
"local-task-handoff-write-archive.mjs": "3668f39d8ab5664ed717994cf1b4bcc7ea23e146a0e06291f851d30337faeb77",
|
|
205
212
|
"local-task-handoff-write-receipts.mjs": "e1321127bd6fcad7840e185639f81ae68a8722a0ee4264a7b504a02b27887647",
|
|
206
|
-
"workflow-local-runner.mjs": "
|
|
207
|
-
"workflow-local-contracts.mjs": "
|
|
213
|
+
"workflow-local-runner.mjs": "dc26f408178f145cbecda7b65add6956c897b952cfa74a77474471d3b97bead1",
|
|
214
|
+
"workflow-local-contracts.mjs": "2ab69043b498dad007e46fa5b4cbda6bffb6720652b970c3dec7f2825175ef87",
|
|
208
215
|
"workflow-local-client.mjs": "45c954f694d96bf0ea87ee2dc6902a6763dac2cb1df713b6a5e749632054e9af",
|
|
209
216
|
"workflow-local-request.mjs": "4e24c543ce386292a87dc9cd41f90d23375f12079cacffc515c8cd01fd571780",
|
|
210
217
|
"task-message-entry.mjs": "31d10056cb905237cd5649e8eb067009ff1612ec5dc1061e0373209fbc0b646a",
|
|
@@ -239,10 +246,10 @@
|
|
|
239
246
|
"local-mutation-files.mjs": "087b00e8424a135e783eb432271012a71e55eeb4a5e30119490d1bc0b2160320",
|
|
240
247
|
"local-mutation-snapshot.mjs": "f9077539680fb8d3ff33a962657756a8fe6afa8409b6191334509534216ebfe8",
|
|
241
248
|
"local-mutation-audit.mjs": "5a30ee3446f632e032ffcfe46bcd97d6dfc0f099a76ea5185c3f9a9118c4f6cb",
|
|
242
|
-
"local-mutation-wire.mjs": "
|
|
249
|
+
"local-mutation-wire.mjs": "fe251b3112d2394c5a0ab108f067ec17fb8569db7be9b3ceb86fab25c68e98e9",
|
|
243
250
|
"local-mutation-binding.mjs": "4440d792453a07a375b635fa5a91db607e58162824baced5c79a9a513f4ec918",
|
|
244
251
|
"local-mutation-pass-store.mjs": "1edc5fcc537dfa04b8ae8b07af554d61ea5911a5da281423ad57214ccf753ad4",
|
|
245
|
-
"local-mutation-authority.mjs": "
|
|
252
|
+
"local-mutation-authority.mjs": "e6b79430ee6b5eb28173843a5faaf1c277da1e5ca7c1dcbd6d96cd60c4460c59",
|
|
246
253
|
"local-mutation-batch.mjs": "de27686b5f99a99b02f0218a3fa159e9469d6b19c595117287d6dd37a6efcb3b",
|
|
247
254
|
"local-mutation-batch-receipt.mjs": "b0e6a8522867255d899e60b66677885933f6a1c4c771cbb52f84dbbbdcf5e5c2",
|
|
248
255
|
"calctool-runtime-entry.mjs": "98d2bc8df38d16131fc56db0a3d4d260531ba4df412baa5ddc4b8b5c5ca99fe9",
|
package/release.mjs
CHANGED
|
@@ -118,6 +118,9 @@ const sourceFiles = [
|
|
|
118
118
|
'local-scope-workspaces.mjs',
|
|
119
119
|
'local-task-routing.mjs', 'remote-client.mjs', 'cloud-task-client.mjs',
|
|
120
120
|
'scope-runner-client.mjs',
|
|
121
|
+
'scope-amendment-client.mjs', 'scope-amendment-state.mjs', 'scope-amendment-wire.mjs',
|
|
122
|
+
'scope-amendment-remote.mjs', 'scope-amendment-observer.mjs', 'scope-amendment-mutation.mjs',
|
|
123
|
+
'scope-amendment-reference.mjs',
|
|
121
124
|
'native-client.mjs', 'native-notification.mjs', 'local-peer-status.mjs',
|
|
122
125
|
'cloud-watch-client.mjs', 'cloud-event-stream.mjs', 'ide-presentation.mjs', 'automatic-feedback-client.mjs',
|
|
123
126
|
'task-entry-client.mjs', 'task-identity.mjs', 'task-identity.d.mts',
|
|
@@ -292,6 +295,10 @@ const taskEntryReleaseLabels = {
|
|
|
292
295
|
}
|
|
293
296
|
|
|
294
297
|
function releaseNote(changed) {
|
|
298
|
+
if (changed.some(file => file.startsWith('scope-amendment-'))) return {
|
|
299
|
+
zh: '新增同一任务的精确范围补正:原账号审核文件哈希与写入/只读角色,原S签名真实事实并重评原合同;计划、写入和结算绑定不可变范围修订,改后刷新保留原M消费证据与失败历史。',
|
|
300
|
+
en: 'Add same-task exact scope amendment with original owner review of file hashes and write/read-only roles, original S observations and native contract reassessment; bind plans, writes and settlement to immutable scope revisions and retain actual M consumption and failure history during post-write refresh.',
|
|
301
|
+
}
|
|
295
302
|
const suppliedZh = process.env.ENTROROVE_RELEASE_NOTE_ZH?.trim()
|
|
296
303
|
const suppliedEn = process.env.ENTROROVE_RELEASE_NOTE_EN?.trim()
|
|
297
304
|
if (Boolean(suppliedZh) !== Boolean(suppliedEn)) {
|
package/releases.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.1.59",
|
|
4
|
+
"date": "2026-10-17",
|
|
5
|
+
"zh": "新增同一任务的精确范围补正:原账号审核文件哈希与写入/只读角色,原S签名真实事实并重评原合同;计划、写入和结算绑定不可变范围修订,改后刷新保留原M消费证据与失败历史。",
|
|
6
|
+
"en": "Add same-task exact scope amendment with original owner review of file hashes and write/read-only roles, original S observations and native contract reassessment; bind plans, writes and settlement to immutable scope revisions and retain actual M consumption and failure history during post-write refresh."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.1.58",
|
|
10
|
+
"date": "2026-10-17",
|
|
11
|
+
"zh": "修复截图等多模态 PostToolUse 事件超过旧输入上限时被误拦:完整读取并严格校验最多 32 MiB 的 UTF-8 JSON 对象,超限或无效事件返回固定安全诊断;保留原授权、待回执调用及 O 证据,私有状态文件仍限 1,000,000 字节。",
|
|
12
|
+
"en": "Fixed multimodal PostToolUse events such as screenshots being rejected by the old input limit. Read and strictly validate the complete UTF-8 JSON object up to 32 MiB, returning fixed safe diagnostics for oversized or invalid events while preserving original authority, pending calls and O evidence. Private state files retain their 1,000,000-byte limit."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.1.57",
|
|
4
16
|
"date": "2026-10-17",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
|
+
import { withStateLock } from './local-state-lock.mjs';
|
|
5
|
+
import { atomicMutationJson14, readMutationJson14 } from './local-mutation-files.mjs';
|
|
6
|
+
import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
|
|
7
|
+
import { amendmentInput, amendmentFail, scopeAmendmentChallenge, scopeRevisionAuthority } from './scope-amendment-wire.mjs';
|
|
8
|
+
import { scopeAmendmentLocation, readScopeAmendmentState, saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
9
|
+
import { amendmentRemote, scopeAmendmentStatus } from './scope-amendment-remote.mjs';
|
|
10
|
+
import { observeScopeAmendment } from './scope-amendment-observer.mjs';
|
|
11
|
+
import { retainScopeAmendmentReference } from './scope-amendment-reference.mjs';
|
|
12
|
+
|
|
13
|
+
const schemaVersion = 'entrorove.scope-amendment-connection/1.0';
|
|
14
|
+
function validateProposal(status, input) {
|
|
15
|
+
const proposal = status.proposal;
|
|
16
|
+
if (!proposal || proposal.proposalId !== input.proposalId || proposal.proposalDigest !== input.proposalDigest
|
|
17
|
+
|| !['pending', 'approved', 'declined'].includes(proposal.approval)) amendmentFail('Current account has no matching original scope proposal');
|
|
18
|
+
return proposal;
|
|
19
|
+
}
|
|
20
|
+
function validateApplied(output, state, location) {
|
|
21
|
+
const effective = output.effectiveAuthority, record = scopeRevisionAuthority(effective?.record, 'entrorove.scope-revision-authority/1.0');
|
|
22
|
+
const challenge = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: true }), refresh = challenge.purpose === 'scope-revision-refresh';
|
|
23
|
+
if (output.status !== 'succeeded' || output.grantsExecutionAuthority !== false || output.claimsCompletion !== false
|
|
24
|
+
|| output.scopeRevisionDigest !== sha256(canonical(record)) || effective.revisionDigest !== output.scopeRevisionDigest
|
|
25
|
+
|| output.revision !== effective.revision || record.revision !== effective.revision
|
|
26
|
+
|| ['flowId', 'proposalId', 'proposalDigest', 'ownerId', 'taskDigest', 'chainDigest', 'originalBindingDigest']
|
|
27
|
+
.some(field => record[field] !== challenge[field])
|
|
28
|
+
|| record.scope?.keyId !== location.binding.keyId || record.scope?.runnerId !== location.binding.runnerId
|
|
29
|
+
|| record.scope?.repositoryId !== location.binding.repositoryId
|
|
30
|
+
|| canonical(record.files.map(file => ({ ...file, sha256: null }))) !== canonical(challenge.files.map(file => ({ ...file, sha256: null })))) {
|
|
31
|
+
amendmentFail('Applied scope amendment lost its signed original authority or exact roles');
|
|
32
|
+
}
|
|
33
|
+
const fact = refresh ? scopeRevisionAuthority(effective.fact, 'entrorove.scope-revision-facts/1.0') : record;
|
|
34
|
+
if (refresh && (fact.scopeRevisionDigest !== output.scopeRevisionDigest || fact.factRevision !== challenge.expectedFactRevision
|
|
35
|
+
|| ['flowId', 'ownerId', 'taskDigest', 'chainDigest'].some(field => fact[field] !== challenge[field]))
|
|
36
|
+
|| canonical(fact.candidateAttestation) !== canonical(state.attestation)
|
|
37
|
+
|| fact.scope?.attestationDigest !== sha256(canonical(state.attestation.scopeAttestation))
|
|
38
|
+
|| effective.factRevision !== (refresh ? fact.factRevision : 0) || output.factRevision !== effective.factRevision) {
|
|
39
|
+
amendmentFail('Applied scope proof differs from this exact actual signed candidate');
|
|
40
|
+
}
|
|
41
|
+
return { schemaVersion, status: 'succeeded', flowId: challenge.flowId, proposalId: challenge.proposalId,
|
|
42
|
+
proposalDigest: challenge.proposalDigest, scopeRevisionDigest: output.scopeRevisionDigest, revision: output.revision,
|
|
43
|
+
factRevision: effective.factRevision, grantsExecutionAuthority: false, claimsCompletion: false,
|
|
44
|
+
...(refresh && state.attestation.memberMutation ? { localAction: {
|
|
45
|
+
command: 'npx @entrorove/skill mutation-settle <absolute repositoryRoot>', input: {
|
|
46
|
+
flowId: challenge.flowId, taskId: state.attestation.memberMutation.consumptionReceipt.taskId,
|
|
47
|
+
stepId: state.attestation.memberMutation.consumptionReceipt.stepId, gatePassPath: location.identity.gatePassPath } } } : {}),
|
|
48
|
+
nextStep: output.nextStep, ...(state.feedback ? { feedback: state.feedback } : {}) };
|
|
49
|
+
}
|
|
50
|
+
async function archiveAttempt(location, state) {
|
|
51
|
+
if (!state.attemptId) return;
|
|
52
|
+
const archive = path.join(location.directory, `${state.attemptId}.json`);
|
|
53
|
+
try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) amendmentFail('Original scope amendment attempt history changed'); }
|
|
54
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
|
|
55
|
+
for (const key of Object.keys(state)) if (!['schemaVersion', 'identity'].includes(key)) delete state[key];
|
|
56
|
+
await saveScopeAmendmentState(location, state);
|
|
57
|
+
}
|
|
58
|
+
async function finish(location, state, dependencies) {
|
|
59
|
+
if (!state.attestation) {
|
|
60
|
+
state.attestation = await observeScopeAmendment(location, state);
|
|
61
|
+
state.attestationDigest = sha256(canonical(state.attestation)); await saveScopeAmendmentState(location, state);
|
|
62
|
+
}
|
|
63
|
+
const applied = state.applied ?? await amendmentRemote('applied', 'workflow-scope-apply', {
|
|
64
|
+
proposalId: location.identity.proposalId, proposalDigest: location.identity.proposalDigest,
|
|
65
|
+
attestation: state.attestation }, state, location, dependencies);
|
|
66
|
+
const result = validateApplied(applied, state, location);
|
|
67
|
+
await retainScopeAmendmentReference(location, applied); return result;
|
|
68
|
+
}
|
|
69
|
+
async function amend(location, dependencies) {
|
|
70
|
+
const state = await readScopeAmendmentState(location);
|
|
71
|
+
if (state.pending) {
|
|
72
|
+
const pending = state.pending;
|
|
73
|
+
await amendmentRemote(pending.stage, pending.input.operation, pending.input.payload, state, location, dependencies);
|
|
74
|
+
if (pending.stage === 'applied') {
|
|
75
|
+
const result = validateApplied(state.applied, state, location);
|
|
76
|
+
await retainScopeAmendmentReference(location, state.applied); return result;
|
|
77
|
+
}
|
|
78
|
+
return finish(location, state, dependencies);
|
|
79
|
+
}
|
|
80
|
+
const status = await scopeAmendmentStatus(location.identity.flowId, dependencies), proposal = validateProposal(status, location.identity);
|
|
81
|
+
if (proposal.approval !== 'approved') return { schemaVersion, status: proposal.approval === 'pending' ? 'needs-input' : 'blocked',
|
|
82
|
+
flowId: location.identity.flowId, proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest,
|
|
83
|
+
grantsExecutionAuthority: false, claimsCompletion: false, nextStep: { operation: 'interaction-answer',
|
|
84
|
+
requiresOriginalOwnerApproval: true, scopeExpansionAutomaticallyAuthorized: false } };
|
|
85
|
+
if (state.challenged && !state.applied && !state.rejection) {
|
|
86
|
+
const challenge = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: true });
|
|
87
|
+
if (Date.parse(challenge.expiresAt) > Date.now()) return finish(location, state, dependencies);
|
|
88
|
+
state.rejection = { code: 'ENTROROVE_SCOPE_AMENDMENT_CHALLENGE_EXPIRED', effects: 'none',
|
|
89
|
+
reason: 'signed-unused-challenge-expired-before-next-post' }; await saveScopeAmendmentState(location, state);
|
|
90
|
+
}
|
|
91
|
+
await archiveAttempt(location, state);
|
|
92
|
+
state.attemptId = randomUUID(); await saveScopeAmendmentState(location, state);
|
|
93
|
+
const challenged = await amendmentRemote('challenged', 'workflow-scope-challenge', { proposalId: proposal.proposalId,
|
|
94
|
+
proposalDigest: proposal.proposalDigest, receiverId: location.binding.runnerId, attemptId: state.attemptId }, state, location, dependencies);
|
|
95
|
+
const challenge = scopeAmendmentChallenge(challenged.challenge, location);
|
|
96
|
+
if (status.effective ? challenge.purpose !== 'scope-revision-refresh'
|
|
97
|
+
|| challenge.authorityRevisionDigest !== status.effective.revisionDigest || challenge.expectedFactRevision !== status.effective.factRevision + 1
|
|
98
|
+
|| challenge.currentScopeArtifactDigest !== sha256(canonical(status.effective.scope))
|
|
99
|
+
: challenge.purpose !== 'scope-revision' || challenge.expectedRevision !== proposal.expectedRevision
|
|
100
|
+
|| challenge.previousDigest !== proposal.previousDigest) amendmentFail('Scope amendment challenge has a different original authority head');
|
|
101
|
+
return finish(location, state, dependencies);
|
|
102
|
+
}
|
|
103
|
+
export async function runScopeAmend(repositoryRoot, input, dependencies) {
|
|
104
|
+
try {
|
|
105
|
+
const identity = amendmentInput(input);
|
|
106
|
+
await dependencies.install();
|
|
107
|
+
if (!await dependencies.agreementAccepted(dependencies.home)) return dependencies.agreementRequired(dependencies.home);
|
|
108
|
+
await bootstrapCloudMutationAuthority(dependencies);
|
|
109
|
+
const location = await scopeAmendmentLocation(repositoryRoot, identity, dependencies);
|
|
110
|
+
return await withStateLock(location.file, () => amend(location, dependencies));
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const requestId = /^[a-f0-9]{64}$/.test(error?.requestId ?? '') ? error.requestId : undefined;
|
|
113
|
+
return { schemaVersion, status: 'blocked', flowId: input?.flowId,
|
|
114
|
+
code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_AMENDMENT_PREREQUISITE',
|
|
115
|
+
message: ['ENTROROVE_SCOPE_AMENDMENT_BINDING', 'ENTROROVE_SCOPE_AMENDMENT_STATE', 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN',
|
|
116
|
+
'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT'].includes(error?.code) ? error.message
|
|
117
|
+
: 'Scope amendment stopped; preserve the original proposal, signer, journal and actual evidence.',
|
|
118
|
+
...(requestId ? { requestId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
119
|
+
...(error?.feedback ? { feedback: error.feedback } : {}),
|
|
120
|
+
...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-amend', requestId,
|
|
121
|
+
method: 'GET', reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mutationPass14 } from './local-mutation-wire.mjs';
|
|
2
|
+
import { readMutationJson14 } from './local-mutation-files.mjs';
|
|
3
|
+
import { attestMutationConsumption14 } from './local-mutation-consumption.mjs';
|
|
4
|
+
import { amendmentFail } from './scope-amendment-wire.mjs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { sha256 } from './local-integrity.mjs';
|
|
7
|
+
|
|
8
|
+
export async function scopeAmendmentMutation(location, state, challenge, facts) {
|
|
9
|
+
const changed = challenge.files.filter(file => file.access === 'write'
|
|
10
|
+
&& facts.files.find(actual => actual.path === file.path)?.sha256 !== challenge.currentFiles.find(current => current.path === file.path)?.sha256);
|
|
11
|
+
if (!changed.length || challenge.purpose !== 'scope-revision-refresh') return undefined;
|
|
12
|
+
const gatePassPath = location.identity.gatePassPath;
|
|
13
|
+
if (!gatePassPath) amendmentFail('Changed write bytes require the exact original stored mutation pass for actual M consumption proof');
|
|
14
|
+
const gates = path.join(location.stateRoot, 'gates', sha256(location.root).slice(0, 32), challenge.flowId);
|
|
15
|
+
if (path.dirname(gatePassPath) !== gates || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\.json$/.test(path.basename(gatePassPath))) {
|
|
16
|
+
amendmentFail('Scope refresh may read only an original stored pass in this repository and flow');
|
|
17
|
+
}
|
|
18
|
+
const pass = mutationPass14(await readMutationJson14(gatePassPath), { live: false });
|
|
19
|
+
if (['flowId', 'ownerId', 'taskDigest', 'chainDigest'].some(field => pass[field] !== challenge[field])
|
|
20
|
+
|| pass.memberScopeKeyId !== challenge.keyId || pass.memberScopeBindingDigest !== challenge.originalBindingDigest
|
|
21
|
+
|| pass.scopeRevisionDigest !== challenge.authorityRevisionDigest
|
|
22
|
+
|| changed.some(file => !pass.paths.includes(file.path))) amendmentFail('Scope refresh cannot replace its actual original mutation pass or approved write paths');
|
|
23
|
+
const observed = await attestMutationConsumption14({ repositoryRoot: location.root, home: location.home,
|
|
24
|
+
flowId: pass.flowId, taskId: pass.taskId, stepId: pass.stepId, gatePassPath }, { allowExpiredReplay: true });
|
|
25
|
+
return { passId: pass.passId, consumptionReceipt: observed.consumptionReceipt };
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { sign, randomUUID } from 'node:crypto';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { scopeTarget } from './local-scope-files.mjs';
|
|
4
|
+
import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
|
|
5
|
+
import { readScopeSnapshot, verifyFreshScopeSnapshot } from './local-scope-snapshot.mjs';
|
|
6
|
+
import { collectScopeFacts } from './local-scope-repository.mjs';
|
|
7
|
+
import { attestScopeFacts } from './local-scope-signature.mjs';
|
|
8
|
+
import { scopeAmendmentChallenge, amendmentFail } from './scope-amendment-wire.mjs';
|
|
9
|
+
import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
10
|
+
import { scopeAmendmentMutation } from './scope-amendment-mutation.mjs';
|
|
11
|
+
|
|
12
|
+
async function inspectFiles(location, challenge) {
|
|
13
|
+
const physical = new Set();
|
|
14
|
+
for (const file of challenge.files) {
|
|
15
|
+
const target = await scopeTarget(location.root, file.path);
|
|
16
|
+
if (!target.stat.isFile()) amendmentFail('Scope amendment permits only exact existing ordinary files');
|
|
17
|
+
const identity = `${target.stat.dev}:${target.stat.ino}`;
|
|
18
|
+
if (physical.has(identity)) amendmentFail('Scope amendment roles cannot alias the same physical file');
|
|
19
|
+
physical.add(identity);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function observeScopeAmendment(location, state) {
|
|
23
|
+
const challenge = scopeAmendmentChallenge(state.challenged.challenge, location);
|
|
24
|
+
await inspectFiles(location, challenge);
|
|
25
|
+
if (!state.snapshotId) { state.snapshotId = `scope-amend-${randomUUID()}`; await saveScopeAmendmentState(location, state); }
|
|
26
|
+
const input = { repositoryRoot: location.root, home: location.home, flowId: challenge.flowId, stepId: challenge.stepId,
|
|
27
|
+
snapshotId: state.snapshotId, paths: challenge.files.map(file => file.path).sort(),
|
|
28
|
+
contractDigest: challenge.proposalDigest, importContexts: location.binding.importContexts };
|
|
29
|
+
if (!state.snapshot) {
|
|
30
|
+
if (!state.snapshotStarted) {
|
|
31
|
+
state.snapshotStarted = true; await saveScopeAmendmentState(location, state);
|
|
32
|
+
try { await snapshotPlan(input); }
|
|
33
|
+
catch (error) { delete state.snapshotStarted; await saveScopeAmendmentState(location, state); throw error; }
|
|
34
|
+
}
|
|
35
|
+
const verified = await snapshotVerify(input);
|
|
36
|
+
if (verified.verified !== true || verified.receipt.contractDigest !== input.contractDigest) amendmentFail('Actual amendment snapshot did not verify');
|
|
37
|
+
state.snapshot = verified.receipt; await saveScopeAmendmentState(location, state);
|
|
38
|
+
}
|
|
39
|
+
const verified = await snapshotVerify(input), snapshot = await readScopeSnapshot(input, location.root);
|
|
40
|
+
if (verified.verified !== true || verified.receipt.snapshotDigest !== state.snapshot.snapshotDigest
|
|
41
|
+
|| snapshot.manifest.contractDigest !== input.contractDigest) amendmentFail('Amendment cannot replace its original candidate snapshot');
|
|
42
|
+
const facts = await collectScopeFacts(location.root, input.paths, undefined, snapshot.known, input);
|
|
43
|
+
facts.snapshotEvidence = await verifyFreshScopeSnapshot(input, location.root, facts);
|
|
44
|
+
for (const file of challenge.files) {
|
|
45
|
+
const actual = facts.files.find(item => item.path === file.path);
|
|
46
|
+
if (!actual?.exists || (challenge.purpose === 'scope-revision' || file.access === 'read-only') && actual.sha256 !== file.sha256) {
|
|
47
|
+
amendmentFail('Scope amendment bytes differ from the exact original approved roles');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const observed = await attestScopeFacts({ ...input, ownerId: challenge.ownerId, taskDigest: challenge.taskDigest,
|
|
51
|
+
chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge,
|
|
52
|
+
signerConfigPath: location.signerConfigPath }, { repositoryRoot: location.root, facts,
|
|
53
|
+
snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt) });
|
|
54
|
+
const memberMutation = await scopeAmendmentMutation(location, state, challenge, facts);
|
|
55
|
+
const core = { schemaVersion: 'entrorove.scope-revision-attestation/1.0', purpose: challenge.purpose,
|
|
56
|
+
proposalId: challenge.proposalId, proposalDigest: challenge.proposalDigest, expectedRevision: challenge.expectedRevision,
|
|
57
|
+
previousDigest: challenge.previousDigest, originalBindingDigest: challenge.originalBindingDigest,
|
|
58
|
+
...(challenge.purpose === 'scope-revision-refresh' ? { authorityRevisionDigest: challenge.authorityRevisionDigest,
|
|
59
|
+
expectedFactRevision: challenge.expectedFactRevision } : {}), scopeAttestation: observed.attestation,
|
|
60
|
+
...(memberMutation ? { memberMutation } : {}) };
|
|
61
|
+
if (Date.parse(observed.attestation.expiresAt) <= Date.now()) amendmentFail('Scope amendment expired before signing');
|
|
62
|
+
return { ...core, signature: sign(null, Buffer.from(canonical(core)), location.key).toString('base64url') };
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { mutationHome14, privateMutationDirectory14, readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
|
|
4
|
+
import { scopeRevisionAuthority, amendmentFail } from './scope-amendment-wire.mjs';
|
|
5
|
+
|
|
6
|
+
async function referenceLocation(root, home, flowId, revisionDigest) {
|
|
7
|
+
const stateRoot = path.join(await mutationHome14(home), '.entrorove');
|
|
8
|
+
return { stateRoot, directory: path.join(stateRoot, 'scope-revisions', sha256(root).slice(0, 32), flowId),
|
|
9
|
+
file: path.join(stateRoot, 'scope-revisions', sha256(root).slice(0, 32), flowId, `${revisionDigest}.json`) };
|
|
10
|
+
}
|
|
11
|
+
export async function retainScopeAmendmentReference(location, output) {
|
|
12
|
+
const effective = output.effectiveAuthority, target = await referenceLocation(location.root, location.home,
|
|
13
|
+
location.identity.flowId, output.scopeRevisionDigest);
|
|
14
|
+
const value = { schemaVersion: 'entrorove.scope-amendment-reference/1.0', input: {
|
|
15
|
+
flowId: location.identity.flowId, proposalId: location.identity.proposalId, proposalDigest: location.identity.proposalDigest }, record: effective.record };
|
|
16
|
+
await privateMutationDirectory14(target.directory, { privateFrom: target.stateRoot });
|
|
17
|
+
try { if (canonical(await readMutationJson14(target.file)) !== canonical(value)) amendmentFail('Scope amendment original authority reference changed'); }
|
|
18
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(target.file, value, { privateFrom: target.stateRoot }); }
|
|
19
|
+
}
|
|
20
|
+
export async function scopeAmendmentRefreshAction(input, pass) {
|
|
21
|
+
if (!pass.scopeRevisionDigest) return undefined;
|
|
22
|
+
try {
|
|
23
|
+
const target = await referenceLocation(input.repositoryRoot, input.home, pass.flowId, pass.scopeRevisionDigest);
|
|
24
|
+
const reference = await readMutationJson14(target.file);
|
|
25
|
+
const record = scopeRevisionAuthority(reference.record, 'entrorove.scope-revision-authority/1.0');
|
|
26
|
+
if (reference.schemaVersion !== 'entrorove.scope-amendment-reference/1.0' || sha256(canonical(record)) !== pass.scopeRevisionDigest
|
|
27
|
+
|| record.flowId !== pass.flowId || record.taskDigest !== pass.taskDigest || record.scope.keyId !== pass.memberScopeKeyId
|
|
28
|
+
|| ['flowId', 'proposalId', 'proposalDigest'].some(field => reference.input[field] !== record[field])) amendmentFail('Stored refresh reference has another original authority');
|
|
29
|
+
return { command: 'npx @entrorove/skill scope-amend <absolute repositoryRoot>',
|
|
30
|
+
input: { ...reference.input, gatePassPath: input.gatePassPath }, resumeOriginalFlow: true, requiredBefore: 'mutation-settle' };
|
|
31
|
+
} catch {
|
|
32
|
+
return { operation: 'continue', input: { flowId: pass.flowId, operation: 'workflow-scope-status', payload: {} },
|
|
33
|
+
purpose: 'recover-original-scope-amendment-reference', requiredBefore: 'mutation-settle', grantsExecutionAuthority: false };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { canonical } from './local-integrity.mjs';
|
|
2
|
+
import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
|
|
3
|
+
import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
|
|
4
|
+
import { ideClientHeaders } from './ide-presentation.mjs';
|
|
5
|
+
import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFeedback } from './automatic-feedback-client.mjs';
|
|
6
|
+
import { readInvocations } from './local-invocations.mjs';
|
|
7
|
+
import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
8
|
+
import { amendmentFail } from './scope-amendment-wire.mjs';
|
|
9
|
+
|
|
10
|
+
const unknown = requestId => Object.assign(new Error('Scope amendment outcome is unknown; recover the original request with GET only'),
|
|
11
|
+
{ code: 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN', outcomeUnknown: true, requestId });
|
|
12
|
+
async function recover(pending, dependencies) {
|
|
13
|
+
try {
|
|
14
|
+
const origin = dependencies.apiOrigin(), token = await dependencies.readCredential(dependencies.home);
|
|
15
|
+
const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
|
|
16
|
+
headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
|
|
17
|
+
const result = parseTransportResponse(response, 'original scope amendment status');
|
|
18
|
+
if (result?.output?.requestId !== pending.requestId || response.status >= 500
|
|
19
|
+
|| ['running', 'pending'].includes(result.output.status)) throw unknown(pending.requestId);
|
|
20
|
+
result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
|
|
21
|
+
verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
|
|
22
|
+
result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
|
|
23
|
+
return { response, result };
|
|
24
|
+
} catch { throw unknown(pending.requestId); }
|
|
25
|
+
}
|
|
26
|
+
async function notSent(pending, home) {
|
|
27
|
+
try {
|
|
28
|
+
const rows = (await readInvocations(home)).filter(row => row.kind === 'remote' && row.requestId === pending.requestId);
|
|
29
|
+
return !rows.length || rows.at(-1).event === 'not-sent';
|
|
30
|
+
} catch (error) { return error.code === 'ENOENT'; }
|
|
31
|
+
}
|
|
32
|
+
export async function amendmentRemote(stage, operation, payload, state, location, dependencies) {
|
|
33
|
+
const input = { flowId: location.identity.flowId, operation, payload }, requestId = buildInvocation('continue', input).requestId;
|
|
34
|
+
let response, result;
|
|
35
|
+
if (state.pending) {
|
|
36
|
+
if (state.pending.stage !== stage || state.pending.requestId !== requestId || canonical(state.pending.input) !== canonical(input)) {
|
|
37
|
+
amendmentFail('Recover the original amendment request before another operation');
|
|
38
|
+
}
|
|
39
|
+
({ response, result } = await recover(state.pending, dependencies));
|
|
40
|
+
} else {
|
|
41
|
+
state.pending = { stage, operation: 'continue', input, requestId };
|
|
42
|
+
await saveScopeAmendmentState(location, state);
|
|
43
|
+
try { ({ response, result } = await invokeRemoteResult({ operation: 'continue', input }, dependencies)); }
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (await notSent(state.pending, dependencies.home)) {
|
|
46
|
+
delete state.pending; await saveScopeAmendmentState(location, state);
|
|
47
|
+
throw Object.assign(new Error('Scope amendment was not transmitted; repair its prerequisite and reuse the original input'),
|
|
48
|
+
{ code: 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', knownBeforeTransmission: true, requestId });
|
|
49
|
+
}
|
|
50
|
+
throw unknown(requestId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
|
|
54
|
+
const output = result.output;
|
|
55
|
+
if (result.feedback) state.feedback = result.feedback;
|
|
56
|
+
if (!response.ok || !['succeeded', 'needs-input'].includes(output.status)) {
|
|
57
|
+
state.rejection = { requestId, output, ...(result.feedback ? { feedback: result.feedback } : {}) };
|
|
58
|
+
delete state.pending; await saveScopeAmendmentState(location, state);
|
|
59
|
+
throw Object.assign(new Error('The original scope amendment was rejected; preserve its exact proposal and evidence'),
|
|
60
|
+
{ code: output.code ?? 'ENTROROVE_SCOPE_AMENDMENT_REJECTED', requestId, output,
|
|
61
|
+
...(result.feedback ? { feedback: result.feedback } : {}) });
|
|
62
|
+
}
|
|
63
|
+
const value = output.result ?? output;
|
|
64
|
+
state[stage] = value; delete state.pending;
|
|
65
|
+
await saveScopeAmendmentState(location, state); return value;
|
|
66
|
+
}
|
|
67
|
+
export async function scopeAmendmentStatus(flowId, dependencies) {
|
|
68
|
+
try {
|
|
69
|
+
const { response, result } = await invokeRemoteResult({ operation: 'continue',
|
|
70
|
+
input: { flowId, operation: 'workflow-scope-status', payload: {} } }, dependencies);
|
|
71
|
+
if (!response.ok || result?.output?.status !== 'succeeded') throw new Error();
|
|
72
|
+
return result.output.result ?? result.output;
|
|
73
|
+
} catch { amendmentFail('Current account cannot read its original live scope revision; no amendment mutation was sent'); }
|
|
74
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
5
|
+
import { scopeRoot, scopePath, scopeFail } from './local-scope-files.mjs';
|
|
6
|
+
import { retainedScope } from './local-scope-state.mjs';
|
|
7
|
+
import { mutationHome14, mutationFile14, privateMutationDirectory14,
|
|
8
|
+
readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
|
|
9
|
+
import { trustedOrigin } from './remote-transport.mjs';
|
|
10
|
+
|
|
11
|
+
const fail = message => scopeFail('ENTROROVE_SCOPE_AMENDMENT_STATE', message);
|
|
12
|
+
const maximumStateBytes = 1_000_000;
|
|
13
|
+
async function privateItem(file, final = 'file') {
|
|
14
|
+
const value = await mutationFile14(file, { final });
|
|
15
|
+
if (process.getuid && value.stat.uid !== process.getuid()
|
|
16
|
+
|| process.platform !== 'win32' && value.stat.mode & 0o077
|
|
17
|
+
|| final === 'file' && (value.stat.nlink !== 1 || value.stat.size > maximumStateBytes)) {
|
|
18
|
+
fail('Scope amendment controls must be bounded private files owned by the current OS account');
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
async function privateText(file) {
|
|
23
|
+
const before = await privateItem(file), handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
24
|
+
try {
|
|
25
|
+
const opened = await handle.stat(), text = await handle.readFile('utf8'), after = await handle.stat();
|
|
26
|
+
const latest = await privateItem(file);
|
|
27
|
+
if ([opened, after, latest.stat].some(stat => ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs', 'mode']
|
|
28
|
+
.some(field => stat[field] !== before.stat[field]))) fail('Original scope signer changed while reading');
|
|
29
|
+
return text;
|
|
30
|
+
} finally { await handle.close(); }
|
|
31
|
+
}
|
|
32
|
+
const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value)
|
|
33
|
+
&& Object.keys(value).sort().join('\0') === [...fields].sort().join('\0');
|
|
34
|
+
export async function scopeAmendmentLocation(repositoryRoot, input, dependencies) {
|
|
35
|
+
const root = await scopeRoot(repositoryRoot), home = await mutationHome14(dependencies.home);
|
|
36
|
+
const relative = path.relative(root, home);
|
|
37
|
+
if (!relative || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
|
|
38
|
+
fail('Scope amendment controls must be outside the observed repository');
|
|
39
|
+
}
|
|
40
|
+
const apiOrigin = trustedOrigin(dependencies.apiOrigin()), stateRoot = path.join(home, '.entrorove');
|
|
41
|
+
const base = path.join(stateRoot, 'scope-runners', sha256(`${apiOrigin}:${input.flowId}:${root}`));
|
|
42
|
+
const controls = path.join(base, 'identity');
|
|
43
|
+
for (const directory of [stateRoot, path.dirname(base), base, controls]) await privateItem(directory, 'directory');
|
|
44
|
+
const binding = JSON.parse(await privateText(path.join(controls, 'binding.json')));
|
|
45
|
+
if (!exact(binding, ['schemaVersion', 'flowId', 'repositoryRoot', 'apiOrigin', 'targetPaths', 'importContexts',
|
|
46
|
+
'runnerId', 'repositoryId', 'publicKey', 'keyId']) || binding.schemaVersion !== 'entrorove.local-scope-binding/1.0'
|
|
47
|
+
|| binding.flowId !== input.flowId || binding.repositoryRoot !== root || binding.apiOrigin !== apiOrigin
|
|
48
|
+
|| !Array.isArray(binding.targetPaths) || !binding.targetPaths.length
|
|
49
|
+
|| canonical(binding.targetPaths) !== canonical([...new Set(binding.targetPaths.map(scopePath))].sort())
|
|
50
|
+
|| !Array.isArray(binding.importContexts)) fail('The original scope signer, entry paths or import contexts are unavailable');
|
|
51
|
+
for (const context of binding.importContexts) {
|
|
52
|
+
if (!exact(context, ['sourcePrefix', 'configPath'])) fail('Original import context is invalid');
|
|
53
|
+
scopePath(context.sourcePrefix); scopePath(context.configPath);
|
|
54
|
+
}
|
|
55
|
+
const signerConfigPath = path.join(controls, 'signer.json'), config = JSON.parse(await privateText(signerConfigPath));
|
|
56
|
+
if (!exact(config, ['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs'])
|
|
57
|
+
|| config.schemaVersion !== 'entrorove.scope-signer/1.0' || config.privateKeyPath !== path.join(controls, 'private.pem')
|
|
58
|
+
|| config.keyId !== binding.keyId || !Number.isSafeInteger(config.receiptTtlMs)
|
|
59
|
+
|| config.receiptTtlMs < 1 || config.receiptTtlMs > 300_000) fail('Original scope signer configuration changed');
|
|
60
|
+
let key;
|
|
61
|
+
try { key = createPrivateKey(await privateText(config.privateKeyPath)); }
|
|
62
|
+
catch (error) { if (error.code?.startsWith('ENTROROVE_')) throw error; fail('Original scope signing key is unavailable'); }
|
|
63
|
+
const der = createPublicKey(key).export({ type: 'spki', format: 'der' });
|
|
64
|
+
if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== binding.publicKey
|
|
65
|
+
|| sha256(der) !== binding.keyId) fail('Original scope signing identity changed');
|
|
66
|
+
const originalStatePath = path.join(stateRoot, 'scope', sha256(root).slice(0, 32), input.flowId, 'state.json');
|
|
67
|
+
await privateItem(originalStatePath);
|
|
68
|
+
const saved = await readMutationJson14(originalStatePath);
|
|
69
|
+
const original = await retainedScope({ repositoryRoot: root, home, flowId: input.flowId,
|
|
70
|
+
stepId: saved.stepId, snapshotId: saved.originalSnapshot?.snapshotId }, root);
|
|
71
|
+
if (canonical(original.originalFacts.files.map(file => file.path)) !== canonical(binding.targetPaths)) {
|
|
72
|
+
fail('Original retained scope no longer matches its frozen entry paths');
|
|
73
|
+
}
|
|
74
|
+
const identity = { ...input, repositoryRoot: root, home, apiOrigin, localBindingDigest: sha256(canonical(binding)),
|
|
75
|
+
originalFactsDigest: original.originalFactsDigest, originalSnapshotDigest: original.originalSnapshot.digest };
|
|
76
|
+
const directory = path.join(base, 'amendments', sha256(canonical(input)));
|
|
77
|
+
await privateMutationDirectory14(directory, { privateFrom: stateRoot });
|
|
78
|
+
return { root, home, stateRoot, directory, file: path.join(directory, 'journal.json'), identity,
|
|
79
|
+
binding, key, signerConfigPath, original };
|
|
80
|
+
}
|
|
81
|
+
export async function readScopeAmendmentState(location) {
|
|
82
|
+
let value;
|
|
83
|
+
try { await privateItem(location.file); value = await readMutationJson14(location.file); }
|
|
84
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
85
|
+
if (!value) return { schemaVersion: 'entrorove.scope-amendment-state/1.0', identity: location.identity };
|
|
86
|
+
if (value.schemaVersion !== 'entrorove.scope-amendment-state/1.0'
|
|
87
|
+
|| canonical(value.identity) !== canonical(location.identity)) fail('Scope amendment journal cannot replace its original task or signer');
|
|
88
|
+
if (value.attestation && value.attestationDigest !== sha256(canonical(value.attestation))) fail('Stored amendment proof changed');
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
export async function saveScopeAmendmentState(location, value) {
|
|
92
|
+
if (Buffer.byteLength(canonical(value)) > maximumStateBytes) fail('Scope amendment state exceeds its bounded private file limit');
|
|
93
|
+
return atomicMutationJson14(location.file, value, { privateFrom: location.stateRoot, replace: true });
|
|
94
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { scopePath, scopeFail } from './local-scope-files.mjs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const identifier = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
7
|
+
const hash = /^[a-f0-9]{64}$/;
|
|
8
|
+
export const amendmentFail = message => scopeFail('ENTROROVE_SCOPE_AMENDMENT_BINDING', message);
|
|
9
|
+
export function amendmentExact(value, fields) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
11
|
+
|| Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) amendmentFail('Scope amendment fields are invalid');
|
|
12
|
+
}
|
|
13
|
+
export function amendmentInput(input) {
|
|
14
|
+
amendmentExact(input, ['flowId', 'proposalId', 'proposalDigest', ...['gatePassPath'].filter(field => Object.hasOwn(input ?? {}, field))]);
|
|
15
|
+
if (!identifier.test(input.flowId) || !identifier.test(input.proposalId) || !hash.test(input.proposalDigest)) {
|
|
16
|
+
amendmentFail('Scope amendment requires its exact original flow and proposal identity');
|
|
17
|
+
}
|
|
18
|
+
if (Object.hasOwn(input, 'gatePassPath') && !path.isAbsolute(input.gatePassPath ?? '')) amendmentFail('Scope refresh requires its exact absolute original stored mutation pass');
|
|
19
|
+
return { ...input };
|
|
20
|
+
}
|
|
21
|
+
export function amendmentFiles(files, original) {
|
|
22
|
+
if (!Array.isArray(files) || !files.length || files.length > 512) amendmentFail('Scope amendment requires a bounded complete exact role list');
|
|
23
|
+
const requirements = new Set(original?.requirements?.length ? original.requirements.map(item => item.id) : ['task-goal']);
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
amendmentExact(file, ['path', 'sha256', 'access', 'requirementRefs', 'reason']); scopePath(file.path);
|
|
26
|
+
if (!hash.test(file.sha256) || !['write', 'read-only'].includes(file.access)
|
|
27
|
+
|| typeof file.reason !== 'string' || !file.reason.trim() || file.reason.length > 2000
|
|
28
|
+
|| !Array.isArray(file.requirementRefs) || !file.requirementRefs.length || file.requirementRefs.length > 512
|
|
29
|
+
|| new Set(file.requirementRefs).size !== file.requirementRefs.length
|
|
30
|
+
|| file.requirementRefs.some(id => !identifier.test(id) || !requirements.has(id))) amendmentFail('Scope amendment role or original requirement changed');
|
|
31
|
+
}
|
|
32
|
+
if (new Set(files.map(file => file.path)).size !== files.length) amendmentFail('Scope amendment file roles must be unique');
|
|
33
|
+
return files;
|
|
34
|
+
}
|
|
35
|
+
export function scopeRevisionAuthority(value, schemaVersion) {
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || value.schemaVersion !== schemaVersion) {
|
|
37
|
+
amendmentFail('Scope amendment requires its signed server authority');
|
|
38
|
+
}
|
|
39
|
+
const { authorityReceipt: receipt, ...core } = value, bytes = Buffer.from(canonical(core));
|
|
40
|
+
let key, der;
|
|
41
|
+
try {
|
|
42
|
+
const encoded = process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY;
|
|
43
|
+
der = Buffer.from(encoded ?? '', 'base64'); key = createPublicKey({ key: der, format: 'der', type: 'spki' });
|
|
44
|
+
if (key.asymmetricKeyType !== 'ed25519' || key.export({ type: 'spki', format: 'der' }).toString('base64') !== encoded) throw new Error();
|
|
45
|
+
} catch { amendmentFail('Pinned workflow public authority is unavailable'); }
|
|
46
|
+
amendmentExact(receipt, ['schemaVersion', 'keyId', 'payloadDigest', 'signature']);
|
|
47
|
+
if (bytes.length > 1_000_000 || receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0'
|
|
48
|
+
|| receipt.keyId !== sha256(der) || receipt.payloadDigest !== sha256(bytes)
|
|
49
|
+
|| typeof receipt.signature !== 'string' || !/^[A-Za-z0-9_-]{86}$/.test(receipt.signature)
|
|
50
|
+
|| Buffer.from(receipt.signature, 'base64url').toString('base64url') !== receipt.signature
|
|
51
|
+
|| !verify(null, bytes, key, Buffer.from(receipt.signature, 'base64url'))) amendmentFail('Scope amendment authority signature changed');
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
export function scopeAmendmentChallenge(value, location, { allowExpired = false } = {}) {
|
|
55
|
+
const challenge = scopeRevisionAuthority(value, 'entrorove.scope-revision-challenge/1.0');
|
|
56
|
+
const refresh = challenge.purpose === 'scope-revision-refresh';
|
|
57
|
+
if (!refresh && challenge.purpose !== 'scope-revision') amendmentFail('Scope amendment challenge has another purpose');
|
|
58
|
+
const common = ['flowId', 'proposalId', 'proposalDigest'];
|
|
59
|
+
if (common.some(field => challenge[field] !== location.identity[field])
|
|
60
|
+
|| challenge.runnerId !== location.binding.runnerId || challenge.repositoryId !== location.binding.repositoryId
|
|
61
|
+
|| challenge.keyId !== location.binding.keyId || challenge.receiverId !== location.binding.runnerId
|
|
62
|
+
|| challenge.taskDigest !== location.original.taskDigest || canonical(challenge.task) !== canonical(location.original.originalTask)
|
|
63
|
+
|| !identifier.test(challenge.ownerId) || !identifier.test(challenge.stepId) || !identifier.test(challenge.challengeId)
|
|
64
|
+
|| !identifier.test(challenge.challenge) || !Number.isSafeInteger(challenge.expectedRevision) || challenge.expectedRevision < 0
|
|
65
|
+
|| ['previousDigest', 'originalBindingDigest', 'originalGateDigest', 'baseScopeArtifactDigest', 'chainDigest'].some(field => !hash.test(challenge[field]))) {
|
|
66
|
+
amendmentFail('Scope amendment challenge replaced its original task, signer or scope identity');
|
|
67
|
+
}
|
|
68
|
+
amendmentFiles(challenge.files, location.original.originalTask);
|
|
69
|
+
if (!Array.isArray(challenge.currentFiles) || challenge.currentFiles.length !== challenge.files.length
|
|
70
|
+
|| !hash.test(challenge.currentScopeArtifactDigest)
|
|
71
|
+
|| new Set(challenge.currentFiles.map(file => file.path)).size !== challenge.files.length) amendmentFail('Scope amendment current facts are invalid');
|
|
72
|
+
for (const current of challenge.currentFiles) {
|
|
73
|
+
amendmentExact(current, ['path', 'sha256']);
|
|
74
|
+
if (!challenge.files.some(file => file.path === current.path) || !hash.test(current.sha256)) amendmentFail('Scope amendment current facts replaced its exact roles');
|
|
75
|
+
}
|
|
76
|
+
if (location.binding.targetPaths.some(path => !challenge.files.some(file => file.path === path))) amendmentFail('Scope amendment omitted an original entry');
|
|
77
|
+
const issued = Date.parse(challenge.issuedAt), expires = Date.parse(challenge.expiresAt);
|
|
78
|
+
if (!Number.isFinite(issued) || !Number.isFinite(expires) || issued > Date.now() || !allowExpired && expires <= Date.now()
|
|
79
|
+
|| expires <= issued || expires - issued > 90_000) amendmentFail('Scope amendment challenge is expired');
|
|
80
|
+
if (refresh && (!hash.test(challenge.authorityRevisionDigest) || challenge.authorityRevisionDigest !== challenge.previousDigest
|
|
81
|
+
|| !Number.isSafeInteger(challenge.expectedFactRevision) || challenge.expectedFactRevision < 1)) amendmentFail('Scope proof refresh lost its unchanged authority revision');
|
|
82
|
+
return challenge;
|
|
83
|
+
}
|
|
@@ -48,6 +48,7 @@ export const WORKFLOW_ACCEPTED_PLAN_SCHEMA = exact({ schemaVersion: { const: 'en
|
|
|
48
48
|
WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.verificationMode = enumeration(['independent', 'member-local']);
|
|
49
49
|
WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.memberScopeKeyId = sha256;
|
|
50
50
|
WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.memberScopeBindingDigest = sha256;
|
|
51
|
+
WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.scopeRevisionDigest = sha256;
|
|
51
52
|
WORKFLOW_ACCEPTED_PLAN_SCHEMA.allOf = [{
|
|
52
53
|
if: { properties: { verificationMode: { const: 'member-local' } }, required: ['verificationMode'] },
|
|
53
54
|
then: { properties: { runnerKeyId: { type: 'null' } }, required: ['memberScopeKeyId', 'memberScopeBindingDigest'] },
|
|
@@ -63,6 +64,7 @@ export const WORKFLOW_LOCAL_LEASE_SCHEMA = exact({ schemaVersion: { const: 'entr
|
|
|
63
64
|
verifiedDependencies: { ...boundedArray(exact({ stepId: id, evidenceDigest: sha256 }), 0, 128), uniqueItems: true }, authorityReceipt,
|
|
64
65
|
});
|
|
65
66
|
WORKFLOW_LOCAL_LEASE_SCHEMA.properties.localDependencies = { ...boundedArray(exact({ stepId: id, acceptanceDigest: sha256 }), 0, 128), uniqueItems: true };
|
|
67
|
+
WORKFLOW_LOCAL_LEASE_SCHEMA.properties.scopeRevisionDigest = sha256;
|
|
66
68
|
WORKFLOW_LOCAL_LEASE_SCHEMA.allOf = [{ if: { required: ['localDependencies'] },
|
|
67
69
|
then: { properties: { verifiedDependencies: { type: 'array', maxItems: 0 } } } }];
|
|
68
70
|
export const WORKFLOW_LOCAL_OPERATION_SCHEMA = object({ repositoryRoot: absolutePath, home: absolutePath,
|
|
@@ -64,7 +64,7 @@ function lifetime(start, end, max, now, permitExpired = false) {
|
|
|
64
64
|
}
|
|
65
65
|
function validatePlan(plan) {
|
|
66
66
|
exact(plan, ['schemaVersion', 'planId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision', 'acceptedAt', 'expiresAt', 'runnerKeyId', 'steps',
|
|
67
|
-
...['verificationMode', 'memberScopeKeyId', 'memberScopeBindingDigest'].filter(field => Object.hasOwn(plan, field)),
|
|
67
|
+
...['verificationMode', 'memberScopeKeyId', 'memberScopeBindingDigest', 'scopeRevisionDigest'].filter(field => Object.hasOwn(plan, field)),
|
|
68
68
|
...(plan.blueprintBinding ? ['blueprintBinding'] : [])], 'ENTROROVE_WORKFLOW_PLAN_FIELDS', 'accepted plan');
|
|
69
69
|
if (plan.blueprintBinding) {
|
|
70
70
|
const binding = exact(plan.blueprintBinding, ['schemaVersion', 'blueprintId', 'revision', 'blueprintSha256', 'machineTasksDigest'],
|
|
@@ -80,6 +80,7 @@ function validatePlan(plan) {
|
|
|
80
80
|
: (plan.verificationMode === undefined || plan.verificationMode === 'independent') && SHA.test(plan.runnerKeyId ?? '')
|
|
81
81
|
&& !Object.hasOwn(plan, 'memberScopeKeyId') && !Object.hasOwn(plan, 'memberScopeBindingDigest');
|
|
82
82
|
if (plan.schemaVersion !== WORKFLOW_LOCAL_PLAN_SCHEMA || !SHA.test(plan.taskDigest) || !SHA.test(plan.chainDigest)
|
|
83
|
+
|| Object.hasOwn(plan, 'scopeRevisionDigest') && !SHA.test(plan.scopeRevisionDigest)
|
|
83
84
|
|| !validRole || !positive(plan.chainRevision) || !Array.isArray(plan.steps) || !plan.steps.length || plan.steps.length > 128) fail('ENTROROVE_WORKFLOW_PLAN_FIELDS', 'accepted plan contract is invalid');
|
|
84
85
|
for (const field of ['planId', 'flowId', 'ownerId']) safeId(plan[field], field);
|
|
85
86
|
const seen = new Set();
|
|
@@ -97,11 +98,12 @@ function validatePlan(plan) {
|
|
|
97
98
|
}
|
|
98
99
|
function validateLease(lease, plan, stepId) {
|
|
99
100
|
exact(lease, ['schemaVersion', 'leaseId', 'planId', 'planDigest', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision',
|
|
100
|
-
'stepId', 'sequence', 'generation', 'issuedAt', 'expiresAt', 'verifiedDependencies'
|
|
101
|
+
'stepId', 'sequence', 'generation', 'issuedAt', 'expiresAt', 'verifiedDependencies',
|
|
102
|
+
...['scopeRevisionDigest'].filter(field => Object.hasOwn(lease, field))], 'ENTROROVE_WORKFLOW_BINDING', 'local lease');
|
|
101
103
|
safeId(lease.leaseId, 'leaseId'); safeId(stepId, 'stepId');
|
|
102
104
|
const step = plan.steps.find(item => item.stepId === stepId);
|
|
103
105
|
if (!step || lease.schemaVersion !== WORKFLOW_LOCAL_LEASE_SCHEMA || lease.stepId !== stepId || lease.sequence !== step.sequence
|
|
104
|
-
|| lease.planDigest !== sha(canonical(plan)) || !positive(lease.generation)
|
|
106
|
+
|| lease.planDigest !== sha(canonical(plan)) || !positive(lease.generation) || lease.scopeRevisionDigest !== plan.scopeRevisionDigest
|
|
105
107
|
|| ['planId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision'].some(key => lease[key] !== plan[key])) fail('ENTROROVE_WORKFLOW_BINDING', 'lease does not match the exact original plan, task, chain, owner and step');
|
|
106
108
|
if (!Array.isArray(lease.verifiedDependencies) || lease.verifiedDependencies.length !== step.dependsOn.length) fail('ENTROROVE_WORKFLOW_DEPENDENCY', 'cloud verified dependency receipts are required');
|
|
107
109
|
const dependencies = lease.verifiedDependencies.map(item => {
|