@entrorove/skill 0.1.52 → 0.1.54

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.
Files changed (50) hide show
  1. package/CAPABILITY-MAP.md +14 -2
  2. package/CHANGELOG.md +12 -0
  3. package/DELIVERY-BOUNDARIES.md +4 -2
  4. package/IDE-EXECUTION.md +25 -3
  5. package/SKILL.md +8 -4
  6. package/cli.mjs +4 -6
  7. package/cloud-mutation-authority.mjs +96 -0
  8. package/connected-cli.mjs +20 -0
  9. package/ide-code-mode.mjs +30 -8
  10. package/ide-command.mjs +27 -1
  11. package/ide-hook.mjs +70 -17
  12. package/ide-results.mjs +8 -6
  13. package/ide-session.mjs +44 -1
  14. package/local-approved-plan.mjs +3 -21
  15. package/local-approved-process.mjs +54 -0
  16. package/local-approved-supervisor.mjs +99 -0
  17. package/local-cli.mjs +13 -2
  18. package/local-contracts.mjs +2 -0
  19. package/local-delegated-modify-analysis.mjs +232 -0
  20. package/local-discipline-actions.mjs +2 -0
  21. package/local-discipline-gate.mjs +3 -1
  22. package/local-error.mjs +3 -0
  23. package/local-execution.mjs +1 -1
  24. package/local-mutation-authority.mjs +23 -5
  25. package/local-mutation-batch.mjs +1 -0
  26. package/local-mutation-binding.mjs +14 -5
  27. package/local-mutation-consumption.mjs +93 -0
  28. package/local-mutation-pass-store.mjs +6 -4
  29. package/local-mutation-wire.mjs +21 -4
  30. package/local-preserved-mutation-protection.mjs +51 -0
  31. package/local-preserved-mutation-signer.mjs +81 -0
  32. package/local-preserved-mutation-state.mjs +96 -0
  33. package/local-preserved-mutation-wire.mjs +49 -0
  34. package/local-scope-create-require.mjs +142 -0
  35. package/local-scope-import-expressions.mjs +2 -2
  36. package/local-scope-repository.mjs +42 -15
  37. package/local-scope-require-resolution.mjs +78 -0
  38. package/manifest.json +1 -1
  39. package/mutation-client.mjs +290 -0
  40. package/package.json +21 -2
  41. package/release-state.json +48 -29
  42. package/release.mjs +29 -0
  43. package/releases.json +12 -0
  44. package/remote-client.mjs +2 -1
  45. package/workflow-observation-client.mjs +44 -0
  46. package/workflow-observation-connection.mjs +35 -0
  47. package/workflow-observation-remote.mjs +84 -0
  48. package/workflow-observation-runner.mjs +87 -0
  49. package/workflow-observation-state.mjs +36 -0
  50. package/workflow-observation-wire.mjs +69 -0
package/ide-hook.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { ideSession, ideRuntime, bindIdeAccount, ideContext, ideDigest, readBreakGlass } from './ide-session.mjs';
4
+ import { ideSession, ideRuntime, bindIdeAccount, ideContext, ideDigest, readBreakGlass,
5
+ ideBlockIdentity, repeatedIdeBlock, observeIdeToolOutcome } from './ide-session.mjs';
5
6
  import { isStageTerminal, stageOf } from './local-stage-boundary.mjs';
6
- import { literalCommand, sdkCommand, readCommand, renderSdkCommand, installerCommand } from './ide-command.mjs';
7
- import { inspectIdeCodeMode } from './ide-code-mode.mjs';
7
+ import { literalCommand, sdkCommand, readCommand, renderSdkCommand, installerCommand,
8
+ runtimeHashCommand, runtimeHashRead } from './ide-command.mjs';
9
+ import { prepareIdeCodeMode } from './ide-code-mode.mjs';
8
10
  import { pathToFileURL } from 'node:url';
9
11
  import path from 'node:path';
10
12
  import { parseIdeResults, consumeIdeResults } from './ide-results.mjs';
@@ -25,7 +27,7 @@ function userPrompt(event) {
25
27
  }
26
28
  return prompt;
27
29
  }
28
- const managementOperations = new Set(['capabilities', 'help', 'start', 'continue', 'task-describe', 'flow-cancel', 'flow-abandon', 'scope-connect', 'status', 'feedback', 'cloud-status', 'cloud-watch', 'queue', 'unlock', 'usage', 'billing', 'recurring']);
30
+ const managementOperations = new Set(['capabilities', 'help', 'start', 'continue', 'task-describe', 'flow-cancel', 'flow-abandon', 'scope-connect', 'mutation-connect', 'mutation-settle', 'mutation-consumption-attest', 'mutation-delegate-prepare', 'status', 'feedback', 'cloud-status', 'cloud-watch', 'queue', 'unlock', 'usage', 'billing', 'recurring']);
29
31
  function operationOf(call) {
30
32
  if (!call) return null;
31
33
  if (call.args[0] === 'invoke') return call.input?.operation ?? null;
@@ -33,17 +35,39 @@ function operationOf(call) {
33
35
  return call.args[0] ?? null;
34
36
  }
35
37
  function isManagementCall(call) { return managementOperations.has(operationOf(call)); }
36
- function resetBlockStreak(state) { state.blockStreak = 0; delete state.lastBlockKey; }
37
- function blockedReason(state, event, reason) {
38
- const key = ideDigest({ tool: event.tool_name, phase: state.phase, reason });
38
+ function resetBlockStreak(state) { state.blockStreak = 0; delete state.lastBlockKey; delete state.lastBlockIdentity; }
39
+ function blockedReason(state, event, reason, detail = {}) {
40
+ const key = ideDigest({ identity: ideBlockIdentity(state), tool: event.tool_name, phase: state.phase, reason });
39
41
  const repeated = state.lastBlockKey === key;
40
42
  state.blockStreak = repeated ? (state.blockStreak ?? 0) + 1 : 1;
41
43
  state.lastBlockKey = key;
42
- const mechanism = `机制:${state.stateFile ?? '会话状态文件'} 当前 phase=${state.phase}${state.pendingCall ? `,pendingCall=${state.pendingCall.toolUseId}` : ''};该阶段只放行 Entrorove 管理调用,业务与写操作需先建立原 flow 并取得 nextStep。`;
44
+ state.lastBlockIdentity = ideBlockIdentity(state);
45
+ const mechanism = detail.mechanism ?? `机制:${state.stateFile ?? '会话状态文件'} 当前 phase=${state.phase}${state.pendingCall ? `,pendingCall=${state.pendingCall.toolUseId}` : ''};该阶段只放行 Entrorove 管理调用,业务与写操作需先建立原 flow 并取得 nextStep。`;
43
46
  const repeat = repeated && state.blockStreak >= 2 ? `同一阻断已出现 ${state.blockStreak} 次,禁止再次检查:说明机制、给出绕过方案并执行绕过。` : '';
44
- return `${reason} ${mechanism} ${repeat}\n绕过方案(离线、无网络、无协议、不经 hook,终端直接运行):\n${unlockCommand}\n执行后重试原管理调用;项目写入仍需原任务授权。`;
47
+ const recovery = detail.recovery ?? `绕过方案(离线、无网络、无协议、不经 hook,终端直接运行):\n${unlockCommand}\n执行后重试原管理调用;项目写入仍需原任务授权。`;
48
+ return `${reason} ${mechanism} ${repeat}\n${recovery}`;
49
+ }
50
+ const denyBlocked = (state, event, reason, detail) => deny(blockedReason(state, event, reason, detail));
51
+ function runtimeHashAttempt(command, parsed) {
52
+ const name = parsed?.words[0];
53
+ return ['shasum', '/usr/bin/shasum', 'sha256sum', '/usr/bin/sha256sum'].includes(name)
54
+ || name === '/usr/bin/env' && parsed.words[1] === '-i' && parsed.words[2] === 'PATH=/usr/bin:/bin'
55
+ && ['/usr/bin/shasum', '/usr/bin/sha256sum'].includes(parsed.words[3])
56
+ || typeof command === 'string' && /^\s*(?:(?:\/usr\/bin\/)?(?:shasum|sha256sum)|\/usr\/bin\/env\s+-i\s+PATH=\/usr\/bin:\/bin\s+\/usr\/bin\/(?:shasum|sha256sum))(?:\s|$)/u.test(command);
57
+ }
58
+ function safeHashToolInput(input) {
59
+ return input && typeof input === 'object' && Object.keys(input).every(key =>
60
+ ['cmd', 'command', 'workdir', 'max_output_tokens', 'yield_time_ms', 'shell', 'login'].includes(key))
61
+ && (input.shell === undefined || input.shell === '/bin/sh')
62
+ && (input.login === undefined || input.login === false);
63
+ }
64
+ function denyRuntimeHash(state, event, runtime) {
65
+ const safe = runtimeHashCommand(runtime);
66
+ return denyBlocked(state, event, 'ENTROROVE_IDE_RUNTIME_HASH_DENIED:仅允许读取本次安装所绑定的 Node 可执行文件 SHA-256。', {
67
+ mechanism: `机制:哈希路径必须精确等于受管 runtime.node;拒绝其他文件、额外参数、shell 展开和自定义执行器。当前 phase=${state.phase}。`,
68
+ recovery: `恢复:用固定只读命令重新取得该 Node 文件的哈希:\n${safe ?? '当前平台没有受支持的固定哈希命令;请使用受管安装所绑定的 Node 路径。'}${safe ? `\n代码模式请传静态 tools.exec_command({cmd:${JSON.stringify(safe)},shell:'/bin/sh',login:false})。` : ''}\n本次在 hook 本地预检中拒绝,未发起协议请求,因此没有 requestId。`,
69
+ });
45
70
  }
46
- const denyBlocked = (state, event, reason) => deny(blockedReason(state, event, reason));
47
71
  function lifecycleResult(value, depth = 0) {
48
72
  if (!value || typeof value !== 'object' || depth > 4) return null;
49
73
  if (value.operation === 'flow-cancel' || value.operation === 'flow-abandon') return value;
@@ -140,11 +164,11 @@ async function beforeTool(event, state, home) {
140
164
  const observer = discovery || ['status','feedback','cloud-status','configure','agreement','install','queue','flows','usage','billing','recurring'].includes(call.args[0]);
141
165
  if (unclassifiedIdeRequirements(state) && !observer && !(unlocked && isManagementCall(call))) return denyBlocked(state, event, 'Entrorove:新需求已保存,先用 queue status 读取并按轻重缓急 route 分类;禁止丢弃新需求或覆盖原任务。');
142
166
  if (newStart && !checkQueuedStart(state,call)) return denyBlocked(state, event, 'Entrorove:新任务目标必须与实际进入队列的用户原文一致。');
143
- if (call.args[0] === 'local' && !discovery && !state.flowId) return denyBlocked(state, event, 'Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
167
+ if (['local', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(call.args[0]) && !discovery && !state.flowId) return denyBlocked(state, event, 'Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
144
168
  if (state.pendingCall && !observer && !(unlocked && isManagementCall(call))) return denyBlocked(state, event, 'Entrorove:原调用尚未收到结束回执;先只读恢复其状态,禁止并发重发。');
145
169
  const input = call.args[0] === 'invoke' ? call.input?.input : call.input;
146
170
  if (input?.flowId && state.flowId && input.flowId !== state.flowId || input?.taskId && state.taskId && ['cloud-watch','cloud-status'].includes(call.args[0]) && input.taskId !== state.taskId) return denyBlocked(state, event, 'Entrorove:调用与本 IDE 任务的原编号不一致。');
147
- if (call.args[0] === 'scope-connect' && call.args[1] !== state.repositoryRoot
171
+ if (['scope-connect', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(call.args[0]) && call.args[1] !== state.repositoryRoot
148
172
  || call.args[0] === 'local' && (input?.repositoryRoot && input.repositoryRoot !== state.repositoryRoot || call.args[2] && call.args[2] !== state.repositoryRoot)) return denyBlocked(state, event, 'Entrorove:调用不能替换本任务的实际仓库。');
149
173
  let requestId;
150
174
  if (newStart) state.taskGoal = call.args[0] === 'cloud-task' ? call.input?.goal : call.input?.input?.task?.goal;
@@ -152,7 +176,10 @@ async function beforeTool(event, state, home) {
152
176
  const { buildInvocation } = await import(pathToFileURL(path.join(path.dirname(runtime.cli), 'remote-client.mjs')).href);
153
177
  requestId = buildInvocation(call.input.operation, call.input.input).requestId;
154
178
  }
155
- const pending = { toolUseId: event.tool_use_id, inputDigest: ideDigest(call), command: call.args[0], ...(requestId ? { requestId } : {}) };
179
+ const pending = { toolUseId: event.tool_use_id, inputDigest: ideDigest(call), command: call.args[0],
180
+ operation: operationOf(call), observer, managementOnly: observer || isManagementCall(call)
181
+ && !['start', 'continue', 'cloud-task'].includes(operationOf(call)) || operationOf(call) === 'task-checkpoint',
182
+ ...(requestId ? { requestId } : {}) };
156
183
  if (observer) {
157
184
  state.readCalls ??= {};
158
185
  if (Object.keys(state.readCalls).length >= 32) return denyBlocked(state, event, 'Entrorove:只读恢复尚未返回,请等待原调用。');
@@ -160,16 +187,32 @@ async function beforeTool(event, state, home) {
160
187
  } else state.pendingCall = pending;
161
188
  const installation = call.args[0] === 'install' && parsed.words.some(word => ['npx','npx.cmd'].includes(word));
162
189
  const rewritten = renderSdkCommand(call, runtime, state.sessionId);
163
- resetBlockStreak(state);
164
190
  return output('PreToolUse', { permissionDecision: 'allow', ...(installation ? {} : { updatedInput: { ...event.tool_input, command: rewritten,
165
191
  ...(Object.hasOwn(event.tool_input ?? {}, 'cmd') ? { cmd: rewritten } : {}) } }), additionalContext: ideContext(state) });
166
192
  }
167
193
  if (!state.active) return {};
168
194
  if (['exec','functions.exec'].includes(event.tool_name)) {
169
195
  const current = await ideRuntime(home), code = typeof event.tool_input === 'string' ? event.tool_input : event.tool_input?.code;
170
- return inspectIdeCodeMode(code, current) ? output('PreToolUse', { additionalContext: ideContext(state) }) : denyBlocked(state, event, 'Entrorove:代码模式含未批准的原生执行或动态工具输入;请使用原任务的受控入口。');
196
+ const prepared = prepareIdeCodeMode(code, current);
197
+ if (prepared.allowed) return output('PreToolUse', { additionalContext: ideContext(state),
198
+ ...(prepared.code === code ? {} : { updatedInput: typeof event.tool_input === 'string' ? prepared.code
199
+ : { ...event.tool_input, code: prepared.code } }) });
200
+ if (typeof code === 'string' && /\b(?:shasum|sha256sum)\b/u.test(code)) return denyRuntimeHash(state, event, current);
201
+ return denyBlocked(state, event, 'Entrorove:代码模式含未批准的原生执行或动态工具输入;请使用原任务的受控入口。');
171
202
  }
172
203
  if (event.tool_name === 'update_goal' && (state.phase === 'setup' || unlocked)) return output('PreToolUse', { additionalContext: ideContext(state) });
204
+ if (isShell) {
205
+ const fixedHash = runtimeHashRead(parsed, runtime);
206
+ if (fixedHash) {
207
+ if (!safeHashToolInput(event.tool_input)) return denyRuntimeHash(state, event, runtime);
208
+ resetBlockStreak(state);
209
+ return output('PreToolUse', { permissionDecision: 'allow', updatedInput: { ...event.tool_input, command: fixedHash,
210
+ ...(Object.hasOwn(event.tool_input ?? {}, 'cmd') ? { cmd: fixedHash } : {}),
211
+ ...(event.tool_name === 'exec_command' ? { shell: '/bin/sh', login: false } : {}) },
212
+ additionalContext: ideContext(state) });
213
+ }
214
+ if (runtimeHashAttempt(command, parsed)) return denyRuntimeHash(state, event, runtime ?? await ideRuntime(home));
215
+ }
173
216
  if (isShell && readCommand(parsed) || readTools.has(event.tool_name)) return output('PreToolUse', { additionalContext: ideContext(state) });
174
217
  return denyBlocked(state, event, `Entrorove:已阻止原生 ${event.tool_name ?? 'unknown'} 调用。当前阶段 ${state.phase}${state.taskNumber ? `,云端任务 ${state.taskNumber}` : ''}。写入、删除、移动及不确定的终端操作必须使用原任务的 guarded-write/guarded-delete/guarded-move 或已批准的执行计划。`);
175
218
  }
@@ -178,6 +221,10 @@ export async function runIdeHook(event, { home } = {}) {
178
221
  const name = event.hook_event_name;
179
222
  const selected = name === 'UserPromptSubmit' && selectPrompt.test(userPrompt(event));
180
223
  const newSelection = selected && !state.active;
224
+ if (name === 'UserPromptSubmit') {
225
+ state.turnSequence = (Number.isSafeInteger(state.turnSequence) ? state.turnSequence : 0) + 1;
226
+ delete state.stopNoticeTurn;
227
+ }
181
228
  if (name === 'PreToolUse') state.enforcement = 'enforced';
182
229
  if (name === 'UserPromptSubmit' && exitPrompt.test(userPrompt(event))) {
183
230
  if (state.phase !== 'user-exited') state.exitPhase = state.phase;
@@ -224,16 +271,22 @@ export async function runIdeHook(event, { home } = {}) {
224
271
  const recoveredOriginal = pendingRead && state.pendingCall?.requestId
225
272
  && values.some(result => (result.output ?? result).requestId === state.pendingCall.requestId);
226
273
  const messages = collectResult(state, bound, pending.command === 'local');
274
+ observeIdeToolOutcome(state, bound, pending);
227
275
  if (pendingRead) delete state.readCalls[event.tool_use_id];
228
276
  if (!pendingRead || recoveredOriginal) delete state.pendingCall;
229
- const resumed = await advanceIdeQueue(state,home);
277
+ const resumed = state.phase === 'blocked' || repeatedIdeBlock(state) ? '' : await advanceIdeQueue(state,home);
230
278
  if (resumed) messages.push(resumed);
231
279
  return { ...output(name, { additionalContext: ideContext(state) }), ...(messages.length ? { systemMessage: messages.join('\n') } : {}) };
232
280
  }
233
281
  if (name==='Stop' && state.active && !(await readBreakGlass(home, state.sessionId))) {
282
+ const turn = state.turnSequence ?? 0;
283
+ if (repeatedIdeBlock(state) || event.stop_hook_active === true || state.stopNoticeTurn === turn) return {};
234
284
  const resumed = await advanceIdeQueue(state,home);
235
285
  if (resumed || unclassifiedIdeRequirements(state) || unfinishedIdeQueue(state) && !['blocked','needs-input','delivered'].includes(state.phase)
236
- || !['ended','blocked','needs-input','setup','delivered'].includes(state.phase)) return {decision:'block',reason:blockedReason(state, event, `${ideContext(state)}${ideQueueContext(state)}${resumed}原任务或队列尚未取得结束回执;继续可运行工作,真实阻断必须明确报告。`)};
286
+ || !['ended','blocked','needs-input','setup','delivered'].includes(state.phase)) {
287
+ state.stopNoticeTurn = turn;
288
+ return {decision:'block',reason:`${ideContext(state)}${ideQueueContext(state)}${resumed}原任务或队列尚未取得结束回执;本轮仅提醒一次继续可运行工作。真实阻断应明确报告并等待修复,目标与队列保持不变。`};
289
+ }
237
290
  }
238
291
  return state.active && ['SessionStart','UserPromptSubmit','PostCompact','PreCompact'].includes(name) ? output(name, { additionalContext: `${ideContext(state)}${ideQueueContext(state)}` }) : {};
239
292
  }, home);
package/ide-results.mjs CHANGED
@@ -46,15 +46,17 @@ export async function consumeIdeResults(event, state, pending, home) {
46
46
  return proof.values;
47
47
  }
48
48
  export function captureIdeResults() {
49
- const original = process.stdout.write, chunks = [];
49
+ const original = process.stdout.write, originalError = process.stderr.write, chunks = [], errors = [];
50
50
  let size = 0;
51
- process.stdout.write = function(chunk, ...rest) {
51
+ const capture = (writer, target) => function(chunk, ...rest) {
52
52
  size += Buffer.byteLength(chunk);
53
- if (size <= 8_000_000) chunks.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk);
54
- return original.call(this, chunk, ...rest);
53
+ if (size <= 8_000_000) target.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk);
54
+ return writer.call(this, chunk, ...rest);
55
55
  };
56
+ process.stdout.write = capture(original, chunks);
57
+ process.stderr.write = capture(originalError, errors);
56
58
  return async () => {
57
- process.stdout.write = original;
58
- if (size <= 8_000_000) await persistIdeResults(ideResultContext(), parseIdeResults(chunks.join('')));
59
+ process.stdout.write = original; process.stderr.write = originalError;
60
+ if (size <= 8_000_000) await persistIdeResults(ideResultContext(), [...parseIdeResults(chunks.join('')), ...parseIdeResults(errors.join(''))]);
59
61
  };
60
62
  }
package/ide-session.mjs CHANGED
@@ -83,10 +83,53 @@ export async function ideRuntime(home = os.homedir()) {
83
83
  if (ideDigest(cli.toString('utf8')) !== runtime.cliDigest) throw ideError('ENTROROVE_IDE_RUNTIME', 'Installed CLI changed; reinstall the managed runtime');
84
84
  return runtime;
85
85
  }
86
+ export const ideBlockIdentity = state => ideDigest({ repositoryRoot: state.repositoryRoot, flowId: state.flowId, taskId: state.taskId });
87
+ export function repeatedIdeBlock(state) {
88
+ const identity = ideBlockIdentity(state);
89
+ return state.executionBlock?.identity === identity && state.executionBlock.count >= 2
90
+ || state.lastBlockIdentity === identity && state.blockStreak >= 2;
91
+ }
92
+ function outcomeNodes(values) {
93
+ const pending = values.map(value => ({ value, depth: 0 })), nodes = [];
94
+ while (pending.length && nodes.length < 64) {
95
+ const { value, depth } = pending.shift();
96
+ if (!value || typeof value !== 'object' || depth > 4) continue;
97
+ nodes.push(value);
98
+ for (const key of ['output', 'result']) if (value[key]) pending.push({ value: value[key], depth: depth + 1 });
99
+ }
100
+ return nodes;
101
+ }
102
+ export function observeIdeToolOutcome(state, values, pending) {
103
+ if (state.phase === 'ended' || !state.flowId && !state.taskId) { delete state.executionBlock; return; }
104
+ const nodes = outcomeNodes(values), identity = ideBlockIdentity(state);
105
+ const failure = nodes.findLast(value => ['blocked', 'failed', 'incomplete'].includes(value.status)
106
+ || ['blocked', 'failed'].includes(value.taskStatus));
107
+ const previous = state.executionBlock?.identity === identity ? state.executionBlock : null;
108
+ const management = pending.observer || pending.managementOnly;
109
+ if (failure) {
110
+ const key = ideDigest({ identity, inputDigest: pending.inputDigest });
111
+ // Management observations cannot replace a failed execution's identity or
112
+ // clear its streak. One consumed call counts once, even with two copies.
113
+ if (!management || !previous || previous.key === key) {
114
+ state.executionBlock = { identity, key, operation: pending.operation ?? pending.command,
115
+ count: previous?.key === key ? previous.count + Number(previous.toolUseId !== pending.toolUseId) : 1,
116
+ toolUseId: pending.toolUseId, observedAt: new Date().toISOString() };
117
+ }
118
+ state.phase = 'blocked';
119
+ return;
120
+ }
121
+ if (management && previous) { state.phase = 'blocked'; return; }
122
+ if (!management && nodes.some(value => ['verified', 'succeeded', 'started', 'reported'].includes(value.status) || value.nextStep)) {
123
+ delete state.executionBlock; state.blockStreak = 0; delete state.lastBlockKey; delete state.lastBlockIdentity;
124
+ }
125
+ }
86
126
  export function ideContext(state) {
87
127
  const delivered = state.phase === 'delivered' && Number.isSafeInteger(state.deliveredStage)
88
128
  ? `阶段 ${state.deliveredStage} 已按边界独立交付(终态,不等于失败或阻塞);续跑从下一个边界继续,不重新执行已交付阶段。` : '';
89
- return `Entrorove 已强制接管本任务。${state.taskNumber ? `云端任务 ${state.taskNumber}。` : '尚未获得云端任务编号,不得声称已进入云端。'}${state.flowId ? `继续原 flowId=${state.flowId},` : ''}${state.requestId ? `原 requestId=${state.requestId}。` : ''}当前阶段 ${state.phase}。${delivered}必须读 SKILL.md 并执行返回的 nextStep;禁止在失败后替换执行器。源文件、数据及终端写入必须走原任务的受控入口。`;
129
+ const waiting = repeatedIdeBlock(state);
130
+ const action = waiting ? '同一真实阻断已达到两次;本轮允许结束并等待修复,禁止原样第三次重试。原目标、flow 与队列均保留。'
131
+ : '必须读 SKILL.md 并执行返回的 nextStep;禁止在失败后替换执行器。';
132
+ return `${waiting ? 'Entrorove 原任务绑定已保留。' : 'Entrorove 已强制接管本任务。'}${state.taskNumber ? `云端任务 ${state.taskNumber}。` : '尚未获得云端任务编号,不得声称已进入云端。'}${state.flowId ? `继续原 flowId=${state.flowId},` : ''}${state.requestId ? `原 requestId=${state.requestId}。` : ''}当前阶段 ${state.phase}。${delivered}${action}源文件、数据及终端写入必须走原任务的受控入口。`;
90
133
  }
91
134
  // Verified hosts only: the guard is universal in shape (host label + session
92
135
  // id), and each host proves itself through its own adapter callback.
@@ -1,10 +1,9 @@
1
1
  import { LOCAL_OPERATION_SCHEMAS } from './local-contracts.mjs';
2
2
  import { createHash, createPrivateKey, createPublicKey, randomUUID, sign } from 'node:crypto';
3
- import { spawn } from 'node:child_process';
3
+ import { runApprovedProcess } from './local-approved-process.mjs';
4
4
  import { promises as fs, constants } from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { performance } from 'node:perf_hooks';
8
7
  import { assertBudgetRunnerAllowed } from './local-budget-reads.mjs';
9
8
  import { validateRunnerParallelPaths, captureRunnerEquivalence } from './local-equivalence-runner.mjs';
10
9
  import { disciplineApprovedPolicy, disciplineDataSnapshotsBound } from './local-discipline-contracts.mjs';
@@ -87,25 +86,8 @@ async function authority(input) {
87
86
  return { root, plan, planFile, approval, signing };
88
87
  }
89
88
 
90
- function runCommand(authority) {
91
- return new Promise((resolve, reject) => {
92
- const started = performance.now(); const { policy } = authority.plan; const remaining = authority.executionDeadlineAt ? Date.parse(authority.executionDeadlineAt) - Date.now() : policy.timeoutMs;
93
- if (remaining <= 0) { reject(Object.assign(new Error('workflow execution lease expired before process launch'), { code: 'ENTROROVE_WORKFLOW_EXPIRED' })); return; }
94
- const child = spawn(policy.executable, policy.args, { cwd: authority.root, env: { ...policy.environment }, shell: false, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
95
- const stdout = []; const stderr = []; let bytes = 0; let dropped = 0; let timedOut = false; let terminating = false; let spawned = false; let processStartedAt = null; let spawnError = null; let timer; let killTimer; const stdoutHash = createHash('sha256'); const stderrHash = createHash('sha256');
96
- const terminate = () => { if (terminating) return; terminating = true; try { process.kill(-child.pid, 'SIGTERM'); } catch {} killTimer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 250); killTimer.unref(); };
97
- 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(); };
98
- child.stdout.on('data', chunk => consume(stdout, stdoutHash, chunk)); child.stderr.on('data', chunk => consume(stderr, stderrHash, chunk));
99
- child.once('spawn', () => { spawned = true; processStartedAt = new Date().toISOString(); }); child.once('error', error => { spawnError = error; terminate(); }); timer = setTimeout(() => { timedOut = true; terminate(); }, Math.min(policy.timeoutMs, remaining));
100
- child.once('close', (exitCode, signal) => {
101
- clearTimeout(timer); clearTimeout(killTimer);
102
- const processResult = { schemaVersion: RUNNER_SCHEMA, status: exitCode === 0 && signal === null && !timedOut && !dropped && !spawnError ? '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,
103
- ...(authority.objectPlan ? { started: spawned, startedAt: processStartedAt, finishedAt: new Date().toISOString(), executionDeadlineExceeded: timedOut && remaining <= policy.timeoutMs } : {}) };
104
- if (spawnError) { spawnError.execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', process: processResult, evidence: [], effects: spawned ? 'unknown' : 'none' }; reject(spawnError); return; }
105
- resolve(processResult);
106
- });
107
- });
108
- }
89
+ const runCommand = authority => runApprovedProcess({ ...authority, policy: authority.plan.policy });
90
+
109
91
  async function subject(authority, result) {
110
92
  const equivalence = await captureRunnerEquivalence(authority, result, projectFile);
111
93
  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, ...(authority.plan.policy.parallelPaths ? { parallelPaths: authority.plan.policy.parallelPaths } : {}), ...(authority.plan.policy.discipline ? { disciplineReviewRequired: true } : {}), ...(authority.plan.evidencePolicy ? { evidenceKind: authority.plan.evidencePolicy.kind, evidenceRunner: authority.plan.evidencePolicy.runner, workflowBinding: { flowId: authority.plan.evidencePolicy.flowId, stepId: authority.plan.evidencePolicy.stepId }, ...(authority.plan.evidencePolicy.localVerification ? { localVerification: authority.plan.evidencePolicy.localVerification } : {}) } : {}) }, ...(equivalence === undefined ? {} : { equivalence }), ...(authority.disciplineComparison ? { disciplineComparison: authority.disciplineComparison } : {}), goldenBaseline: authority.plan.goldenBaseline, contracts: authority.plan.contracts, execution: { exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, outputLimitExceeded: result.outputLimitExceeded } }; }
@@ -0,0 +1,54 @@
1
+ import { fork } from 'node:child_process';
2
+ import { performance } from 'node:perf_hooks';
3
+
4
+ const SUPERVISOR = new URL('./local-approved-supervisor.mjs', import.meta.url);
5
+ const MESSAGE_SCHEMA = 'entrorove.approved-process-supervision/1.0';
6
+ const RUNNER_SCHEMA = 'entrorove.validator-local-runner/1.0';
7
+
8
+ function launchInput(authority) {
9
+ const now = Date.now(), policy = authority.policy;
10
+ const leaseDeadline = authority.executionDeadlineAt === undefined ? Infinity : Date.parse(authority.executionDeadlineAt);
11
+ const startDeadlineAt = authority.startDeadlineAt === undefined ? null : Date.parse(authority.startDeadlineAt);
12
+ if ((!Number.isFinite(leaseDeadline) && leaseDeadline !== Infinity) || leaseDeadline <= now
13
+ || (startDeadlineAt !== null && (!Number.isFinite(startDeadlineAt) || startDeadlineAt <= now))) {
14
+ throw Object.assign(new Error('workflow execution lease expired before process launch'), { code: 'ENTROROVE_WORKFLOW_EXPIRED' });
15
+ }
16
+ return { schemaVersion: MESSAGE_SCHEMA, root: authority.root, executable: policy.executable, args: policy.args,
17
+ environment: policy.environment, timeoutMs: policy.timeoutMs, maxOutputBytes: policy.maxOutputBytes,
18
+ objectPlan: Boolean(authority.objectPlan), deadlineAt: Math.min(now + policy.timeoutMs, leaseDeadline), startDeadlineAt,
19
+ leaseDeadline: leaseDeadline <= now + policy.timeoutMs };
20
+ }
21
+
22
+ function supervisionError(code, message, effects = 'unknown') {
23
+ return Object.assign(new Error(message), { code,
24
+ execution: { schemaVersion: RUNNER_SCHEMA, status: 'failed', evidence: [], effects } });
25
+ }
26
+
27
+ export async function runApprovedProcess(authority) {
28
+ const started = performance.now(), input = launchInput(authority);
29
+ return new Promise((resolve, reject) => {
30
+ let finished = false;
31
+ const supervisor = fork(SUPERVISOR, [], { execPath: process.execPath, execArgv: [], env: {}, detached: true,
32
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'], serialization: 'json' });
33
+ const finish = (error, result) => {
34
+ if (finished) return; finished = true;
35
+ if (supervisor.connected) supervisor.disconnect();
36
+ if (error) reject(error); else resolve({ ...result, durationMs: Math.max(0, Math.round(performance.now() - started)) });
37
+ };
38
+ supervisor.on('message', message => {
39
+ if (message?.schemaVersion !== MESSAGE_SCHEMA || message.type !== 'result') return;
40
+ if (message.error) {
41
+ const error = Object.assign(new Error(message.error.message), { code: message.error.code });
42
+ if (message.process) error.execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', process: message.process,
43
+ evidence: [], effects: message.process.started === true ? 'unknown' : 'none' };
44
+ finish(error); return;
45
+ }
46
+ finish(null, message.process);
47
+ });
48
+ supervisor.once('error', () => finish(supervisionError('ENTROROVE_PROCESS_SUPERVISOR', 'Approved process supervision could not start', 'none')));
49
+ supervisor.once('close', () => { if (!finished) finish(supervisionError('ENTROROVE_PROCESS_SUPERVISOR', 'Approved process supervision ended without a result')); });
50
+ supervisor.send(input, error => {
51
+ if (error) finish(supervisionError('ENTROROVE_PROCESS_SUPERVISOR', 'Approved process supervision could not receive its frozen command', 'none'));
52
+ });
53
+ });
54
+ }
@@ -0,0 +1,99 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { performance } from 'node:perf_hooks';
5
+
6
+ const MESSAGE_SCHEMA = 'entrorove.approved-process-supervision/1.0';
7
+ const RUNNER_SCHEMA = 'entrorove.validator-local-runner/1.0';
8
+ const FIELDS = ['schemaVersion', 'root', 'executable', 'args', 'environment', 'timeoutMs', 'maxOutputBytes', 'objectPlan', 'deadlineAt', 'startDeadlineAt', 'leaseDeadline'];
9
+ const ENV = /^[A-Za-z_][A-Za-z0-9_]*$/;
10
+
11
+ function validInput(value) {
12
+ return value && typeof value === 'object' && !Array.isArray(value)
13
+ && Object.keys(value).sort().join('|') === [...FIELDS].sort().join('|') && value.schemaVersion === MESSAGE_SCHEMA
14
+ && typeof value.root === 'string' && path.isAbsolute(value.root) && !value.root.includes('\0')
15
+ && typeof value.executable === 'string' && path.isAbsolute(value.executable) && !value.executable.includes('\0')
16
+ && Array.isArray(value.args) && value.args.length <= 128
17
+ && value.args.every(item => typeof item === 'string' && !item.includes('\0') && item.length <= 4096)
18
+ && value.environment && typeof value.environment === 'object' && !Array.isArray(value.environment)
19
+ && Object.keys(value.environment).length <= 64
20
+ && Object.entries(value.environment).every(([key, item]) => ENV.test(key) && typeof item === 'string' && !item.includes('\0') && item.length <= 16_384)
21
+ && Number.isSafeInteger(value.timeoutMs) && value.timeoutMs > 0 && value.timeoutMs <= 300_000
22
+ && Number.isSafeInteger(value.maxOutputBytes) && value.maxOutputBytes > 0 && value.maxOutputBytes <= 1_048_576
23
+ && typeof value.objectPlan === 'boolean' && typeof value.leaseDeadline === 'boolean'
24
+ && (value.startDeadlineAt === null || Number.isSafeInteger(value.startDeadlineAt))
25
+ && Number.isSafeInteger(value.deadlineAt) && value.deadlineAt <= Date.now() + value.timeoutMs;
26
+ }
27
+
28
+ function resultMessage(processResult, error) {
29
+ return { schemaVersion: MESSAGE_SCHEMA, type: 'result', process: processResult,
30
+ ...(error ? { error: { code: error.code ?? 'ENTROROVE_PROCESS_SUPERVISOR', message: String(error.message).slice(0, 1024) } } : {}) };
31
+ }
32
+
33
+ function deliver(value) {
34
+ if (!process.connected) return;
35
+ process.send(value, () => { if (process.connected) process.disconnect(); });
36
+ }
37
+
38
+ function assertLaunchable(input) {
39
+ if (!process.connected || input.deadlineAt <= Date.now() || input.startDeadlineAt !== null && input.startDeadlineAt <= Date.now()) {
40
+ throw Object.assign(new Error('workflow execution lease expired before process launch'), { code: 'ENTROROVE_WORKFLOW_EXPIRED' });
41
+ }
42
+ }
43
+
44
+ function supervise(input) {
45
+ const started = performance.now(), stdout = [], stderr = [], stdoutHash = createHash('sha256'), stderrHash = createHash('sha256');
46
+ let bytes = 0, dropped = 0, timedOut = false, terminating = false, spawned = false, closed = false;
47
+ let processStartedAt = null, spawnError = null, killTimer;
48
+ assertLaunchable(input);
49
+ const child = spawn(input.executable, input.args, { cwd: input.root, env: { ...input.environment },
50
+ shell: false, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
51
+ const signalGroup = signal => { if (!closed && child.pid) { try { process.kill(-child.pid, signal); } catch {} } };
52
+ const terminate = () => {
53
+ if (terminating || closed) return; terminating = true; signalGroup('SIGTERM');
54
+ killTimer = setTimeout(() => signalGroup('SIGKILL'), 250);
55
+ };
56
+ const consume = (target, hash, chunk) => {
57
+ const data = Buffer.from(chunk); hash.update(data);
58
+ const keep = Math.max(0, Math.min(data.length, input.maxOutputBytes - bytes));
59
+ if (keep) target.push(data.subarray(0, keep)); bytes += keep; dropped += data.length - keep;
60
+ if (dropped) terminate();
61
+ };
62
+ const timer = setTimeout(() => { timedOut = true; terminate(); }, Math.max(1, input.deadlineAt - Date.now()));
63
+ process.once('disconnect', terminate); process.once('SIGTERM', terminate); process.once('SIGINT', terminate);
64
+ child.stdout.on('data', chunk => consume(stdout, stdoutHash, chunk)); child.stderr.on('data', chunk => consume(stderr, stderrHash, chunk));
65
+ child.once('spawn', () => { spawned = true; processStartedAt = new Date().toISOString(); });
66
+ child.once('error', error => { spawnError = error; terminate(); });
67
+ child.once('exit', () => signalGroup('SIGKILL'));
68
+ child.once('close', (exitCode, signal) => {
69
+ closed = true; clearTimeout(timer); clearTimeout(killTimer);
70
+ process.removeListener('disconnect', terminate); process.removeListener('SIGTERM', terminate); process.removeListener('SIGINT', terminate);
71
+ const processResult = { schemaVersion: RUNNER_SCHEMA,
72
+ status: exitCode === 0 && signal === null && !timedOut && !dropped && !spawnError ? 'succeeded' : 'failed',
73
+ exitCode, signal, timedOut, outputLimitExceeded: dropped > 0, durationMs: Math.max(0, Math.round(performance.now() - started)),
74
+ stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'),
75
+ stdoutSha256: stdoutHash.digest('hex'), stderrSha256: stderrHash.digest('hex'), capturedBytes: bytes, droppedBytes: dropped,
76
+ ...(input.objectPlan ? { started: spawned, startedAt: processStartedAt, finishedAt: new Date().toISOString(),
77
+ executionDeadlineExceeded: timedOut && input.leaseDeadline } : {}) };
78
+ deliver(resultMessage(processResult, spawnError));
79
+ });
80
+ if (!process.connected) terminate();
81
+ }
82
+
83
+ function main() {
84
+ if (process.argv.length !== 2 || typeof process.send !== 'function' || !process.connected) { process.exitCode = 2; return; }
85
+ const startup = setTimeout(() => { if (process.connected) process.disconnect(); }, 5_000);
86
+ const disconnect = () => clearTimeout(startup); process.once('disconnect', disconnect);
87
+ process.once('message', input => {
88
+ clearTimeout(startup); process.removeListener('disconnect', disconnect);
89
+ if (!process.connected) return;
90
+ if (!validInput(input)) {
91
+ deliver(resultMessage(null, { code: 'ENTROROVE_PROCESS_SUPERVISOR', message: 'Approved process supervision requires one bounded frozen command' })); return;
92
+ }
93
+ try { supervise(input); }
94
+ catch (error) { deliver(resultMessage(null, error.code === 'ENTROROVE_WORKFLOW_EXPIRED' ? error
95
+ : { code: 'ENTROROVE_PROCESS_SUPERVISOR', message: 'Approved process supervision could not launch its frozen command' })); }
96
+ });
97
+ }
98
+
99
+ main();
package/local-cli.mjs CHANGED
@@ -28,6 +28,10 @@ import { DISCIPLINE_LOCAL_OPERATIONS } from './local-discipline-operations.mjs';
28
28
  import { assertIdeExecution, settleBreakGlassSession } from './ide-session.mjs';
29
29
  import { runManagedWorkflowLocalStep } from './workflow-local-client.mjs';
30
30
  import { localErrorEnvelope } from './local-error.mjs';
31
+ import { preparePreservedMutationSigner } from './local-preserved-mutation-signer.mjs';
32
+ import { hydrateCloudMutationAuthority } from './cloud-mutation-authority.mjs';
33
+ import { attestMutationConsumption14 } from './local-mutation-consumption.mjs';
34
+ import { apiUrl } from './remote-client.mjs';
31
35
 
32
36
  async function stdinJson() {
33
37
  let raw = '';
@@ -40,6 +44,8 @@ async function stdinJson() {
40
44
  }
41
45
 
42
46
  const operations = {
47
+ 'mutation-consumption-attest': attestMutationConsumption14,
48
+ 'mutation-delegate-prepare': preparePreservedMutationSigner,
43
49
  ...DISCIPLINE_LOCAL_OPERATIONS,
44
50
  'snapshot-plan': snapshotPlan, 'snapshot-verify': snapshotVerify, 'mutation-snapshot-attest': attestMutationSnapshot14, 'mutate-gate': mutateGate,
45
51
  'gate-issue': issueMutationPass, 'gate-verify': verifyMutationPass, 'guarded-write': guardedWrite,
@@ -79,7 +85,11 @@ function capabilitiesFor(group) {
79
85
  }
80
86
 
81
87
  async function checkedLocalInput(operation, input) {
82
- try { return validateLocalInput(operation, input); }
88
+ try {
89
+ const validated = validateLocalInput(operation, input);
90
+ if (['mutation-snapshot-attest', 'mutation-consumption-attest', 'workflow-run-step', 'mutate-gate', 'gate-issue', 'gate-verify', 'guarded-write', 'guarded-delete', 'guarded-move', 'discipline-review', 'discipline-check'].includes(operation)) await hydrateCloudMutationAuthority(input.home, new URL(apiUrl).origin);
91
+ return validated;
92
+ }
83
93
  catch (cause) {
84
94
  if (['guarded-write', 'guarded-delete', 'guarded-move', 'gate-issue', 'mutate-gate', 'mutation-snapshot-attest'].includes(operation)) await recordMutationDenied14(input, cause, operation);
85
95
  throw cause;
@@ -94,9 +104,10 @@ export async function runLocal(args = [], dependencies) {
94
104
  process.stdout.write(`${JSON.stringify(capabilitiesFor(args[1]))}\n`); return;
95
105
  }
96
106
  const handler = operations[operation];
97
- if (!handler) throw new Error(`Usage: entrorove local <${['capabilities', ...Object.keys(operations)].join('|')}> [repositoryRoot]`);
107
+ if (!handler && process.stdin.isTTY) throw new Error(`Unsupported local operation: ${operation}`);
98
108
  const input = await stdinJson();
99
109
  await assertIdeExecution(['local', ...args], input);
110
+ if (!handler) throw new Error(`Usage: entrorove local <${['capabilities', ...Object.keys(operations)].join('|')}> [repositoryRoot]`);
100
111
  if (args[1] && input.repositoryRoot === undefined) input.repositoryRoot = args[1];
101
112
  const validated = await checkedLocalInput(operation, input);
102
113
  const supplied = operation === 'workflow-run-step' || Object.hasOwn(LOCAL_CHAIN_OPERATIONS, operation) ? dependencies : undefined;
@@ -29,6 +29,8 @@ add('flow-cancel|flow-abandon', flowLifecycle, ['repositoryRoot', 'flowId']);
29
29
  add('snapshot-plan', { ...step, paths, snapshotId: identifier, contractDigest: nullable(sha256), handoffCandidate: HANDOFF_SNAPSHOT_CANDIDATE_SCHEMA }, ['repositoryRoot', 'flowId', 'stepId', 'paths']);
30
30
  add('snapshot-verify', { ...step, snapshotId: identifier }, ['repositoryRoot', 'flowId', 'stepId', 'snapshotId']);
31
31
  const gate = { ...step, taskId: identifier, mutationPass: record };
32
+ add('mutation-delegate-prepare', { ...step, taskId: identifier }, ['repositoryRoot', 'home', 'flowId']);
33
+ add('mutation-consumption-attest', { ...step, taskId: identifier, gatePassPath: text }, ['repositoryRoot', 'flowId', 'stepId', 'gatePassPath']);
32
34
  add('mutate-gate|gate-issue', gate, ['repositoryRoot', 'flowId', 'stepId', 'mutationPass']);
33
35
  add('mutation-snapshot-attest', { ...step, taskId: identifier, snapshotId: identifier, challenge: record, signerConfigPath: text },
34
36
  ['repositoryRoot', 'flowId', 'stepId', 'snapshotId', 'challenge', 'signerConfigPath']);