@entrorove/skill 0.1.2 → 0.1.21

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/AGREEMENT.md CHANGED
@@ -1,33 +1,34 @@
1
- # Entrorove 使用、隐私与广告信息协议
1
+ # entrorove 使用、隐私与广告信息协议
2
2
 
3
- 版本:2026-09-29
3
+ 版本:2026-10-02.1
4
4
 
5
- 本协议是首次使用 Entrorove 技能时的合并告知与使用条款。只有用户明确选择“同意”后,技能请求才会发送。IDE 的“完全访问”权限只是本地权限,不代表同意本协议。
5
+ 本协议是首次使用 entrorove 技能时的合并告知与使用条款。只有用户明确选择“同意”后,技能请求才会发送。IDE 的“完全访问”权限只是本地权限,不代表同意本协议。
6
6
 
7
7
  ## 1. 技能与权限
8
8
 
9
- - Entrorove 会按用户与 IDE 明确提供的任务、上下文和权限辅助分析、修改代码及运行检查。
9
+ - entrorove 会按用户与 IDE 明确提供的任务、上下文和权限辅助分析、修改代码及运行检查。
10
10
  - 高权限操作可能修改或删除文件、运行命令或调用外部服务。用户应检查范围、保留备份,并对有重大后果的操作单独确认。
11
11
  - 本协议不扩大 IDE 已授予的权限,也不替代具体高风险操作所需的单独确认。
12
12
 
13
13
  ## 2. 隐私与数据
14
14
 
15
- - 安装包不会因安装本身自动扫描或上传项目文件。只有在发起技能调用时,才会向 Entrorove API 发送 IDE 明确组装的请求负载。
15
+ - 安装包不会因安装本身自动扫描或上传项目文件。只有在发起技能调用时,才会向 entrorove API 发送 IDE 明确组装的请求负载。
16
16
  - 账号服务会处理登录邮箱、密钥哈希与前缀、加密保存的完整密钥、会话与安全记录、调用时间/数量,以及用户自愿提交的留言。已登录用户可再次复制自己的完整密钥。
17
- - 当前实现不将项目源文件或技能任务负载写入业务数据库,不使用个人信息决定广告展示,不出售个人信息。
17
+ - 用户主动提交并明确同意公开的 IDE 评价会展示 IDE 名称、评分、评价文字和时间;公开列表最多展示最近 30 条。
18
+ - 当前实现不将项目源文件或技能任务负载写入业务数据库,不出售个人信息。推广选择使用密钥的连续调用时长和调用次数判断展示时机,不分析项目内容或建立跨站画像。
18
19
  - 本地同意回执仅保存协议版本、同意结果和时间,不包含邮箱、项目内容或密钥。
19
20
 
20
21
  ## 3. 消息与广告
21
22
 
22
23
  - 在长时间任务的“请勿关闭”提示下,服务可按后台配置追加一条带 HTTPS 链接的消息。
23
24
  - 商业内容以后台配置的分类标签显示为 `【分类】`,不伪装成执行结果、系统警告或独立推荐。
24
- - 推广仅在配置的有效期内按权重随机展示;多条推广的权重共同决定展示概率及选择频率。选择不使用用户画像、项目内容或个人信息。
25
+ - 推广仅在配置的有效期和任务使用时段内展示。后台可设置候选权重与概率范围;服务根据同一密钥的连续调用时长及次数调整概率,不分析任务文本或项目文件。
25
26
  - 广告链接指向第三方时,第三方的内容、隐私与条款由其自行负责。
26
27
 
27
28
  ## 4. 风险与责任限制
28
29
 
29
30
  - 技能按现状提供,不保证所有结果均正确、完整、不中断或适合特定目的。用户应在合并、发布或用于生产前审查修改并运行适当检查。
30
- - 在适用法律允许的最大范围内,Entrorove 不承担因使用或无法使用技能导致的间接、附带、特殊或后果性损失。
31
+ - 在适用法律允许的最大范围内,entrorove 不承担因使用或无法使用技能导致的间接、附带、特殊或后果性损失。
31
32
  - 上述限制不排除法律不允许排除的责任,也不限制欺诈、故意不当行为或适用法律规定的强制性消费者权利。
32
33
 
33
34
  ## 5. 同意、拒绝与更新
@@ -38,14 +39,15 @@
38
39
 
39
40
  ---
40
41
 
41
- # Entrorove Terms, Privacy and Advertising Notice
42
+ # entrorove Terms, Privacy and Advertising Notice
42
43
 
43
- Version: 2026-09-29
44
+ Version: 2026-10-02.1
44
45
 
45
46
  This combined notice applies on first use. A skill request is sent only after an explicit **Accept** choice. Granting an IDE “full access” permission does not accept this agreement.
46
47
 
47
- - **Use and access.** Entrorove acts only on tasks, context and permissions supplied by the user and IDE. Elevated permissions may permit file changes, command execution and external calls. Review scope, keep backups and separately confirm consequential actions.
48
- - **Data.** Installation alone does not scan or upload project files. The account service processes login email, key hashes and prefixes, an encrypted copy of the full key that a signed-in user can copy again, sessions and security records, invocation time/count, and messages submitted voluntarily. Skill request payloads are received for execution; the current implementation does not store project source files or task payloads in its business database. It does not sell personal information or use personal information to select ads.
49
- - **Advertising.** A configured message may appear beneath a long-running-task notice. Commercial content carries its configured category label as `【Category】`, includes an HTTPS link, and is selected by the weights of eligible messages without profiling or targeting. Third-party links are governed by their operators.
50
- - **Risk.** The skill is provided as available without a guarantee that every result is correct, complete, uninterrupted or fit for a particular purpose. Review and test changes before production use. To the maximum extent permitted by applicable law, Entrorove excludes indirect, incidental, special and consequential losses. Nothing excludes non-waivable liability, fraud, wilful misconduct or mandatory consumer rights.
48
+ - **Use and access.** entrorove acts only on tasks, context and permissions supplied by the user and IDE. Elevated permissions may permit file changes, command execution and external calls. Review scope, keep backups and separately confirm consequential actions.
49
+ - **Data.** Installation alone does not scan or upload project files. The account service processes login email, key hashes and prefixes, an encrypted copy of the full key that a signed-in user can copy again, sessions and security records, invocation time/count, and messages submitted voluntarily. Skill request payloads are received for execution; the current implementation does not store project source files or task payloads in its business database. It does not sell personal information. Promotion timing uses consecutive invocation duration and count for the same key, without examining project content or building a cross-site profile.
50
+ - **Public reviews.** An IDE review is published only after the user submits it and explicitly consents to public display; the public list may show the IDE name, rating, review text and timestamp, limited to the latest 30 entries.
51
+ - **Advertising.** A configured message may appear beneath a long-running-task notice. Commercial content carries its configured category label as `【Category】` and includes an HTTPS link. Administrators set active dates, task-duration windows, selection weights and probability bounds. The service adjusts display probability using consecutive invocation duration and count, without inspecting task text or project files. Third-party links are governed by their operators.
52
+ - **Risk.** The skill is provided as available without a guarantee that every result is correct, complete, uninterrupted or fit for a particular purpose. Review and test changes before production use. To the maximum extent permitted by applicable law, entrorove excludes indirect, incidental, special and consequential losses. Nothing excludes non-waivable liability, fraud, wilful misconduct or mandatory consumer rights.
51
53
  - **Choice.** Declining prevents the skill request from being sent. Material changes require a new explicit acceptance. Mandatory local laws may provide additional rights. Qualified counsel should review this notice before production release for the actual operating and user jurisdictions.
package/CHANGELOG.md ADDED
@@ -0,0 +1,101 @@
1
+ # Changelog
2
+
3
+ ## 0.1.21 — 2026-10-04
4
+
5
+ - 更新了本地执行闸门与预算。
6
+ - Updated local execution gates and budgets.
7
+
8
+ ## 0.1.20 — 2026-10-04
9
+
10
+ - 更新了本地状态并发锁、本地执行闸门与预算、执行与链路台账。
11
+ - Updated local state concurrency lock, local execution gates and budgets, execution and chain ledger.
12
+
13
+ ## 0.1.19 — 2026-10-04
14
+
15
+ - 更新了安装包配置、自动版本与更新日志、本地状态并发锁、本地执行闸门与预算、预算续期确认、本地任务路由。
16
+ - Updated package configuration, automatic versioning and changelog, local state concurrency lock, local execution gates and budgets, budget renewal confirmation, local task routing.
17
+
18
+ ## 0.1.18 — 2026-10-04
19
+
20
+ - 更新了冻结计划执行器。
21
+ - Updated frozen plan runner.
22
+
23
+ ## 0.1.17 — 2026-10-04
24
+
25
+ - 更新了本地执行命令、冻结计划执行器。
26
+ - Updated local execution command, frozen plan runner.
27
+
28
+ ## 0.1.16 — 2026-10-04
29
+
30
+ - 更新了远端恢复客户端。
31
+ - Updated remote recovery client.
32
+
33
+ ## 0.1.15 — 2026-10-04
34
+
35
+ - 更新了IDE 工作流程说明。
36
+ - Updated IDE workflow instructions.
37
+
38
+ ## 0.1.14 — 2026-10-02
39
+
40
+ - 更新了本地执行闸门与预算。
41
+ - Updated local execution gates and budgets.
42
+
43
+ ## 0.1.13 — 2026-10-02
44
+
45
+ - 更新了执行与链路台账、预算续期确认、本地任务路由。
46
+ - Updated execution and chain ledger, budget renewal confirmation, local task routing.
47
+
48
+ ## 0.1.12 — 2026-10-02
49
+
50
+ - 更新了远端恢复客户端。
51
+ - Updated remote recovery client.
52
+
53
+ ## 0.1.11 — 2026-10-02
54
+
55
+ - 更新了本地执行闸门与预算。
56
+ - Updated local execution gates and budgets.
57
+
58
+ ## 0.1.10 — 2026-10-02
59
+
60
+ - 更新了本地执行闸门与预算。
61
+ - Updated local execution gates and budgets.
62
+
63
+ ## 0.1.9 — 2026-10-02
64
+
65
+ - 更新了自动版本与更新日志、本地执行闸门与预算、证据与回执验签、执行与链路台账、冻结计划执行器。
66
+ - Updated automatic versioning and changelog, local execution gates and budgets, evidence and receipt verification, execution and chain ledger, frozen plan runner.
67
+
68
+ ## 0.1.8 — 2026-10-02
69
+
70
+ - 恢复本地执行层与质量闭环:快照隔离、签名写入、预算、协作、验证回执、公式和任务路由。
71
+ - Restored the local execution and quality workflow: snapshots, signed writes, budgets, coordination, receipts, formulas, and task routing.
72
+
73
+ ## 0.1.7 — 2026-10-02
74
+
75
+ - 恢复统一技能全集、本地执行层与安全复制流程。
76
+ - Restored the complete unified skill set, local execution layer, and secure copy flow.
77
+
78
+ ## 0.1.6 — 2026-09-29
79
+
80
+ - 配置密钥前检查凭据目录,拒绝符号链接目录,避免密钥写入非预期位置。
81
+ - Reject symlinked credential directories before writing a key to prevent storage at an unintended location.
82
+
83
+ ## 0.1.5 — 2026-09-29
84
+
85
+ - 发布前自动检测安装包、技能说明与清单变更,递增版本并同步更新日志;凭据继续跨升级留存。
86
+ - Automatically detect package, skill instruction, and manifest changes before publishing, increment the version, and update release notes while retaining credentials across upgrades.
87
+
88
+ ## 0.1.4 — 2026-09-29
89
+
90
+ - 品牌展示统一为小写 entrorove,保留原有密钥存储路径以确保升级不丢失凭据。
91
+ - Standardized the displayed brand as lowercase entrorove while preserving existing credential storage paths across upgrades.
92
+
93
+ ## 0.1.3 — 2026-09-29
94
+
95
+ - 安装后密钥持久留存;升级时只保留一个受管技能并阻止旧版覆盖,同时明确说明按连续使用时长与调用次数调整推广概率。
96
+ - Keep the key across upgrades, preserve one managed skill and reject downgrades, and disclose usage-based promotion timing.
97
+
98
+ ## 0.1.2 — 2026-09-29
99
+
100
+ - 已加入统一技能安装、持久化账号密钥和首次使用确认。
101
+ - Added the unified skill installer, persistent account key, and explicit first-use agreement.
package/SKILL.md CHANGED
@@ -5,19 +5,19 @@ description: An engineering skill that understands a task, chooses a fitting wor
5
5
 
6
6
  # entrorove · 熵巡
7
7
 
8
- Version: 0.1.2.
8
+ Version: 0.1.21.
9
9
 
10
10
  Read the user's goal and repository instructions first. Preserve the original task through planning, execution and verification. Use the workflow only where it helps; a small, low-risk edit can proceed directly. Never claim a reported step is a verified result.
11
11
 
12
- Install or update the managed copy with `npx @entrorove/skill install`. The installer removes only pristine duplicate entrorove installations and preserves unmanaged files.
12
+ Install or update the managed copy with `npx @entrorove/skill install`. The installer keeps one global managed entrorove skill, replaces its older pristine version, removes only pristine duplicate installations, and preserves hand-edited files. An older package cannot overwrite a newer managed version.
13
13
 
14
- Register or sign in by email at https://entrorove.com. Enter the account key with `npx @entrorove/skill configure` and `{"token":"..."}` on standard input. Never place it in arguments, URLs, project files or logs. Entrorove accounts and keys are independent of other services.
14
+ 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.
15
15
 
16
- On first use, the CLI returns a Confirm Protocol-compatible agreement interaction. Render it with the IDE's native confirmation UI, or show its `chatFallback` text when no adapter exists. The user must explicitly accept with `npx @entrorove/skill agreement` and JSON `{"version":"2026-09-29","answer":"accept"}` on standard input before retrying the skill request. A full-access IDE permission never counts as agreement acceptance. The combined terms, privacy and advertising notice is installed as `AGREEMENT.md`.
16
+ On first use or after a material agreement revision, the CLI returns a Confirm Protocol-compatible agreement interaction. Render it with the IDE's native confirmation UI, or show its `chatFallback` text when no adapter exists. The user must explicitly accept with `npx @entrorove/skill agreement` and JSON `{"version":"2026-10-02.1","answer":"accept"}` on standard input before retrying the skill request. A full-access IDE permission never counts as agreement acceptance. The combined terms, privacy and advertising notice is installed as `AGREEMENT.md`.
17
17
 
18
18
  ## Work with a task
19
19
 
20
- Send JSON on standard input to `npx @entrorove/skill invoke`. Start with `operation: "start"` and an `input.task` containing the goal, work type, risk, difficulty and observed scope facts. Work types are `implementation`, `documentation`, `calculation`, `merge`, `review` and `decision`. Assess facts from the real task and repository; do not invent certainty. Entrorove chooses the workflow. A small task may return `decision: "direct"`, meaning the IDE should do the work without another service call.
20
+ Send JSON on standard input to `npx @entrorove/skill invoke`. Call `operation: "capabilities"` with `{}` first when an adapter needs the live operation and schema list. Adapters that expose the four-step task route can call `task-describe`, `message-route`, `message-accept` and `task-resume` in order; `start` remains the one-call workflow equivalent. Then start with `operation: "start"` and an `input.task` containing the goal, work type, risk, difficulty and observed scope facts. Work types are `implementation`, `documentation`, `calculation`, `merge`, `review`, `decision` and `diagnosis`. Assess facts from the real task and repository; do not invent certainty. entrorove chooses the workflow. A small task may return `decision: "direct"`, meaning the IDE should do the work without another service call. The public envelope is `entrorove.skill.request/2.0`; `requestId` is the SHA-256 digest of `operation + canonical(input)`. Version 1.0 remains accepted for older callers.
21
21
 
22
22
  ```json
23
23
  {"operation":"start","input":{"task":{"goal":"Update the checkout flow and verify it","workType":"implementation","risk":"medium","difficulty":"medium","crossModule":true,"parallel":false,"unclear":false,"architectureContract":false}}}
@@ -25,8 +25,12 @@ Send JSON on standard input to `npx @entrorove/skill invoke`. Start with `operat
25
25
 
26
26
  For a returned `flowId`, continue with `{"operation":"continue","input":{"flowId":"<returned ID>","operation":"<next operation>","payload":{}}}`. Fill `payload` with the next operation's required input. Keep the same task and evidence in the IDE. For later work, `input.task.phase` may be `plan`, `coordinate`, `architecture`, `verify`, `merge` or `decision`; it remains the same skill entry point. Never treat an unknown request outcome as permission to retry a paid or mutating action; query the original request ID when the workflow supports it.
27
27
 
28
+ The v2 progress sequence is durable and ordered: the first runtime step reports `intake`, then the applicability `gate` is recorded and verified, then the selected contract or plan steps run. Every step can be `pending`, `running`, `reported`, `verified`, `blocked` or `failed`; only a completed verdict with trusted evidence is green.
29
+
28
30
  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.
29
31
 
30
- The live API and install package must be deployed and published before this command works outside the local checkout.
32
+ 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. A v2 flow has its own `flowId` and progresses through `pending`, `running`, `reported`, `verified`, `blocked` or `failed`; a reported result is not a verified result.
33
+
34
+ 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. 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.
31
35
 
32
- When the user gives an actual evaluation after using the skill, offer to publish that feedback. Only after they explicitly agree to public display, send their own words, IDE name and 1–5 rating as JSON on standard input to `npx @entrorove/skill review` with `publicConsent: true`. Never invent, rewrite or infer a review from task completion. The website reads accepted reviews directly from Entrorove's independent database.
36
+ When the user gives an actual evaluation after using the skill, offer to publish that feedback. Only after they explicitly agree to public display, send their own words, IDE name and 1–5 rating as JSON on standard input to `npx @entrorove/skill review` with `publicConsent: true`. Never invent, rewrite or infer a review from task completion. The website reads accepted reviews directly from entrorove's independent database.
package/cli.mjs CHANGED
@@ -5,24 +5,25 @@ import { promises as fs, realpathSync } from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
-
8
+ import { compareVersions } from './version.mjs';
9
+ import { runLocal } from './local-cli.mjs';
10
+ import { apiUrl, buildInvocation, invokeRemote, queryFlows, queryStatus } from './remote-client.mjs';
9
11
  const packageDir = path.dirname(fileURLToPath(import.meta.url));
10
- const apiUrl = 'https://entrorove.com/api/skill/invoke';
11
12
  const reviewUrl = 'https://entrorove.com/api/ide-reviews';
12
13
  const markerName = '.entrorove-managed.json';
13
14
  const installFiles = ['SKILL.md', 'AGREEMENT.md', 'manifest.json'];
14
15
  const markerSchema = 'entrorove.managed-install/1.0';
15
16
  const credentialSchema = 'entrorove.credential/1.0';
16
17
  const agreementSchema = 'entrorove.agreement-acceptance/1.0';
17
- const invocationTimeoutMs = 135_000;
18
18
  const installLockWaitMs = 10_000;
19
19
  const installLockStaleMs = 120_000;
20
- export const agreementVersion = '2026-09-29';
20
+ export const agreementVersion = '2026-10-02.1';
21
21
 
22
22
  function sha256(content) {
23
23
  return createHash('sha256').update(content).digest('hex');
24
24
  }
25
25
 
26
+
26
27
  async function readJson(file) {
27
28
  const value = JSON.parse(await fs.readFile(file, 'utf8'));
28
29
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -37,7 +38,7 @@ async function sourceBundle() {
37
38
  if (manifest.schemaVersion !== 'entrorove.skill-manifest/1.0'
38
39
  || manifest.name !== 'entrorove'
39
40
  || manifest.endpoint !== 'https://entrorove.com'
40
- || manifest.requestSchemaVersion !== 'entrorove.skill.request/1.0'
41
+ || !['entrorove.skill.request/1.0', 'entrorove.skill.request/2.0'].includes(manifest.requestSchemaVersion)
41
42
  || !/^\d+\.\d+\.\d+$/.test(manifest.version)
42
43
  || packageMetadata.name !== '@entrorove/skill'
43
44
  || packageMetadata.version !== manifest.version) {
@@ -48,7 +49,7 @@ async function sourceBundle() {
48
49
  content.set(name, await fs.readFile(path.join(packageDir, name)));
49
50
  }
50
51
  if (!content.get('SKILL.md').toString('utf8').includes(`Version: ${manifest.version}.`)) {
51
- throw new Error('Entrorove skill instructions have a mismatched version');
52
+ throw new Error('entrorove skill instructions have a mismatched version');
52
53
  }
53
54
  const hashes = Object.fromEntries(installFiles.map((name) => [name, sha256(content.get(name))]));
54
55
  return { manifest, content, hashes };
@@ -79,7 +80,7 @@ export function agreementInteraction() {
79
80
  schemaVersion: 'confirm.interaction/1.0',
80
81
  requestId: `entrorove-agreement-${agreementVersion}`,
81
82
  type: 'confirm',
82
- question: `首次使用前,请阅读 Entrorove 使用、隐私与广告信息协议(${agreementVersion})。是否同意?`,
83
+ question: `首次使用前,请阅读 entrorove 使用、隐私与广告信息协议(${agreementVersion})。是否同意?`,
83
84
  options: [
84
85
  { id: 'accept', label: '同意', hint: '记录本地回执并允许后续技能调用' },
85
86
  { id: 'decline', label: '不同意', hint: '不发送技能请求' },
@@ -104,7 +105,7 @@ function agreementRequired(home) {
104
105
  agreement,
105
106
  interaction: agreementInteraction(),
106
107
  chatFallback: [
107
- `Entrorove 首次使用确认(${agreementVersion})`,
108
+ `entrorove 首次使用确认(${agreementVersion})`,
108
109
  `请先阅读:${agreement}`,
109
110
  '1. accept — 同意并继续',
110
111
  '2. decline — 不同意,不发送请求',
@@ -168,7 +169,7 @@ async function removeStaleInstallLock(lock) {
168
169
  const state = await statOrNull(lock);
169
170
  if (!state) return true;
170
171
  if (!state.isDirectory() || state.isSymbolicLink()) {
171
- throw new Error('Entrorove install lock is not a directory');
172
+ throw new Error('entrorove install lock is not a directory');
172
173
  }
173
174
  if (Date.now() - state.mtimeMs < installLockStaleMs) return false;
174
175
  let owner;
@@ -195,7 +196,7 @@ async function removeStaleInstallLock(lock) {
195
196
  const moved = await fs.lstat(quarantine);
196
197
  if (moved.ino !== state.ino || moved.dev !== state.dev) {
197
198
  await fs.rename(quarantine, lock);
198
- throw new Error('Entrorove install lock changed during stale recovery');
199
+ throw new Error('entrorove install lock changed during stale recovery');
199
200
  }
200
201
  await fs.rm(quarantine, { recursive: true });
201
202
  return true;
@@ -211,7 +212,7 @@ async function withInstallLock(parent, action) {
211
212
  } catch (error) {
212
213
  if (error.code !== 'EEXIST') throw error;
213
214
  if (await removeStaleInstallLock(lock)) continue;
214
- if (Date.now() >= deadline) throw new Error('Entrorove install is busy');
215
+ if (Date.now() >= deadline) throw new Error('entrorove install is busy');
215
216
  await new Promise(resolve => setTimeout(resolve, 100));
216
217
  }
217
218
  }
@@ -229,6 +230,9 @@ async function updateGlobalInstall(target, bundle) {
229
230
  await fs.mkdir(parent, { recursive: true, mode: 0o700 });
230
231
  const previous = await managedState(target);
231
232
  if (previous === false) throw new Error('Global entrorove skill is not pristine and managed; refusing overwrite');
233
+ if (previous && compareVersions(previous.version, bundle.manifest.version) > 0) {
234
+ throw new Error('A newer managed entrorove skill is installed; use the latest package');
235
+ }
232
236
  if (previous?.version === bundle.manifest.version
233
237
  && installFiles.every((name) => previous.files[name] === bundle.hashes[name])) return false;
234
238
  const stage = path.join(parent, `.entrorove-stage-${randomUUID()}`);
@@ -314,14 +318,20 @@ async function stdinJson() {
314
318
  return value;
315
319
  }
316
320
 
321
+ async function preparePrivateDirectory(parent) {
322
+ await fs.mkdir(parent, { recursive: true, mode: 0o700 });
323
+ const state = await fs.lstat(parent);
324
+ if (!state.isDirectory() || state.isSymbolicLink()) throw new Error('Credential directory must be a real directory');
325
+ if (process.platform !== 'win32') await fs.chmod(parent, 0o700);
326
+ }
327
+
317
328
  async function writeCredential(token, home) {
318
- if (typeof token !== 'string' || !/^ent_[A-Za-z0-9_-]{16,256}$/.test(token)) {
329
+ if (typeof token !== 'string' || !/^ent_[A-Za-z0-9_-]{43}$/.test(token)) {
319
330
  throw new Error('Invalid entrorove key');
320
331
  }
321
332
  const file = credentialPath(home);
322
333
  const parent = path.dirname(file);
323
- await fs.mkdir(parent, { recursive: true, mode: 0o700 });
324
- if (process.platform !== 'win32') await fs.chmod(parent, 0o700);
334
+ await preparePrivateDirectory(parent);
325
335
  const existing = await statOrNull(file);
326
336
  if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw new Error('Credential path is not a regular file');
327
337
  const stage = path.join(parent, `.credential-${randomUUID()}.tmp`);
@@ -343,7 +353,7 @@ async function readCredential(home) {
343
353
  if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) throw new Error('Credential permissions must be 0600');
344
354
  const data = await readJson(file);
345
355
  if (data.schemaVersion !== credentialSchema || typeof data.token !== 'string'
346
- || !/^ent_[A-Za-z0-9_-]{16,256}$/.test(data.token)) throw new Error('Invalid credential file');
356
+ || !/^ent_[A-Za-z0-9_-]{43}$/.test(data.token)) throw new Error('Invalid credential file');
347
357
  return data.token;
348
358
  }
349
359
 
@@ -369,8 +379,7 @@ async function writeAgreementAcceptance(data, home) {
369
379
  if (data.answer === 'decline') return false;
370
380
  const file = agreementPath(home);
371
381
  const parent = path.dirname(file);
372
- await fs.mkdir(parent, { recursive: true, mode: 0o700 });
373
- if (process.platform !== 'win32') await fs.chmod(parent, 0o700);
382
+ await preparePrivateDirectory(parent);
374
383
  const existing = await statOrNull(file);
375
384
  if (existing && (!existing.isFile() || existing.isSymbolicLink())) {
376
385
  throw new Error('Agreement path is not a regular file');
@@ -387,43 +396,17 @@ async function writeAgreementAcceptance(data, home) {
387
396
  return true;
388
397
  }
389
398
 
390
- export function buildInvocation(operation, input) {
391
- if (operation !== 'start' && operation !== 'continue') throw new Error('Invalid entrorove operation');
392
- if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Input must be a JSON object');
393
- const requestId = randomUUID();
394
- return { url: apiUrl, requestId, body: { schemaVersion: 'entrorove.skill.request/1.0', requestId, operation, input } };
399
+ export { buildInvocation };
400
+
401
+ function remoteDependencies() {
402
+ return {
403
+ install, agreementAccepted, agreementRequired, readCredential, home: os.homedir(),
404
+ apiOrigin: () => new URL(apiUrl).origin,
405
+ };
395
406
  }
396
407
 
397
408
  async function invoke(data) {
398
- await install();
399
- if (!await agreementAccepted(os.homedir())) {
400
- process.stdout.write(JSON.stringify(agreementRequired(os.homedir())) + '\n');
401
- process.exitCode = 2;
402
- return;
403
- }
404
- const call = buildInvocation(data.operation, data.input);
405
- const token = await readCredential(os.homedir());
406
- let response;
407
- try {
408
- response = await fetch(call.url, {
409
- method: 'POST', redirect: 'error', signal: AbortSignal.timeout(invocationTimeoutMs),
410
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
411
- body: JSON.stringify(call.body),
412
- });
413
- } catch {
414
- throw new Error(`Invocation outcome is unknown; requestId=${call.requestId}. Do not automatically resend this request.`);
415
- }
416
- if (!response.ok) throw new Error(`entrorove invocation failed: HTTP ${response.status}; requestId=${call.requestId}`);
417
- let result;
418
- try {
419
- result = await response.json();
420
- } catch {
421
- throw new Error(`Invocation outcome is unknown; requestId=${call.requestId}. Do not automatically resend this request.`);
422
- }
423
- if (result?.ok !== true || result.output?.requestId !== call.requestId) {
424
- throw new Error(`Invalid entrorove response; requestId=${call.requestId}`);
425
- }
426
- process.stdout.write(JSON.stringify(result) + '\n');
409
+ return invokeRemote(data, remoteDependencies());
427
410
  }
428
411
 
429
412
  export function buildReview(data) {
@@ -477,11 +460,28 @@ async function main() {
477
460
  await invoke(await stdinJson());
478
461
  } else if (command === 'review' && args.length === 0) {
479
462
  await submitReview(await stdinJson());
463
+ } else if (command === 'status' && args.length === 1) {
464
+ await install();
465
+ if (!await agreementAccepted(os.homedir())) {
466
+ process.stdout.write(JSON.stringify(agreementRequired(os.homedir())) + '\n');
467
+ process.exitCode = 2;
468
+ return;
469
+ }
470
+ await queryStatus(args[0], remoteDependencies());
471
+ } else if (command === 'flows' && args.length === 0) {
472
+ await install();
473
+ if (!await agreementAccepted(os.homedir())) {
474
+ process.stdout.write(JSON.stringify(agreementRequired(os.homedir())) + '\n');
475
+ process.exitCode = 2;
476
+ return;
477
+ }
478
+ await queryFlows(remoteDependencies());
479
+ } else if (command === 'local') {
480
+ await runLocal(args);
480
481
  } else {
481
- throw new Error('Usage: entrorove install | configure | agreement | invoke | review (JSON on stdin)');
482
+ throw new Error('Usage: entrorove install | configure | agreement | invoke | status <requestId> | flows | review | local (JSON on stdin)');
482
483
  }
483
484
  }
484
-
485
485
  if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
486
486
  main().catch((error) => {
487
487
  process.stderr.write(`${error.message}\n`);
@@ -0,0 +1,81 @@
1
+ import { createHash, createPrivateKey, createPublicKey, randomUUID, sign } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { promises as fs, constants } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { performance } from 'node:perf_hooks';
7
+
8
+ export const RUNNER_SCHEMA = 'entrorove.validator-local-runner/1.0';
9
+ export const PLAN_SCHEMA = 'validator.execution-plan/1.0';
10
+ export const APPROVAL_SCHEMA = 'validator.runner-approval/1.0';
11
+ export const SIGNER_SCHEMA = 'validator.runner-signer/1.0';
12
+ export const RECEIPT_SCHEMA = 'validator.execution-receipt/1.0';
13
+ export const RUNNER_LIMITS = Object.freeze({ maxFiles: 512, maxFileBytes: 16 * 1024 * 1024, maxControlBytes: 1024 * 1024, maxExecutableBytes: 256 * 1024 * 1024, maxTimeoutMs: 300_000, maxOutputBytes: 1024 * 1024, maxApprovalLifetimeMs: 86_400_000, maxReceiptLifetimeMs: 600_000 });
14
+ const SHA256 = /^[0-9a-f]{64}$/; const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const ENV = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+ function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
16
+ function digest(value) { return createHash('sha256').update(value).digest('hex'); }
17
+ function canonical(value) { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; return JSON.stringify(value); }
18
+ function safeRelative(value, label = 'path') { if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail('ENTROROVE_PATH_UNSAFE', `${label} must be a normalized relative path`); return value; }
19
+ function inside(root, target, label) { const relative = path.relative(root, target); if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) return; fail('ENTROROVE_PATH_ESCAPE', `${label} escapes repositoryRoot`); }
20
+ async function rootOf(value) { if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute'); const stat = await fs.lstat(value); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory'); return fs.realpath(value); }
21
+ async function regular(file, max, privateFile = false) { const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > max) fail('ENTROROVE_FILE_INVALID', 'control file must be a bounded regular file'); if (privateFile && process.platform !== 'win32' && ((typeof process.getuid === 'function' && stat.uid !== process.getuid()) || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1)) fail('ENTROROVE_CONTROL_PERMISSIONS', 'control file must be owned and mode 0600'); return { bytes: await fs.readFile(file), sha256: digest(await fs.readFile(file)) }; }
22
+ async function projectFile(root, relative) { const safe = safeRelative(relative); const target = path.resolve(root, ...safe.split('/')); inside(root, target, safe); const stat = await fs.lstat(target); if (!stat.isFile() || stat.isSymbolicLink() || await fs.realpath(target) !== target) fail('ENTROROVE_FILE_INVALID', `${safe} must be a regular file`); return { path: safe, target, stat, ...(await regular(target, RUNNER_LIMITS.maxFileBytes)) }; }
23
+ async function externalFile(root, value, label) { if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_CONTROL_PATH', `${label} must be an absolute path`); const entry = await fs.lstat(value); if (!entry.isFile() || entry.isSymbolicLink()) fail('ENTROROVE_CONTROL_FILE', `${label} must be a regular non-symlink file`); const target = await fs.realpath(value); const relative = path.relative(root, target); if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) fail('ENTROROVE_PATH_ESCAPE', `${label} must be outside repositoryRoot`); return { path: target, ...(await regular(target, RUNNER_LIMITS.maxControlBytes, true)) }; }
24
+ function exactObject(value, fields, label) { if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join('|') !== [...fields].sort().join('|')) fail('ENTROROVE_PLAN_FIELDS', `${label} has invalid fields`); return value; }
25
+ function boundedInt(value, max, label) { if (!Number.isSafeInteger(value) || value <= 0 || value > max) fail('ENTROROVE_LIMIT_INVALID', `${label} must be a positive bounded integer`); }
26
+ function planArtifact(files) { return digest(canonical(files)); }
27
+
28
+ function validatePlan(plan) {
29
+ exactObject(plan, ['schemaVersion', 'frozen', 'planId', 'validationRunId', 'memberId', 'chainId', 'artifactSha256', 'files', 'tests', 'policy', 'goldenBaseline', 'contracts'], 'execution plan');
30
+ if (plan.schemaVersion !== PLAN_SCHEMA || plan.frozen !== true || !ID.test(plan.planId) || !ID.test(plan.validationRunId) || !ID.test(plan.memberId) || !ID.test(plan.chainId) || !SHA256.test(plan.artifactSha256) || !Array.isArray(plan.files) || plan.files.length === 0 || plan.files.length > RUNNER_LIMITS.maxFiles) fail('ENTROROVE_PLAN_INVALID', 'plan must be frozen and bounded');
31
+ for (let i = 0; i < plan.files.length; i += 1) { const file = exactObject(plan.files[i], ['path', 'sha256'], 'manifest entry'); safeRelative(file.path); if (!SHA256.test(file.sha256) || (i > 0 && plan.files[i - 1].path >= file.path)) fail('ENTROROVE_PLAN_MANIFEST', 'manifest paths must be sorted and unique'); }
32
+ if (planArtifact(plan.files) !== plan.artifactSha256) fail('ENTROROVE_PLAN_MANIFEST', 'artifact manifest digest mismatch');
33
+ const policy = exactObject(plan.policy, ['executable', 'executableSha256', 'args', 'environment', 'timeoutMs', 'maxOutputBytes', 'requiredExitCode'], 'execution policy');
34
+ if (typeof policy.executable !== 'string' || !path.isAbsolute(policy.executable) || !SHA256.test(policy.executableSha256) || !Array.isArray(policy.args) || policy.args.length > 128 || policy.args.some(value => typeof value !== 'string' || value.includes('\0') || value.length > 4096)) fail('ENTROROVE_POLICY_INVALID', 'executable and args are invalid');
35
+ if (!policy.environment || typeof policy.environment !== 'object' || Array.isArray(policy.environment) || Object.keys(policy.environment).length > 64 || Object.entries(policy.environment).some(([key, value]) => !ENV.test(key) || typeof value !== 'string' || value.includes('\0') || value.length > 16_384)) fail('ENTROROVE_POLICY_ENVIRONMENT', 'environment must be explicit bounded strings');
36
+ boundedInt(policy.timeoutMs, RUNNER_LIMITS.maxTimeoutMs, 'timeoutMs'); boundedInt(policy.maxOutputBytes, RUNNER_LIMITS.maxOutputBytes, 'maxOutputBytes'); if (policy.requiredExitCode !== 0) fail('ENTROROVE_POLICY_EXIT', 'requiredExitCode must be zero');
37
+ if (!Array.isArray(plan.tests) || plan.tests.length === 0 || plan.tests.length > RUNNER_LIMITS.maxFiles) fail('ENTROROVE_PLAN_TESTS', 'tests must be non-empty');
38
+ for (const test of plan.tests) { exactObject(test, ['testId', 'path'], 'test'); if (!ID.test(test.testId) || !plan.files.some(file => file.path === test.path) || !policy.args.includes(test.path)) fail('ENTROROVE_PLAN_TESTS', 'each test must be in the manifest and explicit in args'); }
39
+ if (plan.contracts?.archguard?.driftStatus === 'red') fail('ENTROROVE_ARCHGUARD_BLOCKED', 'ArchGuard red blocks execution'); return plan;
40
+ }
41
+
42
+ async function validateApproval(root, input, planSha256) {
43
+ const file = await externalFile(root, input.approvalPath, 'approvalPath'); let approval; try { approval = JSON.parse(file.bytes.toString('utf8')); } catch (cause) { fail('ENTROROVE_APPROVAL_INVALID', `approval JSON is invalid: ${cause.message}`); }
44
+ exactObject(approval, ['schemaVersion', 'repositoryRoot', 'planSha256', 'approvedBy', 'approvedAt', 'expiresAt'], 'runner approval'); const approvedAt = Date.parse(approval.approvedAt); const expiresAt = Date.parse(approval.expiresAt); let approvedRoot = null; try { approvedRoot = await fs.realpath(approval.repositoryRoot); } catch {}
45
+ if (approval.schemaVersion !== APPROVAL_SCHEMA || approvedRoot !== root || approval.planSha256 !== planSha256 || !ID.test(approval.approvedBy) || !Number.isFinite(approvedAt) || !Number.isFinite(expiresAt) || approvedAt > Date.now() || expiresAt <= Date.now() || expiresAt <= approvedAt || expiresAt - approvedAt > RUNNER_LIMITS.maxApprovalLifetimeMs) fail('ENTROROVE_APPROVAL_INVALID', 'approval is expired or does not match the frozen plan'); return { ...file, approval };
46
+ }
47
+ async function signer(root, input, policy) {
48
+ if (input.signerConfigPath === null) return null; const file = await externalFile(root, input.signerConfigPath, 'signerConfigPath'); let config; try { config = JSON.parse(file.bytes.toString('utf8')); } catch (cause) { fail('ENTROROVE_SIGNER_INVALID', `signer JSON is invalid: ${cause.message}`); }
49
+ exactObject(config, ['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs'], 'runner signer'); if (config.schemaVersion !== SIGNER_SCHEMA || !SHA256.test(config.keyId)) fail('ENTROROVE_SIGNER_INVALID', 'signer config is invalid'); boundedInt(config.receiptTtlMs, RUNNER_LIMITS.maxReceiptLifetimeMs, 'receiptTtlMs'); const privateFile = await externalFile(root, config.privateKeyPath, 'privateKeyPath');
50
+ let privateKey; try { privateKey = createPrivateKey(privateFile.bytes); if (privateKey.asymmetricKeyType !== 'ed25519' || digest(createPublicKey(privateKey).export({ format: 'der', type: 'spki' })) !== config.keyId) fail('ENTROROVE_SIGNER_INVALID', 'signer public key fingerprint mismatch'); } finally { privateFile.bytes.fill(0); }
51
+ if (JSON.stringify([policy.args, policy.environment]).includes(privateFile.path) || JSON.stringify([policy.args, policy.environment]).includes(file.path)) fail('ENTROROVE_SIGNER_SCOPE', 'signer path may not be passed to child'); return { config, file, privateKey };
52
+ }
53
+
54
+ async function authority(input) {
55
+ if (process.platform === 'win32' || typeof process.getuid !== 'function') fail('ENTROROVE_POSIX_REQUIRED', 'approved plan runner requires POSIX');
56
+ exactObject(input, ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath'], 'runner input'); const root = await rootOf(input.repositoryRoot); const planFile = await projectFile(root, input.planPath); if (planFile.bytes.length > RUNNER_LIMITS.maxControlBytes) fail('ENTROROVE_PLAN_SIZE', 'plan exceeds control limit');
57
+ let plan; try { plan = validatePlan(JSON.parse(planFile.bytes.toString('utf8'))); } catch (cause) { throw cause; } const approval = await validateApproval(root, input, planFile.sha256); const signing = await signer(root, input, plan.policy); const executable = await regular(plan.policy.executable, RUNNER_LIMITS.maxExecutableBytes); if (executable.sha256 !== plan.policy.executableSha256) fail('ENTROROVE_EXECUTABLE_HASH', 'executable SHA-256 mismatch');
58
+ for (const file of plan.files) { const actual = await projectFile(root, file.path); if (actual.sha256 !== file.sha256) fail('ENTROROVE_PLAN_FILE_HASH', `${file.path} changed before execution`); }
59
+ return { root, plan, planFile, approval, signing };
60
+ }
61
+
62
+ function runCommand(authority) {
63
+ return new Promise((resolve, reject) => {
64
+ const started = performance.now(); const { policy } = authority.plan; const child = spawn(policy.executable, policy.args, { cwd: authority.root, env: { ...policy.environment }, shell: false, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
65
+ const stdout = []; const stderr = []; let bytes = 0; let dropped = 0; let timedOut = false; let terminating = false; let timer; const stdoutHash = createHash('sha256'); const stderrHash = createHash('sha256');
66
+ const terminate = () => { if (terminating) return; terminating = true; try { process.kill(-child.pid, 'SIGTERM'); } catch {} setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 250).unref(); };
67
+ const consume = (target, hash, chunk) => { const data = Buffer.from(chunk); hash.update(data); const keep = Math.max(0, Math.min(data.length, policy.maxOutputBytes - bytes)); if (keep) target.push(data.subarray(0, keep)); bytes += keep; dropped += data.length - keep; if (dropped) terminate(); };
68
+ child.stdout.on('data', chunk => consume(stdout, stdoutHash, chunk)); child.stderr.on('data', chunk => consume(stderr, stderrHash, chunk)); child.once('error', reject); timer = setTimeout(() => { timedOut = true; terminate(); }, policy.timeoutMs);
69
+ child.once('close', (exitCode, signal) => { clearTimeout(timer); resolve({ schemaVersion: RUNNER_SCHEMA, status: exitCode === 0 && signal === null && !timedOut && !dropped ? 'succeeded' : 'failed', exitCode, signal, timedOut, outputLimitExceeded: dropped > 0, durationMs: Math.max(0, Math.round(performance.now() - started)), stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), stdoutSha256: stdoutHash.digest('hex'), stderrSha256: stderrHash.digest('hex'), capturedBytes: bytes, droppedBytes: dropped }); });
70
+ });
71
+ }
72
+ function subject(authority, result) { return { schemaVersion: 'validator.validation-subject/1.0', artifactSha256: authority.plan.artifactSha256, memberId: authority.plan.memberId, chainId: authority.plan.chainId, executedAt: new Date().toISOString(), files: authority.plan.files, validationRunId: authority.plan.validationRunId, planId: authority.plan.planId, tests: authority.plan.tests, policy: { command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), requiredExitCode: 0, executionPlanSha256: authority.planFile.sha256 }, goldenBaseline: authority.plan.goldenBaseline, contracts: authority.plan.contracts, execution: { exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, outputLimitExceeded: result.outputLimitExceeded } }; }
73
+ function evidence(authority, result, sub) { const summary = result.timedOut ? 'Approved validation command exceeded its deadline' : result.outputLimitExceeded ? 'Approved validation command exceeded its output limit' : result.signal ? `Approved validation command ended on signal ${result.signal}` : `Approved validation command exited with code ${result.exitCode}`; const base = { schemaVersion: 'entrorove.test-evidence/2.0', evidenceId: `${authority.plan.validationRunId}:process`, kind: 'test', runner: authority.signing ? 'trusted-runner' : 'local', command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), exitCode: result.exitCode, durationMs: result.durationMs, summary, artifactSha256: authority.plan.artifactSha256, subject: sub, subjectDigest: digest(canonical(sub)) };
74
+ if (!authority.signing || !Number.isInteger(result.exitCode)) return base; const privateKey = authority.signing.privateKey; const receiptCore = { schemaVersion: RECEIPT_SCHEMA, keyId: authority.signing.config.keyId, subjectDigest: base.subjectDigest, nonce: randomUUID(), issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + authority.signing.config.receiptTtlMs).toISOString(), result: { runner: 'trusted-runner', passed: result.status === 'succeeded', exitCode: result.exitCode, durationMs: result.durationMs, summary } }; const receipt = { ...receiptCore, signature: sign(null, Buffer.from(canonical(receiptCore)), privateKey).toString('base64url') }; return { ...base, receipt };
75
+ }
76
+
77
+ export async function runApprovedPlan(input) { const owner = await authority(input); 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; }
78
+ const sub = 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' };
79
+ }
80
+
81
+ export const RUNNER_CAPABILITIES = Object.freeze({ schemaVersion: RUNNER_SCHEMA, operations: ['capabilities', 'run-approved-plan'], operationSchemas: { capabilities: { type: 'object', additionalProperties: false, required: [] }, 'run-approved-plan': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath'] } }, limits: RUNNER_LIMITS, boundary: 'POSIX subprocess runner; external approval is required; local evidence remains incomplete without a configured signer.' });
@@ -0,0 +1,37 @@
1
+ import { createHash, randomUUID } 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 { withStateLock } from './local-state-lock.mjs';
6
+
7
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
8
+ function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
9
+ function digest(value) { return createHash('sha256').update(value).digest('hex'); }
10
+ function canonical(value) { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; return JSON.stringify(value); }
11
+ function rootHome(home = os.homedir()) { if (typeof home !== 'string' || !path.isAbsolute(home)) fail('ENTROROVE_HOME_INVALID', 'home must be absolute'); return path.join(home, '.entrorove'); }
12
+ async function privateDir(directory) { await fs.mkdir(directory, { recursive: true, mode: 0o700 }); const stat = await fs.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'state directory must be a real directory'); if (process.platform !== 'win32') await fs.chmod(directory, 0o700); }
13
+ async function repo(value) { if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute'); const stat = await fs.lstat(value); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory'); return fs.realpath(value); }
14
+ function statePath(home, root, flowId) { return path.join(rootHome(home), 'budgets', digest(root).slice(0, 32), flowId, 'budget.json'); }
15
+ async function load(input) { const root = await repo(input.repositoryRoot); if (typeof input.flowId !== 'string' || !ID.test(input.flowId)) fail('ENTROROVE_ID_INVALID', 'flowId must be a safe identifier'); const file = statePath(input.home, root, input.flowId); await privateDir(path.dirname(file)); const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'budget state must be a regular file'); const state = JSON.parse(await fs.readFile(file, 'utf8')); if (state.flowId !== input.flowId || state.repositoryDigest !== digest(root).slice(0, 32)) fail('ENTROROVE_BUDGET_SCOPE', 'budget state does not match repository and flow'); return { root, file, state }; }
16
+ async function save(file, state) { await privateDir(path.dirname(file)); const temp = `${file}.${randomUUID()}.tmp`; await fs.writeFile(temp, `${canonical(state)}\n`, { flag: 'wx', mode: 0o600 }); await fs.rename(temp, file); if (process.platform !== 'win32') await fs.chmod(file, 0o600); }
17
+ export async function consumeBudgetRenewal({ file, state, now = Date.now() }) {
18
+ if (!state.enforced || !state.autoRenew || state.autoRenew.status !== 'authorized') return state;
19
+ const elapsed = state.elapsedMs + now - Date.parse(state.startedAt); const used = state.autoRenew.renewalsUsed ?? 0;
20
+ if (elapsed < state.maxDurationMs || used >= state.autoRenew.policy.maxRenewals) return state;
21
+ const updated = { ...state, startedAt: new Date(now).toISOString(), elapsedMs: 0, autoRenew: { ...state.autoRenew, renewalsUsed: used + 1, lastRenewedAt: new Date(now).toISOString() } };
22
+ await save(file, updated); return updated;
23
+ }
24
+ function scope(input) { if (!input.scope || typeof input.scope.goal !== 'string' || !Array.isArray(input.scope.allowedPaths) || !input.scope.allowedPaths.length) fail('ENTROROVE_RENEWAL_SCOPE', 'renewal scope must include goal and allowedPaths'); return { goal: input.scope.goal, allowedPaths: [...new Set(input.scope.allowedPaths)].sort() }; }
25
+ function policy(input) { if (!input.policy || !Number.isSafeInteger(input.policy.intervalMs) || input.policy.intervalMs < 1 || !Number.isSafeInteger(input.policy.maxRenewals) || input.policy.maxRenewals < 1 || input.policy.maxRenewals > 100) fail('ENTROROVE_RENEWAL_POLICY', 'renewal policy must have bounded intervalMs and maxRenewals'); return { intervalMs: input.policy.intervalMs, maxRenewals: input.policy.maxRenewals }; }
26
+ async function renewalRequestUnlocked(input) { const current = await load(input); const requested = scope(input); const renewalPolicy = policy(input); if (!input.requestId || typeof input.requestId !== 'string' || !ID.test(input.requestId)) fail('ENTROROVE_REQUEST_ID', 'requestId must be a safe identifier'); if (current.state.autoRenew?.status === 'authorized') fail('ENTROROVE_RENEWAL_EXISTS', 'this budget already has an immutable renewal authorization'); const record = { requestId: input.requestId, chainId: input.flowId, scope: requested, scopeDigest: digest(canonical(requested)), policy: renewalPolicy, status: 'pending', createdAt: new Date().toISOString() }; const state = { ...current.state, autoRenew: record }; await save(current.file, state); return { schemaVersion: 'entrorove.local-execution/1.0', status: 'waiting', interaction: { schemaVersion: 'confirm.interaction/1.0', interactionId: `renew-${randomUUID()}`, risk: 'medium', question: `Allow automatic read-time renewal for chain ${input.flowId}?`, options: [{ id: 'approve', label: 'Approve' }, { id: 'reject', label: 'Reject' }], default: null, expiresAt: new Date(Date.now() + 300_000).toISOString(), callback: { operation: 'budget-auto-renew', requestId: input.requestId } }, requestId: input.requestId, scope: requested, policy: renewalPolicy }; }
27
+ export async function budgetAutoRenewRequest(input) { const current = await load(input); return withStateLock(current.file, () => renewalRequestUnlocked(input)); }
28
+ async function renewalUnlocked(input) { const current = await load(input); const pending = current.state.autoRenew; const requested = scope(input); const renewalPolicy = policy(input); const confirmation = input.confirmation; if (!pending || pending.requestId !== input.requestId || pending.status !== 'pending' || confirmation?.answer !== 'approve' || confirmation.requestId !== input.requestId || pending.scopeDigest !== digest(canonical(requested)) || canonical(pending.policy) !== canonical(renewalPolicy)) return { schemaVersion: 'entrorove.local-execution/1.0', status: 'blocked', allowed: false, code: 'ENTROROVE_CONFIRMATION_BINDING' }; const state = { ...current.state, autoRenew: { ...pending, status: 'authorized', approvedAt: new Date().toISOString(), renewalsUsed: 0 } }; await save(current.file, state); return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', allowed: true, chainId: input.flowId, requestId: input.requestId, scope: requested, policy: renewalPolicy, renewalsUsed: 0 }; }
29
+ export async function budgetAutoRenew(input) { const current = await load(input); return withStateLock(current.file, () => renewalUnlocked(input)); }
30
+ async function renewalStopUnlocked(input) { const current = await load(input); const reason = input.reason; if (!['revoked', 'completed'].includes(reason)) fail('ENTROROVE_RENEWAL_REASON', 'reason must be revoked or completed'); if (!current.state.autoRenew || current.state.autoRenew.status !== 'authorized') return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', stopped: false, reason }; const state = { ...current.state, autoRenew: { ...current.state.autoRenew, status: 'stopped', stoppedAt: new Date().toISOString(), stopReason: reason } }; await save(current.file, state); return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', stopped: true, reason, renewalsUsed: state.autoRenew.renewalsUsed ?? 0 }; }
31
+ export async function budgetAutoRenewStop(input) { const current = await load(input); return withStateLock(current.file, () => renewalStopUnlocked(input)); }
32
+ export const LOCAL_RENEWAL_OPERATIONS = Object.freeze({ 'budget-auto-renew-request': budgetAutoRenewRequest, 'budget-auto-renew': budgetAutoRenew, 'budget-auto-renew-stop': budgetAutoRenewStop });
33
+ export const LOCAL_RENEWAL_SCHEMAS = Object.freeze({
34
+ 'budget-auto-renew-request': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy'] },
35
+ 'budget-auto-renew': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy', 'confirmation'] },
36
+ 'budget-auto-renew-stop': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'flowId', 'reason'] },
37
+ });