@entrorove/skill 0.1.55 → 0.1.57
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 +1 -1
- package/CHANGELOG.md +10 -0
- package/IDE-EXECUTION.md +18 -4
- package/SKILL.md +4 -4
- package/automatic-feedback-client.mjs +6 -2
- package/connected-cli.mjs +2 -1
- package/ide-command.mjs +3 -1
- package/ide-hook.mjs +22 -6
- package/ide-install.mjs +1 -1
- package/ide-local-acceptance.mjs +134 -0
- package/ide-results.mjs +5 -1
- package/local-approved-plan.mjs +3 -2
- package/local-discipline-contracts.mjs +5 -1
- package/local-mutation-wire.mjs +14 -2
- package/local-preserved-mutation-state.mjs +3 -1
- package/local-preserved-mutation-wire.mjs +8 -1
- package/local-runner-discipline.mjs +7 -2
- package/manifest.json +1 -1
- package/mutation-client.mjs +2 -1
- package/package.json +8 -1
- package/release-state.json +30 -28
- package/release.mjs +1 -0
- package/releases.json +12 -0
- package/reuse-discipline-release.mjs +1 -1
- package/snapshot-retention.mjs +8 -2
- package/workflow-local-acceptance.mjs +35 -0
- package/workflow-local-contracts.mjs +18 -1
- package/workflow-local-runner.mjs +12 -4
- package/workflow-observation-client.mjs +7 -2
- package/workflow-observation-connection.mjs +19 -3
- package/workflow-observation-remote.mjs +19 -4
- package/workflow-observation-wire.mjs +17 -10
package/CAPABILITY-MAP.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
| --- | --- | --- |
|
|
7
7
|
| 成员受限编辑 | `mutation-connect <repositoryRoot>`、`local mutation-delegate-prepare` | 原任务、冻结修改声明及租约绑定;观察、编辑、独立验证三把密钥分离。运行时依赖保持未知且只读,只允许现存文件的受限修改;不授予通用执行、合并或交付权限 |
|
|
8
8
|
| 连续编辑结算 | `mutation-settle <repositoryRoot>`、`local mutation-consumption-attest`、服务端 `mutation-pass-settle` | 实际写后 M 回执与 fresh S 后验共同确认原 pass;下一轮使用 `previousPassId`,不伪造架构验证事件 |
|
|
9
|
-
|
|
|
9
|
+
| 沙箱关闭后的本地验收 | `workflow-observe-step <repositoryRoot>`、`member-observation-admit/report/status`、`workflow-verdict` | 服务器签名 member-local 计划中,原冻结命令和测试真实通过后签发独立保存的 localAcceptance,续跑原步骤并完成本地验收;原 O 无 V 回执,明确未进行独立验证。原范围、审批、S/M 分离及未知依赖保护保留 |
|
|
10
10
|
| 续跑入口 | 原 `execution-claim`、原 flow 恢复 | 成员受限范围返回原字段的 `localAction`,客户端串联真实快照、签名、云端通行证及本地安装;网络结果不明时恢复原请求 |
|
|
11
11
|
| 同一阻断停止 | IDE `Stop` 回调 | 真实重复阻断可等待;状态查询及检查点不能重置阻断,宿主重复 Stop 不再生成无限续跑。原 flow、需求队列与恢复数据保留 |
|
|
12
12
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.57 — 2026-10-17
|
|
4
|
+
|
|
5
|
+
- 云端关闭时以原本地 IDE 执行与实际测试完成本地验收,补齐签名步骤续跑、最终验收、钩子与队列恢复;保留原范围、审批和 S/M 证据,明确未做独立验证。修复原规划字段诊断与沙箱模式恢复。
|
|
6
|
+
- Completed explicit local IDE acceptance with real execution and tests when cloud sandbox is disabled, including signed step progress, final acceptance, hooks and queue recovery while preserving original scope, approvals and S/M evidence without claiming independent verification. Added precise planning diagnostics and original sandbox-mode recovery.
|
|
7
|
+
|
|
8
|
+
## 0.1.56 — 2026-10-17
|
|
9
|
+
|
|
10
|
+
- 补齐原工作流显式只读步骤及无写入结算的本地观察,复核真实受检仓库字节与权限;修复流程列表空输入兼容,返回精确规划前置条件与范围错误,并配置 GitHub OIDC 发布。
|
|
11
|
+
- Added explicit read-only workflow steps and local observations without fabricated mutation settlements, checking actual repository bytes and modes; fixed empty-input flow listing, precise planning prerequisite and scope errors, and GitHub OIDC publishing metadata.
|
|
12
|
+
|
|
3
13
|
## 0.1.55 — 2026-10-17
|
|
4
14
|
|
|
5
15
|
- 修复任务间消息被技能误拦截,将已授权通信交回主机审批;规范只读和 SDK 命令执行参数;新增原流程计划阶段的显式范围重评与恢复提示,保留原门禁、合同、请求身份及失败历史。
|
package/IDE-EXECUTION.md
CHANGED
|
@@ -6,6 +6,8 @@ If the IDE guard itself is the blocked step, run `entrorove unlock` directly fro
|
|
|
6
6
|
|
|
7
7
|
Capability discovery and offline unlock remain administrative calls during `entry`, before a flow exists. The guard recognizes its own rendered commands with quoted identity assignments, their order changed, or a literal `env` prefix containing only supported host/session identities; it rewrites approved calls to the installed runtime and current session. This does not authorize native scripts or unbound project writes.
|
|
8
8
|
|
|
9
|
+
`flows` is a read-only account query with no stdin. For host compatibility, the guard accepts a literal empty `{}` here-doc on `flows` and removes it from the bound command; any nonempty input remains rejected.
|
|
10
|
+
|
|
9
11
|
Approved Codex `exec_command` reads and SDK calls run with `/bin/sh` and `login:false`; static code mode uses the same execution input check. Its noninteractive workdir and output controls pass through. Other hosts keep their adapter's command schema, including timeout where supported, with custom executors and permission expansion rejected.
|
|
10
12
|
|
|
11
13
|
For a Codex task-to-task report explicitly authorized by the human user, the guard passes the exact `mcp__codex_app__send_message_to_thread` tool through unchanged, including a static `tools.mcp__codex_app__send_message_to_thread({...})` call inside `functions.exec`. Codex still decides whether the user authorized the message. Its text and response grant no Entrorove flow, file, shell or task-creation authority.
|
|
@@ -34,7 +36,7 @@ Set `ENTROROVE_IDE` to the actual host name (for example `Codex`, `Cursor` or `C
|
|
|
34
36
|
|
|
35
37
|
### Interrupting and resuming requirements
|
|
36
38
|
|
|
37
|
-
User text received during an unfinished governed task is persisted as an exact, private queue entry. `queue status` reads it; `queue route` assigns the existing `background`, `normal`, `high` or `urgent` priority. `interrupt` only takes effect at a controlled-call checkpoint and keeps the parent flow, task ID, task number, request ID, repository and account binding. Once the inserted requirement
|
|
39
|
+
User text received during an unfinished governed task is persisted as an exact, private queue entry. `queue status` reads it; `queue route` assigns the existing `background`, `normal`, `high` or `urgent` priority. `interrupt` only takes effect at a controlled-call checkpoint and keeps the parent flow, task ID, task number, request ID, repository and account binding. Once the inserted requirement satisfies its original completion contract and stored feedback acknowledgement, the parent is restored and its returned `nextStep` is the only continuation. A member-local task requires the signed final local acceptance; an independent task requires verified completion. Stop may request one bounded continuation to classify a queued requirement or recover an unresolved request read only. A persisted repeated blocker may wait without terminating or advancing the original queue; automatic continuation is limited to one attempt per user turn and respects the host's `stop_hook_active` signal. Status queries and checkpoints do not erase the blocker. The queue does not promise execution when the host is powered off or the IDE process is unavailable.
|
|
38
40
|
|
|
39
41
|
### Delivery boundaries and stop-loss
|
|
40
42
|
|
|
@@ -90,7 +92,19 @@ After the pass's actual writes finish, rerun the original `scope-connect` with i
|
|
|
90
92
|
|
|
91
93
|
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.
|
|
92
94
|
|
|
93
|
-
With cloud sandbox disabled, the returned `observationAction` runs `npx @entrorove/skill workflow-observe-step <original repositoryRoot
|
|
95
|
+
With cloud sandbox disabled, the returned `observationAction` runs `npx @entrorove/skill workflow-observe-step <original repositoryRoot>`. A mutable step uses `{taskId,flowId,stepId,leaseId,generation,settlementId}` and the service updates source hashes only through accepted M settlements. An explicitly frozen read-only step uses `{taskId,flowId,stepId,leaseId,generation}`; its signed admission declares `sourceKind: original-read-only-plan` and contains no settlement ID or digest. Both paths require the original frozen command, executable hash, environment, limits and current signed source manifest. The local runner checks actual source and protected dependencies before and after execution, including repository bytes and modes. No validator private key is needed. The raw observation stays `status: incomplete`, `runner: local`, `receipt: null`, `independentValidation: pending`; local acceptance is represented separately below. Reentry preserves a completed process result; an unknown process outcome never authorizes an automatic second launch.
|
|
96
|
+
|
|
97
|
+
### Local IDE acceptance
|
|
98
|
+
|
|
99
|
+
For an enrolled member with the account's cloud sandbox disabled, `workflow-plan` freezes a server-signed `verificationMode: member-local`, `runnerKeyId: null` plan bound to the original scope signer. This is an explicit local acceptance contract. It neither depends on a missing V key nor changes an already frozen independent plan. Both fully known and restricted dependency scopes retain their existing write guards; unknown loaders remain protected.
|
|
100
|
+
|
|
101
|
+
Use each returned original `localAction` and `observationAction`. Only an actual successful original process, passing checks and unchanged protected repository can receive a server-signed `localAcceptance`. The wrapper reports `local-accepted` and `independentValidation: not-performed`, while its raw observation remains intact. The CLI and IDE hook verify that signature and the original request, lease and result binding. Local dependencies carry `localDependencies`; formal `verifiedDependencies` remain empty. A failed process, changed bytes, forged signature or unknown outcome cannot advance the next step.
|
|
102
|
+
|
|
103
|
+
After all original steps and requirement checkpoints have passed, invoke the returned `workflow-verdict`. A signed `entrorove.member-local-workflow-acceptance/1.0` completes local acceptance of that same task. Display “本地执行与测试已验收;未进行独立验证”. Formal V, Blueprint independent criteria, Swarm, merge and handoff reject local plans. Do not generate a validator key, claim independent verification, alter failed history or create a replacement flow.
|
|
104
|
+
|
|
105
|
+
The installed hook verifies the original signed local result before continuing or ending the task. Final acceptance and acknowledged truthful feedback permit restoring its queued parent and cleaning superseded snapshots for this flow, preserving original, final and referenced rollback copies. Cloud resource expiry can clean that resource while a valid original local plan continues; explicit cancellation and already terminal workflows retain their original termination.
|
|
106
|
+
|
|
107
|
+
For `ENTROROVE_PLAN_VALIDATION_REQUIRED`, correct only the identified proposal field and derive a canonical request identity from the corrected content on the same flow. Manifest hashes use `SHA256("validator.file-manifest/1.0\n" + files.map(file => file.path + "\0" + file.sha256).join("\n"))`, without a trailing newline. This is distinct from the canonical JSON digest of the tests array. `ENTROROVE_PLAN_SCOPE_REVIEW_REQUIRED` does not authorize extra existing files or name an executable scope amendment operation; preserve the exact accepted targets and obtain the original scope authority before expanding them.
|
|
94
108
|
|
|
95
109
|
This command runs with the host OS user's permissions, not inside an OS sandbox. Its repository checks do not prove that external files, networks or databases were unchanged: `executionIsolation` remains `host-permissions`, and `externalEffects` remains `unverified`. The independent supervisor enforces the admitted start deadline and command timeout and reclaims its own process group if the CLI disappears. Only the original approved command can be launched; these observations grant no general shell or project-write permission.
|
|
96
110
|
|
|
@@ -100,7 +114,7 @@ An editing pass does not certify test execution, independent validation, merge o
|
|
|
100
114
|
|
|
101
115
|
For a decision, include `input.task.interaction` with `type` (`confirm`, `choice`, `multi` or `input`), a `question`, `options` (two or more with unique `id` and `label`, or an empty array for input), and a `callback` containing its `operation` and `payload`. Medium- and high-risk decisions also need a `riskDescription`. The decision waits for an explicit answer; it never chooses a default on timeout.
|
|
102
116
|
|
|
103
|
-
Use `npx @entrorove/skill status <requestId>` after a connection loss. It performs a read-only GET lookup and never resends a POST. Use `npx @entrorove/skill flows` to list recoverable flow steps. `npx @entrorove/skill usage [flowId]` reads measured consumption grouped per host and model and, with an original flow, its delivered stages; it never displays an invented quota or a predicted duration. `npx @entrorove/skill billing` exports measured cost per account, host, model and task with that task's delivery stages. A declared host adapter may report the host's own remaining quota through the same module that describes its guard; when it cannot, the answer stays explicitly `unknown` and only this skill's measured ledger is used. `GET /api/skill/flows/:id/status` reports `delivery` with delivered, total and remaining stages
|
|
117
|
+
Use `npx @entrorove/skill status <requestId>` after a connection loss. It performs a read-only GET lookup and never resends a POST. Use `npx @entrorove/skill flows` to list recoverable flow steps. `npx @entrorove/skill usage [flowId]` reads measured consumption grouped per host and model and, with an original flow, its delivered stages; it never displays an invented quota or a predicted duration. `npx @entrorove/skill billing` exports measured cost per account, host, model and task with that task's delivery stages. A declared host adapter may report the host's own remaining quota through the same module that describes its guard; when it cannot, the answer stays explicitly `unknown` and only this skill's measured ledger is used. `GET /api/skill/flows/:id/status` reports `delivery` with delivered, total and remaining stages. Independent plans count independently verified steps; member-local plans count only signed local acceptances of original business steps and explicitly report that basis. A v2 flow keeps its original `flowId`; local acceptance does not change formal step status to `verified`.
|
|
104
118
|
|
|
105
119
|
The local execution layer is available through `npx @entrorove/skill local <operation> [repositoryRoot]` with a JSON object on standard input. `local capabilities` lists the complete contract, operation groups and `requiresPass` metadata. `local capabilities <group>` filters that same contract. Operations include `snapshot-plan`, `snapshot-verify`, `mutate-gate`, `gate-issue`, `gate-verify`, `guarded-write`, `budget-init`, `budget-context`, `budget-read`, `budget-status`, `budget-extend`, budget renewal confirmation, `peer-coordinate`, `spawn-bind`, `peer-complete`, `lease-verify`, `probe`, `reassess`, execution ledger and virtual branch records, `chain-init`, `chain-resume`, `chain-status`, `chain-answer`, `evidence-sign`, `evidence-verify`, `merge-verified`, `rollback`, `formula-evaluate`, `formula-graph`, `formula-final-gate`, `run-approved-plan` and `final-gate`. State is kept only under `~/.entrorove/`; snapshots never create Git branches or worktrees. Guarded writes require a short-lived Ed25519 pass bound to the flow, step, snapshot digest and path set. Local evidence uses `entrorove.test-evidence/2.0`; unsigned local evidence remains incomplete and verified delivery requires a trusted receipt. Approved plan execution requires a frozen plan, matching approval and unchanged files; without a configured runner signer it reports local evidence as incomplete.
|
|
106
120
|
|
|
@@ -120,7 +134,7 @@ The website's signed-in bell control enables encrypted Web Push for the current
|
|
|
120
134
|
|
|
121
135
|
Every completed or failed execution call receives a server-generated `feedback` receipt bound to the original account, request hash and result digest. Durable calls commit this evaluation in the same transaction as the result; domain adapters return an idempotent receipt with the same HTTP response. The CLI verifies the original identity and evaluation digest, then immediately returns that exact receipt identity to the authenticated acknowledgement endpoint before finishing the call. Cloud watches do the same as soon as the original evaluation is available. A confirmed `acknowledgement` records the IDE and server reception time without changing the evaluation or counting another invocation. Preserve `receiptId`, `requestId` and `evaluation.digest`; never generate a score or comment locally. Immediately display the confirmed evaluation acknowledgement in visible IDE commentary before the task's final answer, deduplicated by receipt ID. Tool output alone is insufficient. Say “已回传” only with a confirmed acknowledgement; an upload failure must remain visible and cannot become successful feedback. Read the saved receipt with `npx @entrorove/skill feedback <original requestId>` after a connection loss; this is GET only and never repeats execution or submits another evaluation. An authorized retry before side effects keeps the original request but gets a new server execution generation and immutable receipt. Default feedback reads the latest generation; `feedback <original requestId> <generation>` reads a preserved earlier receipt. Never overwrite a failed receipt or use it as the current retry result. Discovery/status queries create no evaluations.
|
|
122
136
|
|
|
123
|
-
Only server-verified progress
|
|
137
|
+
Only server-verified progress, a validated pure operation or a verified server-signed local acceptance earns a positive automatic evaluation. Local evaluations state `verificationMode: member-local` and `independentValidation: not-performed`. Reported steps, intake, model summaries and incomplete evidence without local acceptance remain `pending` with no score. Blocked/failed results and P0/P1 findings cannot become positive. These are private technical evaluations; they do not create a public user review or masquerade as the user's words. If a server advertising this extension returns no valid receipt, show the missing receipt and query its original ID. Do not silently claim that evaluation was reported.
|
|
124
138
|
|
|
125
139
|
When the user gives an actual evaluation after using the skill, offer to publish that feedback. Only after they explicitly agree to public display under the current notice, send their unchanged words, IDE name and 1–5 rating as JSON on standard input to `npx @entrorove/skill review`, including `invocationRequestId` from their actual successful call, `publicConsent: true` and `noticeVersion: "2026-10-07.1"`. The same account must own that successful call within the last 30 days; a recorded usage event, failed call or unknown result does not qualify. Three public reviews per account per day share one server quota across API replicas. Public output exposes only IDE, rating, original text and time, never account or invocation identity. Never invent, rewrite or infer a review from task completion. The website reads accepted reviews directly from entrorove's independent database.
|
|
126
140
|
|
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.57.
|
|
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
|
|
|
@@ -16,15 +16,15 @@ Entrorove applies to the whole task once selected. Reading this document or call
|
|
|
16
16
|
To remove this skill and its host hooks, run `entrorove uninstall` directly. It works offline and preserves unrelated host settings, credentials and task records. If a managed file was edited, it reports the preserved path instead of deleting it.
|
|
17
17
|
2. Read [IDE-EXECUTION.md](IDE-EXECUTION.md) for the exact entry, cloud callbacks, account agreement and operation contracts. Use the stored account credential; never put it in arguments, URLs, project files, output or fixtures. Entrorove is independent of other systems.
|
|
18
18
|
3. Discover `capabilities`, then invoke `start` with the actual goal, task type, risk and scope facts. An enabled account returns a cloud choice before charging a model. Show that choice visibly using the host question tool. Confirmation calls its original callback; exit adopts the local route for this task. Never treat silence as permission, invent acceptance, or create both routes. Existing explicit authorization for this task persists.
|
|
19
|
-
With cloud sandbox disabled, the IDE performs its own planning and continues the original API flow. Do not require a cloud model call or create a sandbox task to perform local work.
|
|
19
|
+
With cloud sandbox disabled, the IDE performs its own planning and continues the original API flow. Do not require a cloud model call or create a sandbox task to perform local work. The service freezes eligible member plans with `verificationMode: member-local` and `runnerKeyId: null`; successful original IDE execution and tests satisfy local acceptance. This mode comes from the stored account preference and enrolled scope, never a caller flag or an unavailable validator key. An already frozen independent plan keeps its original acceptance contract.
|
|
20
20
|
When the model produces a task contract, preserve its `workType`, `risk`, `difficulty`, `crossModule`, `parallel`, `unclear`, `architectureContract`, optional `baselineRef`, and optional `interaction` as returned. Valid work types are `implementation`, `documentation`, `calculation`, `merge`, `review`, `decision`, and `diagnosis`. A `merge` requires a concrete `baselineRef` identifying the branch, tag, commit, or review baseline; keep that baseline unchanged through independent verification. A `decision` requires `interaction`, including a risk description for medium or high risk. Do not fill missing required fields by guessing.
|
|
21
21
|
4. After real cloud acceptance, immediately display **云端任务 <taskNumber> · 已进入**. Preserve `taskNumber` verbatim: it is the homepage sandbox tab number. Keep its UUID `taskId` separately. Run the returned `cloud-watch`, show each real public event and its cursor, and continue the original accepted flow from status. A command log alone is insufficient. No accepted cloud task means no cloud ID or model-execution claim.
|
|
22
22
|
5. For an accepted `flowId`, execute the returned `nextStep` with its required payload. Discover the schema before constructing input. An empty payload is valid only when that operation's schema allows it. Reconnect and refresh only the original flow. A changed snapshot is a binding failure to investigate; do not invent a fresh successful baseline, rebuild a flow to evade it, or erase the old failure. If a fresh scope proof returns `ENTROROVE_SCOPE_PLANNING_REASSESSMENT_REQUIRED` before plan freeze, follow its explicit `workflow-scope-reassess` recovery and retry the original complete request; a local reassessment or snapshot refresh alone does not renew the accepted gate. If the terminal flow truly owns a stale task slot, use the explicit `flow-abandon` lifecycle operation described below before routing the original task again.
|
|
23
23
|
6. All governed mutations use the original authority: `local guarded-write`, `local guarded-delete`, `local guarded-move`, or the original frozen approved runner plan. Native patches, scripts, terminal writes, database mutations and unknown MCP operations must not substitute for the controlled entry. User-authorized task messages use the host's message tool and remain subject to host approval; sending a report does not change the flow or grant execution authority. **A failed gate or unknown outcome must never be replaced with another executor or reported as local success.** Query the original request read-only, repair the cause, and obtain the original flow's valid authority before proceeding.
|
|
24
|
-
For a
|
|
24
|
+
For a member scope, follow a returned `localAction` before submitting execution evidence. `mutation-connect` obtains only the original task's limited editing pass; it keeps unresolved loader files and project configuration read only. After actual writes, refresh the original scope and run `mutation-settle`. Use the returned `previousPassId` for further edits. In a signed member-local plan, `workflow-observe-step` runs the original approved command and tests without a validator private key. A server-signed `localAcceptance` advances local dependencies; a failed test does not. Preserve the raw observation and its absent independent receipt. See [IDE-EXECUTION.md](IDE-EXECUTION.md#local-ide-acceptance).
|
|
25
25
|
7. Read [REUSE-DISCIPLINE.md](REUSE-DISCIPLINE.md) when modifying an existing system. The comparison baseline is the existing implementation in that same repository. It never means another project, another account, or copying external systems.
|
|
26
26
|
8. Read [DELIVERY-BOUNDARIES.md](DELIVERY-BOUNDARIES.md) before claiming a delivered stage or showing consumption. A delivered stage requires a clean boundary and uses `delivered-stage-N`. Blocked or unknown work can wait with recovery data intact; it is not a delivered stage. Display consumption only with a measured baseline.
|
|
27
|
-
9.
|
|
27
|
+
9. For independent verified completion, submit actual independent execution evidence and run server verification and verdict. For a signed member-local plan, finish all original local steps and requirement checkpoints, then call the returned `workflow-verdict`; only its signed final `localAcceptance` means local acceptance is complete. Describe it as local execution and tests accepted, with independent validation not performed. Never invoke formal V, merge, Swarm or handoff to relabel it. Display the confirmed evaluation acknowledgement and preserve `receiptId`, `requestId`, execution generation and evaluation digest. “已回传” requires `acknowledgement.stored: true`. Missing or failed feedback remains visible. Active, waiting and unknown tasks retain recovery data.
|
|
28
28
|
|
|
29
29
|
The Codex guard preserves this binding over subsequent prompts and compaction and denies unsupported native mutations before execution. Hooks are host guardrails, not an operating-system security boundary; unsupported tool paths or a disabled/untrusted hook cannot be described as enforced. An explicit user decision to stop using the skill ends its governance and never becomes verified Entrorove delivery. Do not infer such a decision from quoted screenshots or a service failure.
|
|
30
30
|
|
|
@@ -23,10 +23,14 @@ export function verifyAutomaticFeedback(feedback, requestId, requestHash) {
|
|
|
23
23
|
throw new Error(`Invalid automatic feedback receipt; query feedback ${requestId} without repeating the invocation`);
|
|
24
24
|
}
|
|
25
25
|
const { digest: checksum,...core } = evaluation;
|
|
26
|
-
|
|
26
|
+
const local = evaluation.status === 'local-accepted';
|
|
27
|
+
if (digest(core) !== checksum || !['succeeded','reported','blocked','failed','incomplete','local-accepted'].includes(evaluation.status)
|
|
28
|
+
|| local && (evaluation.verificationMode !== 'member-local' || evaluation.independentValidation !== 'not-performed'
|
|
29
|
+
|| evaluation.disposition !== 'good' || evaluation.validation !== 'passed')
|
|
30
|
+
|| !local && (evaluation.verificationMode !== undefined || evaluation.independentValidation !== undefined)
|
|
27
31
|
|| evaluation.findingCount < evaluation.p0Count + evaluation.p1Count
|
|
28
32
|
|| evaluation.disposition === 'pending' && (evaluation.score !== null || evaluation.validation !== 'incomplete')
|
|
29
|
-
|| evaluation.disposition === 'good' && (evaluation.status
|
|
33
|
+
|| evaluation.disposition === 'good' && (!['succeeded','local-accepted'].includes(evaluation.status) || evaluation.validation !== 'passed' || evaluation.p0Count || evaluation.p1Count)
|
|
30
34
|
|| evaluation.disposition === 'bad' && evaluation.validation !== 'failed'
|
|
31
35
|
|| evaluation.disposition !== 'pending' && (!Number.isInteger(evaluation.score) || evaluation.score < 0 || evaluation.score > 100)) {
|
|
32
36
|
throw new Error('Automatic feedback digest or validation state is inconsistent');
|
package/connected-cli.mjs
CHANGED
|
@@ -14,7 +14,8 @@ export async function runConnectedCommand(command, root, input, dependencies) {
|
|
|
14
14
|
if (!isConnectedCommand(command)) throw new Error('Unsupported connected command');
|
|
15
15
|
const result = await commands[command](root, input, dependencies);
|
|
16
16
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
17
|
-
const expected = command === 'mutation-settle' ? ['reported', 'succeeded']
|
|
17
|
+
const expected = command === 'mutation-settle' ? ['reported', 'succeeded']
|
|
18
|
+
: command === 'workflow-observe-step' ? ['succeeded', 'local-accepted'] : ['succeeded'];
|
|
18
19
|
if (!expected.includes(result.status)) process.exitCode = 2;
|
|
19
20
|
return result;
|
|
20
21
|
}
|
package/ide-command.mjs
CHANGED
|
@@ -79,7 +79,9 @@ export function sdkCommand(parsed, runtime) {
|
|
|
79
79
|
if (['unlock', 'uninstall'].includes(args[0]) && args.length === 1 && !parsed.input) return { args };
|
|
80
80
|
if (simple.has(args[0]) && args.length === 1 && parsed.input) return { args, input: parsed.input };
|
|
81
81
|
if (['status', 'feedback'].includes(args[0]) && /^[a-f0-9]{64}$/u.test(args[1] ?? '') && [2,3].includes(args.length)) return { args };
|
|
82
|
-
if ([
|
|
82
|
+
if (args[0] === 'flows' && args.length === 1 && (!parsed.input || typeof parsed.input === 'object'
|
|
83
|
+
&& !Array.isArray(parsed.input) && Object.keys(parsed.input).length === 0)) return { args };
|
|
84
|
+
if (['install', 'billing'].includes(args[0]) && args.length === 1 && !parsed.input) return { args };
|
|
83
85
|
if (args[0] === 'install' && args.length === 3 && args[1] === '--host' && args[2] === 'codex' && !parsed.input) return { args };
|
|
84
86
|
if (args[0] === 'recurring' && ['list', 'claim'].includes(args[1] ?? '') && args.length === 2 && !parsed.input) return { args };
|
|
85
87
|
if (args[0] === 'recurring' && ['finish', 'runs'].includes(args[1] ?? '') && args.length === 3 && (!parsed.input || args[1] === 'finish')) return { args, ...(parsed.input ? { input: parsed.input } : {}) };
|
package/ide-hook.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { hostTaskMessageInput, hostTaskMessageTool, prepareIdeCodeMode } from '.
|
|
|
10
10
|
import { pathToFileURL } from 'node:url';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { parseIdeResults, consumeIdeResults } from './ide-results.mjs';
|
|
13
|
+
import { verifyIdeLocalAcceptance } from './ide-local-acceptance.mjs';
|
|
13
14
|
import { registerIdeRequirement, ideQueueContext, unclassifiedIdeRequirements,
|
|
14
15
|
advanceIdeQueue, checkQueuedStart, unfinishedIdeQueue } from './ide-queue.mjs';
|
|
15
16
|
|
|
@@ -83,9 +84,9 @@ function lifecycleResult(value, depth = 0) {
|
|
|
83
84
|
}
|
|
84
85
|
return null;
|
|
85
86
|
}
|
|
86
|
-
function collectResult(state, values, local) {
|
|
87
|
+
function collectResult(state, values, local, acceptances = []) {
|
|
87
88
|
const messages = [];
|
|
88
|
-
for (const result of values) {
|
|
89
|
+
for (const [index, result] of values.entries()) {
|
|
89
90
|
const value = result.output ?? result;
|
|
90
91
|
const lifecycleValue = lifecycleResult(result) ?? value;
|
|
91
92
|
const lifecycle = lifecycleValue.operation === 'flow-cancel' || lifecycleValue.operation === 'flow-abandon';
|
|
@@ -105,7 +106,10 @@ function collectResult(state, values, local) {
|
|
|
105
106
|
state.taskId = value.taskId; state.taskNumber = value.taskNumber;
|
|
106
107
|
}
|
|
107
108
|
const nextStep = value.nextStep ?? value.result?.nextStep ?? value.result?.result?.nextStep;
|
|
108
|
-
if (
|
|
109
|
+
if (acceptances[index] === 'workflow') { state.phase = 'ended'; delete state.nextStep; }
|
|
110
|
+
else if (acceptances[index] === 'step') { state.phase = 'continuing'; state.nextStep = { operation: 'task-resume', payload: { flowId: state.flowId } }; }
|
|
111
|
+
else if (acceptances[index] === 'invalid') state.phase = 'blocked';
|
|
112
|
+
else if (!lifecycle && (value.status === 'needs-input' || value.result?.status === 'needs-input' || value.interaction || value.result?.interaction)) state.phase = 'needs-input';
|
|
109
113
|
else if (isStageTerminal(value.status) || isStageTerminal(value.result?.status)) {
|
|
110
114
|
state.phase = 'delivered'; state.deliveredStage = stageOf(value.status) ?? stageOf(value.result?.status);
|
|
111
115
|
}
|
|
@@ -116,7 +120,7 @@ function collectResult(state, values, local) {
|
|
|
116
120
|
&& result.feedback?.acknowledgement?.stored === true) state.phase = 'ended';
|
|
117
121
|
else if (!lifecycle && local && value.status === 'verified') state.phase = 'continuing';
|
|
118
122
|
else if (!lifecycle && (value.status === 'started' || value.status === 'succeeded')) state.phase = 'continuing';
|
|
119
|
-
if (!lifecycle && nextStep) state.nextStep = { operation: nextStep.operation, payload: nextStep.payload };
|
|
123
|
+
if (!lifecycle && !acceptances[index] && nextStep) state.nextStep = { operation: nextStep.operation, payload: nextStep.payload };
|
|
120
124
|
const notices = [...(result.ideMessages ?? []), ...(value.ideMessages ?? []),
|
|
121
125
|
...(value.schemaVersion === 'entrorove.ide-event/1.0' ? [value.message] : []), ...(value.events ?? []).map(item => item.message)];
|
|
122
126
|
for (const message of notices) {
|
|
@@ -129,6 +133,11 @@ function collectResult(state, values, local) {
|
|
|
129
133
|
state.seen = state.seen.slice(-200);
|
|
130
134
|
return messages;
|
|
131
135
|
}
|
|
136
|
+
function originalLocalCall(pending, call, input) {
|
|
137
|
+
if (call.args[0] === 'workflow-observe-step') pending.observation = Object.fromEntries(
|
|
138
|
+
['taskId', 'flowId', 'stepId', 'leaseId', 'generation'].map(field => [field, input?.[field]]));
|
|
139
|
+
if (operationOf(call) === 'continue') pending.continuationOperation = input?.operation;
|
|
140
|
+
}
|
|
132
141
|
async function beforeTool(event, state, home) {
|
|
133
142
|
if (state.phase === 'user-exited') return {};
|
|
134
143
|
const unlocked = await readBreakGlass(home, state.sessionId);
|
|
@@ -185,6 +194,7 @@ async function beforeTool(event, state, home) {
|
|
|
185
194
|
operation: operationOf(call), observer, managementOnly: observer || isManagementCall(call)
|
|
186
195
|
&& !['start', 'continue', 'cloud-task'].includes(operationOf(call)) || operationOf(call) === 'task-checkpoint',
|
|
187
196
|
...(requestId ? { requestId } : {}) };
|
|
197
|
+
originalLocalCall(pending, call, input);
|
|
188
198
|
if (observer) {
|
|
189
199
|
state.readCalls ??= {};
|
|
190
200
|
if (Object.keys(state.readCalls).length >= 32) return denyBlocked(state, event, 'Entrorove:只读恢复尚未返回,请等待原调用。');
|
|
@@ -282,8 +292,14 @@ export async function runIdeHook(event, { home } = {}) {
|
|
|
282
292
|
}) : values;
|
|
283
293
|
const recoveredOriginal = pendingRead && state.pendingCall?.requestId
|
|
284
294
|
&& values.some(result => (result.output ?? result).requestId === state.pendingCall.requestId);
|
|
285
|
-
const
|
|
286
|
-
|
|
295
|
+
const acceptances = await Promise.all(bound.map(value => verifyIdeLocalAcceptance(value, pending, state, home)));
|
|
296
|
+
const messages = collectResult(state, bound, pending.command === 'local', acceptances);
|
|
297
|
+
// Preserve the full raw O in the completion file/output. Only this control
|
|
298
|
+
// projection can omit its pending status after the signed proof is checked.
|
|
299
|
+
const outcomes = bound.map((value, index) => acceptances[index] === 'step'
|
|
300
|
+
? { status: 'local-accepted', nextStep: { operation: 'task-resume' } }
|
|
301
|
+
: acceptances[index] === 'invalid' ? { status: 'blocked' } : value);
|
|
302
|
+
observeIdeToolOutcome(state, outcomes, pending);
|
|
287
303
|
if (pendingRead) delete state.readCalls[event.tool_use_id];
|
|
288
304
|
if (!pendingRead || recoveredOriginal) delete state.pendingCall;
|
|
289
305
|
const resumed = state.phase === 'blocked' || repeatedIdeBlock(state) ? '' : await advanceIdeQueue(state,home);
|
package/ide-install.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { ideDirectory, ideDigest, readIdeJson, writeIdeJson, ideError } from './
|
|
|
5
5
|
import { withStateLock } from './local-state-lock.mjs';
|
|
6
6
|
|
|
7
7
|
const source = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const files = ['ide-hook.mjs', 'ide-session.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-queue.mjs', 'ide-command.mjs', 'ide-code-mode.mjs', 'remote-transport.mjs', 'local-credential.mjs', 'local-state-lock.mjs', 'local-stage-boundary.mjs'];
|
|
8
|
+
const files = ['ide-hook.mjs', 'ide-session.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-local-acceptance.mjs', 'ide-queue.mjs', 'ide-command.mjs', 'ide-code-mode.mjs', 'remote-transport.mjs', 'local-credential.mjs', 'local-state-lock.mjs', 'local-stage-boundary.mjs'];
|
|
9
9
|
const quote = text => `'${text.replace(/'/gu, "'\\''")}'`;
|
|
10
10
|
|
|
11
11
|
// One shared host-agnostic hook binary; every host is described by its own
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { readIdeJson } from './ide-session.mjs';
|
|
6
|
+
|
|
7
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
const sha = value => typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value);
|
|
9
|
+
const id = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value);
|
|
10
|
+
const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
&& Object.keys(value).sort().join('\0') === [...fields].sort().join('\0');
|
|
12
|
+
function canonical(value) {
|
|
13
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
14
|
+
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
15
|
+
return JSON.stringify(value);
|
|
16
|
+
}
|
|
17
|
+
const digest = value => hash(canonical(value));
|
|
18
|
+
const common = ['schemaVersion', 'status', 'verificationMode', 'independentValidation', 'runnerKeyId',
|
|
19
|
+
'flowId', 'ownerId', 'taskId', 'taskDigest', 'chainDigest', 'planId', 'planDigest', 'requestId', 'acceptedAt', 'authorityReceipt'];
|
|
20
|
+
const step = ['stepId', 'executionPlanSha256', 'leaseId', 'leaseDigest', 'generation', 'observationId', 'admissionDigest', 'resultDigest'];
|
|
21
|
+
const workflow = ['stepAcceptances', 'claimsIndependentVerification', 'localExecutionAndTestsPassed'];
|
|
22
|
+
function localCore(value) {
|
|
23
|
+
if (value?.localAcceptance || value?.status === 'local-accepted') return value;
|
|
24
|
+
if (value?.result?.localAcceptance || value?.result?.status === 'local-accepted') return value.result;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export function ideLocalAcceptanceMetadata(value) {
|
|
28
|
+
if (!localCore(value) || Buffer.byteLength(JSON.stringify(value)) > 100_000) return null;
|
|
29
|
+
// A complete bounded output is needed to check the acknowledged feedback's
|
|
30
|
+
// original digest. No command output is included in local observation results.
|
|
31
|
+
return structuredClone(value);
|
|
32
|
+
}
|
|
33
|
+
async function authority(home = os.homedir()) {
|
|
34
|
+
for (const directory of [home, path.join(home, '.entrorove')]) {
|
|
35
|
+
const stat = await fs.lstat(directory);
|
|
36
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== 'win32'
|
|
37
|
+
&& (stat.uid !== process.getuid() || directory !== home && stat.mode & 0o077)) throw new Error();
|
|
38
|
+
}
|
|
39
|
+
const pin = await readIdeJson(path.join(home, '.entrorove', 'cloud-mutation-authority.json'));
|
|
40
|
+
const testOrigin = process.env.NODE_ENV === 'test' && /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):[0-9]+$/u.test(pin?.apiOrigin ?? '');
|
|
41
|
+
if (!exact(pin, ['schemaVersion', 'apiOrigin', 'authority']) || pin.schemaVersion !== 'entrorove.cloud-mutation-authority-pin/1.0'
|
|
42
|
+
|| pin.apiOrigin !== 'https://entrorove.com' && !testOrigin
|
|
43
|
+
|| !exact(pin.authority, ['schemaVersion', 'purpose', 'algorithm', 'keyId', 'publicKey'])
|
|
44
|
+
|| pin.authority.schemaVersion !== 'entrorove.mutation-authority-key/1.0'
|
|
45
|
+
|| pin.authority.purpose !== 'mutation-pass-verification' || pin.authority.algorithm !== 'Ed25519') throw new Error();
|
|
46
|
+
const encoded = pin.authority.publicKey, der = Buffer.from(encoded, 'base64');
|
|
47
|
+
const key = createPublicKey({ key: der, type: 'spki', format: 'der' });
|
|
48
|
+
if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== encoded
|
|
49
|
+
|| key.export({ type: 'spki', format: 'der' }).toString('base64') !== encoded || hash(der) !== pin.authority.keyId) throw new Error();
|
|
50
|
+
return { key, keyId: hash(der) };
|
|
51
|
+
}
|
|
52
|
+
async function signedAcceptance(value, kind, home) {
|
|
53
|
+
if (!exact(value, [...common, ...(kind === 'step' ? step : workflow)])
|
|
54
|
+
|| value.schemaVersion !== `entrorove.member-local-${kind === 'step' ? 'step' : 'workflow'}-acceptance/1.0`
|
|
55
|
+
|| value.status !== 'local-accepted' || value.verificationMode !== 'member-local'
|
|
56
|
+
|| value.independentValidation !== 'not-performed' || value.runnerKeyId !== null
|
|
57
|
+
|| !['flowId', 'taskId', 'planId'].every(field => id(value[field]))
|
|
58
|
+
|| !/^[a-f0-9-]{36}$/u.test(value.ownerId ?? '')
|
|
59
|
+
|| !['taskDigest', 'chainDigest', 'planDigest', 'requestId'].every(field => sha(value[field]))
|
|
60
|
+
|| typeof value.acceptedAt !== 'string' || !Number.isFinite(Date.parse(value.acceptedAt))
|
|
61
|
+
|| new Date(value.acceptedAt).toISOString() !== value.acceptedAt) throw new Error();
|
|
62
|
+
const receipt = value.authorityReceipt, trusted = await authority(home);
|
|
63
|
+
if (!exact(receipt, ['schemaVersion', 'keyId', 'payloadDigest', 'signature'])) throw new Error();
|
|
64
|
+
const { authorityReceipt: _, ...payload } = value, bytes = Buffer.from(canonical(payload));
|
|
65
|
+
const signature = Buffer.from(receipt.signature ?? '', 'base64url');
|
|
66
|
+
if (receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0' || receipt.keyId !== trusted.keyId
|
|
67
|
+
|| receipt.payloadDigest !== hash(bytes) || signature.length !== 64 || signature.toString('base64url') !== receipt.signature
|
|
68
|
+
|| !verify(null, bytes, trusted.key, signature)) throw new Error();
|
|
69
|
+
}
|
|
70
|
+
function successfulObservation(proof, result) {
|
|
71
|
+
const process = result?.process, checks = ['sourceManifestUnchanged', 'protectedFilesUnchanged', 'repositoryUnchanged'];
|
|
72
|
+
return result?.schemaVersion === 'entrorove.local-observation-result/1.0' && result.status === 'incomplete'
|
|
73
|
+
&& result.independentValidation === 'pending' && result.runner === 'local' && result.receipt === null
|
|
74
|
+
&& result.executionIsolation === 'host-permissions' && result.externalEffects === 'unverified'
|
|
75
|
+
&& result.observationId === proof.observationId && result.admissionDigest === proof.admissionDigest
|
|
76
|
+
&& digest(result) === proof.resultDigest && sha(result.sourceManifestDigest)
|
|
77
|
+
&& exact(result.checks, checks) && checks.every(field => result.checks[field] === true)
|
|
78
|
+
&& sha(result.beforeRepositoryDigest) && result.beforeRepositoryDigest === result.afterRepositoryDigest
|
|
79
|
+
&& process?.started === true && process.status === 'succeeded' && process.exitCode === 0 && process.signal === null
|
|
80
|
+
&& process.timedOut === false && process.outputLimitExceeded === false;
|
|
81
|
+
}
|
|
82
|
+
function acknowledgedLocalFeedback(result, output, requestId) {
|
|
83
|
+
const feedback = result.feedback, evaluation = feedback?.evaluation, ack = feedback?.acknowledgement;
|
|
84
|
+
if (!evaluation) return false;
|
|
85
|
+
const { digest: checksum, ...core } = evaluation;
|
|
86
|
+
return feedback.schemaVersion === 'entrorove.automatic-feedback/1.0' && feedback.stored === true
|
|
87
|
+
&& feedback.requestId === requestId && feedback.requestHash === requestId && feedback.resultDigest === digest(output)
|
|
88
|
+
&& evaluation.requestId === requestId && evaluation.schemaVersion === 'entrorove.automatic-evaluation/1.0'
|
|
89
|
+
&& evaluation.status === 'local-accepted' && evaluation.verificationMode === 'member-local'
|
|
90
|
+
&& evaluation.independentValidation === 'not-performed' && evaluation.disposition === 'good'
|
|
91
|
+
&& evaluation.validation === 'passed' && evaluation.p0Count === 0 && evaluation.p1Count === 0
|
|
92
|
+
&& checksum === digest(core) && ack?.schemaVersion === 'entrorove.feedback-acknowledgement/1.0'
|
|
93
|
+
&& ack.stored === true && ack.receiptId === feedback.receiptId && ack.requestId === requestId
|
|
94
|
+
&& ack.generation === (feedback.generation ?? 1);
|
|
95
|
+
}
|
|
96
|
+
function workflowProof(proof) {
|
|
97
|
+
return proof.claimsIndependentVerification === false && proof.localExecutionAndTestsPassed === true
|
|
98
|
+
&& Array.isArray(proof.stepAcceptances) && proof.stepAcceptances.length > 0 && proof.stepAcceptances.length <= 2_000
|
|
99
|
+
&& new Set(proof.stepAcceptances.map(value => value.stepId)).size === proof.stepAcceptances.length
|
|
100
|
+
&& proof.stepAcceptances.every(value => exact(value, ['stepId', 'acceptanceDigest', 'observationId', 'resultDigest'])
|
|
101
|
+
&& id(value.stepId) && id(value.observationId) && sha(value.acceptanceDigest) && sha(value.resultDigest));
|
|
102
|
+
}
|
|
103
|
+
export async function verifyIdeLocalAcceptance(result, pending, state, home) {
|
|
104
|
+
const output = result?.output ?? result, source = output?.localAcceptanceOutput ?? output, core = localCore(source);
|
|
105
|
+
if (!core) return null;
|
|
106
|
+
try {
|
|
107
|
+
const proof = core.localAcceptance, kind = proof?.schemaVersion === 'entrorove.member-local-step-acceptance/1.0' ? 'step' : 'workflow';
|
|
108
|
+
await signedAcceptance(proof, kind, home);
|
|
109
|
+
if (core.status !== 'local-accepted' || core.independentValidation !== 'not-performed'
|
|
110
|
+
|| !['local-accepted', 'succeeded'].includes(output.status) || source.flowId && source.flowId !== proof.flowId
|
|
111
|
+
|| proof.flowId !== state.flowId || state.taskId && proof.taskId !== state.taskId) throw new Error();
|
|
112
|
+
if (kind === 'step') {
|
|
113
|
+
if (pending.command !== 'workflow-observe-step' || !pending.observation
|
|
114
|
+
|| core.grantsExecutionAuthority !== false || core.grantsValidatorAuthority !== false || core.stored !== true
|
|
115
|
+
|| core.localObservationStatus !== 'local-accepted' || core.receipt !== null || core.resultDigest !== proof.resultDigest
|
|
116
|
+
|| ['taskId', 'flowId', 'stepId'].some(field => core[field] !== proof[field])
|
|
117
|
+
|| ['taskId', 'flowId', 'stepId', 'leaseId', 'generation'].some(field => proof[field] !== pending.observation[field])
|
|
118
|
+
|| !['stepId', 'leaseId', 'observationId'].every(field => id(proof[field]))
|
|
119
|
+
|| !['executionPlanSha256', 'leaseDigest', 'admissionDigest', 'resultDigest'].every(field => sha(proof[field]))
|
|
120
|
+
|| !Number.isSafeInteger(proof.generation) || proof.generation < 1 || !successfulObservation(proof, core.result)
|
|
121
|
+
|| proof.requestId !== hash('member-observation-report' + canonical({ observationId: proof.observationId,
|
|
122
|
+
admissionDigest: proof.admissionDigest, result: core.result }))) throw new Error();
|
|
123
|
+
} else if (core.verificationMode !== 'member-local' || pending.operation !== 'continue' || pending.continuationOperation !== 'workflow-verdict'
|
|
124
|
+
|| pending.requestId !== proof.requestId || source.requestId !== proof.requestId || !workflowProof(proof)
|
|
125
|
+
|| !acknowledgedLocalFeedback(result, source, proof.requestId)) throw new Error();
|
|
126
|
+
return kind;
|
|
127
|
+
} catch { return 'invalid'; }
|
|
128
|
+
}
|
|
129
|
+
export async function verifiedLocalWorkflowCompletion(call, result, home) {
|
|
130
|
+
const output = result?.output;
|
|
131
|
+
if (call.operation !== 'continue' || call.input?.operation !== 'workflow-verdict' || !id(call.input.flowId)) return false;
|
|
132
|
+
return await verifyIdeLocalAcceptance(result, { operation: 'continue', continuationOperation: 'workflow-verdict', requestId: call.requestId },
|
|
133
|
+
{ flowId: call.input.flowId }, home) === 'workflow' && output?.flowId === call.input.flowId;
|
|
134
|
+
}
|
package/ide-results.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import { ideDirectory, ideDigest, ideResultContext, readIdeJson, writeIdeJson } from './ide-session.mjs';
|
|
4
|
+
import { ideLocalAcceptanceMetadata } from './ide-local-acceptance.mjs';
|
|
4
5
|
|
|
5
6
|
export function parseIdeResults(response) {
|
|
6
7
|
const raw = typeof response === 'string' ? response : response?.output ?? response?.stdout ?? response?.aggregated_output
|
|
@@ -14,6 +15,8 @@ export function parseIdeResults(response) {
|
|
|
14
15
|
function resultMetadata(value, depth = 0) {
|
|
15
16
|
if (!value || typeof value !== 'object' || depth > 3) return undefined;
|
|
16
17
|
const metadata = {};
|
|
18
|
+
const localOutput = ideLocalAcceptanceMetadata(value);
|
|
19
|
+
if (localOutput) metadata.localAcceptanceOutput = localOutput;
|
|
17
20
|
for (const key of ['schemaVersion', 'requestId', 'flowId', 'status', 'operation', 'termination', 'flowStatus', 'taskId', 'taskNumber', 'taskStatus']) {
|
|
18
21
|
if (typeof value[key] === 'string' && value[key].length <= 200) metadata[key] = value[key];
|
|
19
22
|
}
|
|
@@ -22,7 +25,8 @@ function resultMetadata(value, depth = 0) {
|
|
|
22
25
|
for (const key of ['nextStep', 'nextAction']) if (typeof value[key]?.operation === 'string') metadata[key] = { operation: value[key].operation };
|
|
23
26
|
if (value.interaction) metadata.interaction = true;
|
|
24
27
|
if (Array.isArray(value.reports)) metadata.reports = value.reports.map(report => ({ verdict: report.verdict }));
|
|
25
|
-
if (value.feedback?.acknowledgement?.stored === true) metadata.feedback =
|
|
28
|
+
if (value.feedback?.acknowledgement?.stored === true) metadata.feedback = ideLocalAcceptanceMetadata(value.output)
|
|
29
|
+
? structuredClone(value.feedback) : { receiptId: value.feedback.receiptId, acknowledgement: { stored: true } };
|
|
26
30
|
return metadata;
|
|
27
31
|
}
|
|
28
32
|
const resultFile = (directory, sessionId, toolUseId) => path.join(directory, `result-${ideDigest({ sessionId, toolUseId })}.json`);
|
package/local-approved-plan.mjs
CHANGED
|
@@ -40,6 +40,7 @@ function validatePlan(plan, artifactDigest = planArtifact) {
|
|
|
40
40
|
validateRunnerParallelPaths(plan);
|
|
41
41
|
if (policy.discipline) {
|
|
42
42
|
const reviewed = disciplineApprovedPolicy(policy.discipline);
|
|
43
|
+
if (reviewed.access === 'read-only' && policy.parallelPaths !== undefined) fail('ENTROROVE_READ_ONLY_POLICY_INVALID', 'Read-only execution cannot grant parallel mutation or equivalence permissions');
|
|
43
44
|
if (!disciplineDataSnapshotsBound(reviewed, plan.files)) {
|
|
44
45
|
fail('ENTROROVE_DISCIPLINE_DATA_SNAPSHOT_REQUIRED', 'Existing data operations require their actual frozen snapshot artifact before execution');
|
|
45
46
|
}
|
|
@@ -96,7 +97,7 @@ function evidence(authority, result, sub) { const summary = result.timedOut ? 'A
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
export async function runApprovedPlan(input) { const owner = await authority(input); const comparison = await runnerDisciplineBefore(input, owner); const result = await runCommand(owner); try { const after = await authority(input); if (after.planFile.sha256 !== owner.planFile.sha256) fail('ENTROROVE_PLAN_CHANGED', 'plan changed during execution'); } catch (cause) { const execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', error: cause.message, process: result, evidence: [] }; const error = new Error(cause.message); error.code = cause.code; error.execution = execution; throw error; }
|
|
99
|
-
owner.disciplineComparison = await runnerDisciplineAfter(comparison, owner);
|
|
100
|
+
owner.disciplineComparison = await runnerDisciplineAfter(comparison, owner, result);
|
|
100
101
|
const sub = await subject(owner, result); const item = evidence(owner, result, sub); const status = !owner.signing && result.status === 'succeeded' ? 'incomplete' : result.status; return { schemaVersion: RUNNER_SCHEMA, status, planSha256: owner.planFile.sha256, approvalSha256: owner.approval.sha256, approvedBy: owner.approval.approval.approvedBy, subject: sub, process: result, evidence: [item], receipt: item.receipt ?? null, ...(owner.signing ? { receiptPublicKey: createPublicKey(owner.signing.privateKey).export({ format: 'der', type: 'spki' }).toString('base64') } : {}), signingBoundary: owner.signing ? 'configured-signature-only' : 'local-evidence-only' };
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -125,7 +126,7 @@ export async function runApprovedPlanObject(input) {
|
|
|
125
126
|
const error = new Error(cause.message); error.code = cause.code;
|
|
126
127
|
error.execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', error: cause.message, process: result, evidence: [], effects: 'unknown' }; throw error;
|
|
127
128
|
}
|
|
128
|
-
owner.disciplineComparison = await runnerDisciplineAfter(comparison, owner);
|
|
129
|
+
owner.disciplineComparison = await runnerDisciplineAfter(comparison, owner, result);
|
|
129
130
|
const sub = await subject(owner, result); const item = evidence(owner, result, sub);
|
|
130
131
|
return { schemaVersion: RUNNER_SCHEMA, status: !owner.signing && result.status === 'succeeded' ? 'incomplete' : result.status,
|
|
131
132
|
planSha256: owner.planFile.sha256, approvalSha256: owner.approval.sha256, approvedBy: owner.approval.approval.approvedBy,
|
|
@@ -12,11 +12,15 @@ export const DISCIPLINE_WRITE_SCHEMA = object({ targetPath: relativePath,
|
|
|
12
12
|
}, ['targetPath', 'kind', 'reason', 'requirementRefs']);
|
|
13
13
|
const approval = object({ interactionId: identifier, interactionDigest: shaSchema, auditId: identifier,
|
|
14
14
|
proposalDigest: shaSchema }, ['interactionId', 'interactionDigest', 'auditId', 'proposalDigest']);
|
|
15
|
-
|
|
15
|
+
const mutablePolicy = object({ schemaVersion: { const: 'entrorove.discipline-policy/1.0' },
|
|
16
16
|
requirementIds: refs, writes: array(DISCIPLINE_WRITE_SCHEMA, 1), approval,
|
|
17
17
|
dataOperations: array(object({ operation: { enum: ['delete', 'migrate', 'rebuild'] }, target: text,
|
|
18
18
|
reason: text, requirementRefs: refs, snapshotArtifactDigest: shaSchema }, ['operation', 'target', 'reason', 'requirementRefs', 'snapshotArtifactDigest'])),
|
|
19
19
|
}, ['schemaVersion', 'requirementIds', 'writes']);
|
|
20
|
+
const readOnlyPolicy = object({ schemaVersion: { const: 'entrorove.discipline-policy/1.0' },
|
|
21
|
+
access: { const: 'read-only' }, requirementIds: refs, writes: { type: 'array', maxItems: 0 },
|
|
22
|
+
}, ['schemaVersion', 'access', 'requirementIds', 'writes']);
|
|
23
|
+
export const DISCIPLINE_POLICY_SCHEMA = { oneOf: [mutablePolicy, readOnlyPolicy] };
|
|
20
24
|
export const DISCIPLINE_DECLARATION_SCHEMA = object({ ...DISCIPLINE_WRITE_SCHEMA.properties,
|
|
21
25
|
usageReviewDigest: shaSchema }, DISCIPLINE_WRITE_SCHEMA.required);
|
|
22
26
|
const validate = new Ajv({ allErrors: true, strict: true }).compile(DISCIPLINE_POLICY_SCHEMA);
|
package/local-mutation-wire.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const PASS_FIELDS = ['schemaVersion', 'passId', 'challengeId', 'challengeDigest'
|
|
|
17
17
|
'snapshotManifestDigest', 'attestationDigest', 'coordinationRequired'];
|
|
18
18
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
19
19
|
const SHA = /^[0-9a-f]{64}$/;
|
|
20
|
+
const localRoleFields = ['verificationMode', 'memberScopeKeyId', 'memberScopeBindingDigest'];
|
|
20
21
|
function exact(value, fields) {
|
|
21
22
|
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
22
23
|
|| Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) {
|
|
@@ -39,13 +40,21 @@ function pinnedAuthority() {
|
|
|
39
40
|
} catch { mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Pinned cloud mutation authority is unavailable or invalid'); }
|
|
40
41
|
}
|
|
41
42
|
function identity(payload) {
|
|
43
|
+
const local = payload.verificationMode === 'member-local';
|
|
44
|
+
if (local ? payload.runnerKeyId !== null || !payload.writeDelegation
|
|
45
|
+
|| !SHA.test(payload.memberScopeKeyId ?? '') || !SHA.test(payload.memberScopeBindingDigest ?? '')
|
|
46
|
+
|| payload.memberScopeKeyId !== payload.writeDelegation.scopeKeyId || payload.memberScopeBindingDigest !== payload.scopeBindingDigest
|
|
47
|
+
: (payload.verificationMode !== undefined && payload.verificationMode !== 'independent') || !SHA.test(payload.runnerKeyId ?? '')
|
|
48
|
+
|| Object.hasOwn(payload, 'memberScopeKeyId') || Object.hasOwn(payload, 'memberScopeBindingDigest')) {
|
|
49
|
+
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation verification roles must preserve their original independent or member-local authority');
|
|
50
|
+
}
|
|
42
51
|
if (Object.hasOwn(payload, 'previousPassId') && (!ID.test(payload.previousPassId) || payload.previousPassId === payload.passId)) {
|
|
43
52
|
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation continuation must retain a distinct original settled predecessor');
|
|
44
53
|
}
|
|
45
54
|
const ids = ['flowId', 'taskId', 'acceptanceReceiptId', 'stepId', 'leaseId', 'planId', 'executionAuthorityId',
|
|
46
55
|
'executionAttemptNonce', 'runnerId', 'repositoryId', 'snapshotId', 'nonce', 'challengeId'];
|
|
47
56
|
const digests = ['taskDigest', 'chainDigest', 'acceptanceDigest', 'routeDigest', 'receiverDigest', 'leaseDigest',
|
|
48
|
-
'planDigest', 'executionPlanSha256', 'executionAuthorityDigest', 'runnerKeyId', 'scopeBindingDigest',
|
|
57
|
+
'planDigest', 'executionPlanSha256', 'executionAuthorityDigest', ...(local ? [] : ['runnerKeyId']), 'scopeBindingDigest',
|
|
49
58
|
'scopeArtifactDigest', 'scopeContractDigest'];
|
|
50
59
|
const delegated = Object.hasOwn(payload, 'writeDelegation');
|
|
51
60
|
const execution = new Set(['executionAuthorityId', 'executionAuthorityDigest', 'executionAttemptNonce']);
|
|
@@ -83,6 +92,9 @@ function authority(value, fields, schema, options) {
|
|
|
83
92
|
|| receipt.payloadDigest !== sha256(bytes) || !verify(null, bytes, key.key, signature)) {
|
|
84
93
|
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority requires a valid pinned cloud signature');
|
|
85
94
|
}
|
|
95
|
+
if (payload.runnerKeyId !== null && payload.runnerKeyId === receipt.keyId) {
|
|
96
|
+
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'The workflow authority key cannot become an independent Validator runner');
|
|
97
|
+
}
|
|
86
98
|
identity(payload); mutationLifetime14(payload, options); return value;
|
|
87
99
|
}
|
|
88
100
|
export function mutationChallenge14(value, options) {
|
|
@@ -91,7 +103,7 @@ export function mutationChallenge14(value, options) {
|
|
|
91
103
|
function authorityFields(value, fields) {
|
|
92
104
|
const original = Object.hasOwn(value ?? {}, 'writeDelegation') ? [...fields.filter(field =>
|
|
93
105
|
!['executionAuthorityId', 'executionAuthorityDigest', 'executionAttemptNonce'].includes(field)), 'writeDelegation'] : fields;
|
|
94
|
-
return [...original, ...['previousPassId'].filter(field => Object.hasOwn(value ?? {}, field))];
|
|
106
|
+
return [...original, ...['previousPassId', ...localRoleFields].filter(field => Object.hasOwn(value ?? {}, field))];
|
|
95
107
|
}
|
|
96
108
|
export function mutationPass14(value, options) {
|
|
97
109
|
const fields = [...authorityFields(value, PASS_FIELDS), ...['disciplinePolicy', 'parallelPaths'].filter(key => Object.hasOwn(value ?? {}, key))];
|
|
@@ -8,7 +8,9 @@ import { readPreservedMutationBinding, loadPreservedMutationSigner } from './loc
|
|
|
8
8
|
import { preservedMutationDelegation, preservedMutationFail, preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs';
|
|
9
9
|
|
|
10
10
|
const identity = authority => Object.fromEntries(['flowId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest',
|
|
11
|
-
'repositoryDigest', 'runnerKeyId', 'runnerId', 'repositoryId', 'receiverDigest'
|
|
11
|
+
'repositoryDigest', 'runnerKeyId', 'runnerId', 'repositoryId', 'receiverDigest',
|
|
12
|
+
...['verificationMode', 'memberScopeKeyId', 'memberScopeBindingDigest'].filter(field => Object.hasOwn(authority, field))]
|
|
13
|
+
.map(field => [field, authority[field]]));
|
|
12
14
|
async function rejectGenericHistory(location) {
|
|
13
15
|
const anchor = process.env.ENTROROVE_TASK_HANDOFF_STATE_DIRECTORY;
|
|
14
16
|
if (!anchor) return;
|
|
@@ -30,9 +30,16 @@ export function preservedMutationDelegation(authority) {
|
|
|
30
30
|
exact(value, ['schemaVersion', 'purpose', 'scopeKeyId', 'mutationKeyId', 'mutationPublicKey',
|
|
31
31
|
'gateEvidenceDigest', 'importGraphDigest', 'protectedFiles', 'targetFiles', 'targetImports']);
|
|
32
32
|
const publicKey = preservedMutationPublicKey(value.mutationPublicKey);
|
|
33
|
+
const local = authority.verificationMode === 'member-local';
|
|
34
|
+
const roleKeys = local ? [value.scopeKeyId, value.mutationKeyId] : [authority.runnerKeyId, value.scopeKeyId, value.mutationKeyId];
|
|
35
|
+
if (local && (authority.runnerKeyId !== null || authority.memberScopeKeyId !== value.scopeKeyId
|
|
36
|
+
|| authority.memberScopeBindingDigest !== authority.scopeBindingDigest)
|
|
37
|
+
|| !local && authority.runnerKeyId === authority.authorityReceipt?.keyId) {
|
|
38
|
+
preservedMutationFail('Preserved mutation cannot promote scope, mutation or workflow signing keys to independent validation');
|
|
39
|
+
}
|
|
33
40
|
if (value.schemaVersion !== 'entrorove.preserved-write-delegation/1.0' || value.purpose !== 'preserved-existing-modify'
|
|
34
41
|
|| ['scopeKeyId', 'mutationKeyId', 'gateEvidenceDigest', 'importGraphDigest'].some(field => !/^[0-9a-f]{64}$/.test(value[field]))
|
|
35
|
-
|| publicKey.keyId !== value.mutationKeyId || new Set(
|
|
42
|
+
|| publicKey.keyId !== value.mutationKeyId || new Set(roleKeys).size !== roleKeys.length
|
|
36
43
|
|| authority.epoch !== 0 || authority.newFilePaths.length !== 0) preservedMutationFail('Preserved mutation role or original epoch is invalid');
|
|
37
44
|
files(value.protectedFiles); files(value.targetFiles);
|
|
38
45
|
if (canonical(value.targetFiles.map(file => file.path)) !== canonical(authority.paths)
|