@entrorove/skill 0.1.37 → 0.1.38

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 (57) hide show
  1. package/CAPABILITY-MAP.md +11 -0
  2. package/CHANGELOG.md +5 -0
  3. package/IDE-EXECUTION.md +93 -0
  4. package/REUSE-DISCIPLINE.md +18 -0
  5. package/SKILL.md +22 -93
  6. package/cli.mjs +11 -7
  7. package/cloud-task-client.mjs +3 -1
  8. package/ide-code-mode.mjs +38 -0
  9. package/ide-command.mjs +76 -0
  10. package/ide-hook.mjs +167 -0
  11. package/ide-install.mjs +59 -0
  12. package/ide-presentation.mjs +6 -0
  13. package/ide-results.mjs +59 -0
  14. package/ide-session.mjs +86 -0
  15. package/local-approved-plan.mjs +21 -6
  16. package/local-archguard-ast.mjs +6 -1
  17. package/local-archguard-language-ast.mjs +7 -0
  18. package/local-cli.mjs +6 -1
  19. package/local-contracts.mjs +4 -1
  20. package/local-discipline-actions.mjs +71 -0
  21. package/local-discipline-analysis.mjs +153 -0
  22. package/local-discipline-batch.mjs +32 -0
  23. package/local-discipline-contracts.d.mts +6 -0
  24. package/local-discipline-contracts.mjs +62 -0
  25. package/local-discipline-gate.mjs +85 -0
  26. package/local-discipline-native.mjs +33 -0
  27. package/local-discipline-operations.mjs +39 -0
  28. package/local-discipline-repository.mjs +87 -0
  29. package/local-discipline-snapshot.mjs +29 -0
  30. package/local-equivalence-runner.mjs +33 -0
  31. package/local-error.mjs +10 -0
  32. package/local-execution.mjs +1 -0
  33. package/local-integrity.d.mts +5 -0
  34. package/local-integrity.mjs +4 -0
  35. package/local-merge-controller.mjs +1 -0
  36. package/local-merge-rollback.mjs +2 -1
  37. package/local-mutation-authority.mjs +16 -7
  38. package/local-mutation-batch.mjs +7 -3
  39. package/local-mutation-files.mjs +22 -0
  40. package/local-mutation-snapshot.mjs +6 -3
  41. package/local-mutation-wire.mjs +6 -1
  42. package/local-runner-discipline.mjs +23 -0
  43. package/local-scope-repository.mjs +16 -0
  44. package/local-task-handoff-write-receipts.mjs +11 -0
  45. package/manifest.json +1 -1
  46. package/package.json +33 -4
  47. package/release-state.json +55 -28
  48. package/release.mjs +3 -1
  49. package/releases.json +6 -0
  50. package/remote-client.mjs +8 -0
  51. package/reuse-discipline-release.mjs +18 -0
  52. package/runtime/validator-runtime-v7.0.44.d.mts +3 -0
  53. package/runtime/validator-runtime-v7.0.44.mjs +1362 -0
  54. package/snapshot-cleanup-journal.mjs +32 -0
  55. package/snapshot-retention.mjs +95 -0
  56. package/task-entry-client.mjs +2 -2
  57. package/validator-runtime-build.mjs +16 -0
package/ide-hook.mjs ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { ideSession, ideRuntime, bindIdeAccount, ideContext, ideDigest } from './ide-session.mjs';
5
+ import { literalCommand, sdkCommand, readCommand, renderSdkCommand, installerCommand } from './ide-command.mjs';
6
+ import { inspectIdeCodeMode } from './ide-code-mode.mjs';
7
+ import { pathToFileURL } from 'node:url';
8
+ import path from 'node:path';
9
+ import { parseIdeResults, consumeIdeResults } from './ide-results.mjs';
10
+
11
+ const output = (event, extra = {}) => ({ hookSpecificOutput: { hookEventName: event, ...extra } });
12
+ const deny = reason => output('PreToolUse', { permissionDecision: 'deny', permissionDecisionReason: reason });
13
+ const publicId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,200}$/u.test(value);
14
+ const readTools = new Set(['wait', 'view_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource', 'web_search', 'update_plan', 'request_user_input', 'request_user_input_async']);
15
+ function collectResult(state, values, local) {
16
+ const messages = [];
17
+ for (const result of values) {
18
+ const value = result.output ?? result;
19
+ if (typeof value.requestId === 'string' && /^[a-f0-9]{64}$/u.test(value.requestId)) state.requestId = value.requestId;
20
+ if (!local && value.flowId && value.requestId) state.remoteRequestId = value.requestId;
21
+ if (publicId(value.flowId)) {
22
+ if (state.flowId && state.flowId !== value.flowId) throw new Error('Returned flow replaced the original task');
23
+ state.flowId = value.flowId;
24
+ }
25
+ if (typeof value.taskNumber === 'string' && /^[0-9]{5}$/u.test(value.taskNumber)) {
26
+ if (state.taskId && state.taskId !== value.taskId) throw new Error('Returned cloud task replaced the original task');
27
+ state.taskId = value.taskId; state.taskNumber = value.taskNumber;
28
+ }
29
+ const nextStep = value.nextStep ?? value.result?.nextStep ?? value.result?.result?.nextStep;
30
+ if (value.status === 'needs-input' || value.result?.status === 'needs-input' || value.interaction || value.result?.interaction) state.phase = 'needs-input';
31
+ else if (['blocked', 'failed', 'incomplete'].includes(value.status) || ['blocked', 'failed', 'incomplete'].includes(value.result?.status)
32
+ || ['blocked', 'failed'].includes(value.taskStatus)) state.phase = 'blocked';
33
+ else if (nextStep || value.nextAction) state.phase = 'continuing';
34
+ else if (!local && (value.reports?.length || value.result?.reports?.length || ['completed','cancelled'].includes(value.taskStatus))
35
+ && result.feedback?.acknowledgement?.stored === true) state.phase = 'ended';
36
+ else if (local && value.status === 'verified') state.phase = 'continuing';
37
+ else if (value.status === 'started' || value.status === 'succeeded') state.phase = 'continuing';
38
+ if (nextStep) state.nextStep = { operation: nextStep.operation, payload: nextStep.payload };
39
+ const notices = [...(result.ideMessages ?? []), ...(value.ideMessages ?? []),
40
+ ...(value.schemaVersion === 'entrorove.ide-event/1.0' ? [value.message] : []), ...(value.events ?? []).map(item => item.message)];
41
+ for (const message of notices) {
42
+ if (typeof message !== 'string' || !message || message.length > 12000) continue;
43
+ const key = ideDigest({ taskId: state.taskId, message, receiptId: result.feedback?.receiptId });
44
+ if (!state.seen.includes(key)) { state.seen.push(key); messages.push(message); }
45
+ }
46
+ if (result.feedback?.acknowledgement?.stored === true) state.feedbackAcknowledged = true;
47
+ }
48
+ state.seen = state.seen.slice(-200);
49
+ return messages;
50
+ }
51
+ async function beforeTool(event, state, home) {
52
+ const command = event.tool_input?.command ?? event.tool_input?.cmd, parsed = literalCommand(command);
53
+ const isShell = ['Bash', 'exec_command', 'shell_command'].includes(event.tool_name);
54
+ const installer = isShell && installerCommand(parsed);
55
+ if (installer) {
56
+ if (state.active) {
57
+ state.readCalls ??= {};
58
+ state.readCalls[event.tool_use_id] = { toolUseId: event.tool_use_id, command: 'install' };
59
+ }
60
+ return output('PreToolUse', { permissionDecision: 'allow', updatedInput: { ...event.tool_input, command: installer,
61
+ ...(Object.hasOwn(event.tool_input ?? {}, 'cmd') ? { cmd: installer } : {}) } });
62
+ }
63
+ const namedSdk = parsed?.words.some(word => /^@entrorove\/skill(?:@[^ ]+)?$/u.test(word)
64
+ || /(?:@entrorove\/skill|entrorove-skill)\/cli\.mjs$/u.test(word));
65
+ if (!state.active && !namedSdk) return {};
66
+ const runtime = isShell && parsed ? await ideRuntime(home) : null;
67
+ const call = runtime && sdkCommand(parsed, runtime);
68
+ if (call) {
69
+ state.active = true;
70
+ if (call.args[0] !== 'configure') await bindIdeAccount(state, home);
71
+ const newStart = call.args[0] === 'cloud-task' || call.args[0] === 'invoke' && ['start','cloud-task'].includes(call.input?.operation);
72
+ if (newStart && (state.flowId || state.taskId) && state.phase !== 'ended') return deny('Entrorove:必须继续原任务,禁止用第二次 start 或 cloud-task 绕过原门禁。');
73
+ const discovery = ['invoke','local'].includes(call.args[0]) && ['capabilities','help'].includes(call.input?.operation ?? call.args[1]);
74
+ const observer = discovery || ['status','feedback','cloud-status','configure','agreement','install'].includes(call.args[0]);
75
+ if (call.args[0] === 'local' && !discovery && !state.flowId) return deny('Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
76
+ if (state.pendingCall && !observer) return deny('Entrorove:原调用尚未收到结束回执;先只读恢复其状态,禁止并发重发。');
77
+ const input = call.args[0] === 'invoke' ? call.input?.input : call.input;
78
+ 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 deny('Entrorove:调用与本 IDE 任务的原编号不一致。');
79
+ if (call.args[0] === 'scope-connect' && call.args[1] !== state.repositoryRoot
80
+ || call.args[0] === 'local' && (input?.repositoryRoot && input.repositoryRoot !== state.repositoryRoot || call.args[2] && call.args[2] !== state.repositoryRoot)) return deny('Entrorove:调用不能替换本任务的实际仓库。');
81
+ let requestId;
82
+ if (call.args[0] === 'invoke' && !call.input.operation.startsWith('cloud-')) {
83
+ const { buildInvocation } = await import(pathToFileURL(path.join(path.dirname(runtime.cli), 'remote-client.mjs')).href);
84
+ requestId = buildInvocation(call.input.operation, call.input.input).requestId;
85
+ }
86
+ const pending = { toolUseId: event.tool_use_id, inputDigest: ideDigest(call), command: call.args[0], ...(requestId ? { requestId } : {}) };
87
+ if (observer) {
88
+ state.readCalls ??= {};
89
+ if (Object.keys(state.readCalls).length >= 32) return deny('Entrorove:只读恢复尚未返回,请等待原调用。');
90
+ state.readCalls[event.tool_use_id] = pending;
91
+ } else state.pendingCall = pending;
92
+ const installation = call.args[0] === 'install' && parsed.words.some(word => ['npx','npx.cmd'].includes(word));
93
+ const rewritten = renderSdkCommand(call, runtime, state.sessionId);
94
+ return output('PreToolUse', { permissionDecision: 'allow', ...(installation ? {} : { updatedInput: { ...event.tool_input, command: rewritten,
95
+ ...(Object.hasOwn(event.tool_input ?? {}, 'cmd') ? { cmd: rewritten } : {}) } }), additionalContext: ideContext(state) });
96
+ }
97
+ if (!state.active) return {};
98
+ if (['exec','functions.exec'].includes(event.tool_name)) {
99
+ const current = await ideRuntime(home), code = typeof event.tool_input === 'string' ? event.tool_input : event.tool_input?.code;
100
+ return inspectIdeCodeMode(code, current) ? output('PreToolUse', { additionalContext: ideContext(state) }) : deny('Entrorove:代码模式含未批准的原生执行或动态工具输入;请使用原任务的受控入口。');
101
+ }
102
+ if (isShell && readCommand(parsed) || readTools.has(event.tool_name)) return output('PreToolUse', { additionalContext: ideContext(state) });
103
+ return deny(`Entrorove:已阻止原生 ${event.tool_name ?? 'unknown'} 调用。当前阶段 ${state.phase}${state.taskNumber ? `,云端任务 ${state.taskNumber}` : ''}。写入、删除、移动及不确定的终端操作必须使用原任务的 guarded-write/guarded-delete/guarded-move 或已批准的执行计划;门禁未通过不得改换执行器。`);
104
+ }
105
+ export async function runIdeHook(event, { home } = {}) {
106
+ return ideSession(event, async state => {
107
+ const name = event.hook_event_name;
108
+ if (name === 'UserPromptSubmit' && /^(?:(?:请)?(?:停止使用|退出)\s*\$?entrorove|(?:stop using|exit)\s+\$?entrorove)[.!。!]?$/iu.test((event.prompt ?? '').trim())) {
109
+ state.active = false; state.phase = 'user-exited';
110
+ return output(name, { additionalContext: '用户明确退出 Entrorove 管理;原任务仍保留,未验收、未评分为成功。' });
111
+ }
112
+ if (name === 'UserPromptSubmit' && /\bentrorove\b/iu.test(event.prompt ?? '')) {
113
+ state.active = true;
114
+ if (state.phase === 'ended') { delete state.flowId; delete state.taskId; delete state.taskNumber; delete state.requestId; state.phase = 'entry'; }
115
+ }
116
+ if (name === 'PreToolUse') return beforeTool(event, state, home);
117
+ const pendingRead = state.readCalls?.[event.tool_use_id];
118
+ if (name === 'PostToolUse' && state.active && (state.pendingCall?.toolUseId === event.tool_use_id || pendingRead)) {
119
+ const pending = pendingRead ?? state.pendingCall;
120
+ const recorded = await consumeIdeResults(event, state, pending, home);
121
+ const visible = parseIdeResults(event.tool_response);
122
+ const values = [...recorded, ...visible];
123
+ if (['install','configure','agreement'].includes(pending.command)) {
124
+ delete state.readCalls[event.tool_use_id];
125
+ const configured = values.some(value => pending.command === 'install' ? /^\d+\.\d+\.\d+$/u.test(value.version ?? '') && value.ide?.configured === true
126
+ : pending.command === 'configure' ? value.configured === true : value.accepted === true);
127
+ if (!state.flowId && !state.taskId && !state.pendingCall && configured) state.phase = 'setup';
128
+ return output(name, { additionalContext: ideContext(state) });
129
+ }
130
+ if (!values.length) {
131
+ if (pendingRead) { delete state.readCalls[event.tool_use_id]; return output(name, { additionalContext: ideContext(state) }); }
132
+ state.phase = 'unknown';
133
+ if (pending.requestId) state.requestId = pending.requestId;
134
+ return { ...output(name, { additionalContext: `${ideContext(state)}原请求结果未知,只能 GET 恢复其 requestId,禁止再次 POST。` }), systemMessage: 'Entrorove 原调用尚无可验证结果,已保留待恢复记录。' };
135
+ }
136
+ const expectedRequest = state.pendingCall?.requestId ?? state.remoteRequestId ?? state.requestId;
137
+ const bound = pendingRead ? values.filter(result => {
138
+ const value = result.output ?? result;
139
+ return value.requestId === expectedRequest || state.taskId && value.taskId === state.taskId;
140
+ }) : values;
141
+ const recoveredOriginal = pendingRead && state.pendingCall?.requestId
142
+ && values.some(result => (result.output ?? result).requestId === state.pendingCall.requestId);
143
+ const messages = collectResult(state, bound, pending.command === 'local');
144
+ if (pendingRead) delete state.readCalls[event.tool_use_id];
145
+ if (!pendingRead || recoveredOriginal) delete state.pendingCall;
146
+ return { ...output(name, { additionalContext: ideContext(state) }), ...(messages.length ? { systemMessage: messages.join('\n') } : {}) };
147
+ }
148
+ if (name === 'Stop' && state.active && !['ended','blocked','needs-input','setup'].includes(state.phase)) return { decision: 'block', reason: `${ideContext(state)}本轮没有取得原任务的结束回执,必须继续返回的操作或明确报告阻断原因。` };
149
+ return state.active && ['SessionStart','UserPromptSubmit','PostCompact','PreCompact'].includes(name) ? output(name, { additionalContext: ideContext(state) }) : {};
150
+ }, home);
151
+ }
152
+ let incomingEvent;
153
+ async function main() {
154
+ let raw = '';
155
+ for await (const chunk of process.stdin) { raw += chunk; if (raw.length > 1_000_000) throw new Error('Host hook input is too large'); }
156
+ const event = JSON.parse(raw); incomingEvent = event.hook_event_name;
157
+ const result = await runIdeHook(event);
158
+ process.stdout.write(`${JSON.stringify(result)}\n`);
159
+ }
160
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(() => {
161
+ // A non-zero generic exit may fail open in the host. Always return a supported
162
+ // explicit denial, without including arguments, prompts, credentials or paths.
163
+ const reason = 'Entrorove 安全检查失败;本次工具调用已阻止,请修复原检查。';
164
+ const result = incomingEvent === 'Stop' ? { decision: 'block', reason } : incomingEvent === 'PreToolUse' ? deny(reason)
165
+ : { continue: false, stopReason: reason, systemMessage: reason };
166
+ process.stdout.write(`${JSON.stringify(result)}\n`);
167
+ });
@@ -0,0 +1,59 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { ideDirectory, ideDigest, readIdeJson, writeIdeJson, ideError } from './ide-session.mjs';
5
+ import { withStateLock } from './local-state-lock.mjs';
6
+
7
+ const source = path.dirname(fileURLToPath(import.meta.url));
8
+ const files = ['ide-hook.mjs', 'ide-session.mjs', 'ide-results.mjs', 'ide-command.mjs', 'ide-code-mode.mjs', 'remote-transport.mjs', 'local-credential.mjs', 'local-state-lock.mjs'];
9
+ const events = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostCompact', 'Stop'];
10
+ const quote = text => `'${text.replace(/'/gu, "'\\''")}'`;
11
+ function definition(node, file) {
12
+ const command = `${quote(node)} ${quote(file)}`;
13
+ return { hooks: Object.fromEntries(events.map(event => [event, [{ hooks: [{ type: 'command', command,
14
+ timeout: 10, statusMessage: 'Entrorove · 检查任务', additionalContextLimit: 1500 }] }]])) };
15
+ }
16
+ async function prepareConfigDirectory(home) {
17
+ const directory = path.join(home, '.codex');
18
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
19
+ const stat = await fs.lstat(directory);
20
+ if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== 'win32' && stat.uid !== process.getuid()) throw ideError('ENTROROVE_IDE_CONFIG', 'Codex config must be an owned real directory');
21
+ return directory;
22
+ }
23
+ function mergeHooks(existing, previous, current) {
24
+ if (existing && (typeof existing !== 'object' || Array.isArray(existing) || existing.hooks && typeof existing.hooks !== 'object')) throw ideError('ENTROROVE_IDE_CONFIG', 'Existing hooks config is invalid; it was preserved');
25
+ const next = { ...existing, hooks: { ...existing?.hooks } };
26
+ for (const event of events) {
27
+ const old = previous?.hooks?.[event]?.[0], entries = next.hooks[event] ?? [];
28
+ if (!Array.isArray(entries)) throw ideError('ENTROROVE_IDE_CONFIG', 'Existing hook list is invalid; it was preserved');
29
+ const ours = entries.filter(entry => old && JSON.stringify(entry) === JSON.stringify(old));
30
+ if (previous && !ours.length && entries.some(entry => entry.hooks?.some(hook => hook.command === old?.hooks?.[0]?.command))) throw ideError('ENTROROVE_IDE_CONFIG_EDITED', 'A hand-edited Entrorove hook was preserved; review it before updating');
31
+ next.hooks[event] = [...entries.filter(entry => !ours.includes(entry)), current.hooks[event][0]];
32
+ }
33
+ return next;
34
+ }
35
+ export async function installIdeHooks({ home, version, packageDirectory = source, node = process.execPath }) {
36
+ const directory = await ideDirectory(home, true), configDirectory = await prepareConfigDirectory(home);
37
+ const hooksPath = path.join(configDirectory, 'hooks.json'), recordPath = path.join(directory, 'installation.json');
38
+ return withStateLock(hooksPath, async () => {
39
+ const existing = await readIdeJson(hooksPath), previous = await readIdeJson(recordPath);
40
+ const current = definition(node, path.join(directory, 'ide-hook.mjs'));
41
+ const config = mergeHooks(existing, previous?.definition, current);
42
+ const contents = await Promise.all(files.map(async name => [name, await fs.readFile(path.join(source, name))]));
43
+ for (const [name, bytes] of contents) {
44
+ const target = path.join(directory, name), stat = await fs.lstat(target).catch(cause => { if (cause.code !== 'ENOENT') throw cause; return null; });
45
+ if (stat && (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)) throw ideError('ENTROROVE_IDE_STORE', 'Managed hook target must be a private regular file');
46
+ if (stat && previous?.files?.[name] && ideDigest((await fs.readFile(target)).toString('utf8')) !== previous.files[name]) throw ideError('ENTROROVE_IDE_HOOK_EDITED', 'Hand-edited hook code was preserved');
47
+ await fs.writeFile(target, bytes, { mode: 0o600 });
48
+ }
49
+ const cli = path.join(packageDirectory, 'cli.mjs');
50
+ await writeIdeJson(path.join(directory, 'runtime.json'), { schemaVersion: 'entrorove.ide-runtime/1.0', version, node, cli,
51
+ apiOrigin: 'https://entrorove.com',
52
+ cliDigest: ideDigest((await fs.readFile(cli)).toString('utf8')) });
53
+ if (JSON.stringify(existing) !== JSON.stringify(config)) await writeIdeJson(hooksPath, config);
54
+ await writeIdeJson(recordPath, { schemaVersion: 'entrorove.ide-installation/1.0', version, definition: current,
55
+ files: Object.fromEntries(contents.map(([name, bytes]) => [name, ideDigest(bytes.toString('utf8'))])) });
56
+ return { host: 'Codex', configured: true, enforcement: 'review-required', hooksPath,
57
+ message: '已安装持续执行检查。请在 Codex /hooks 审查并信任 Entrorove;实际收到 PreToolUse 回调后才算拦截已生效。' };
58
+ });
59
+ }
@@ -7,6 +7,12 @@ export function ideClientHeaders(environment = process.env) {
7
7
  if (typeof ide !== 'string' || !/^[\p{L}\p{N} .+_-]{2,48}$/u.test(ide)) throw new Error('Invalid ENTROROVE_IDE client name');
8
8
  return { 'x-entrorove-ide': ide };
9
9
  }
10
+ export function ideTaskIdentity(supplied, environment = process.env) {
11
+ if (environment.CODEX_THREAD_ID && supplied && supplied !== environment.CODEX_THREAD_ID) {
12
+ throw new Error('Cloud task must retain the actual Codex task identity');
13
+ }
14
+ return environment.CODEX_THREAD_ID ?? supplied;
15
+ }
10
16
  export function displayIdeEvent(event, sink, notifications) {
11
17
  if (sink) sink(event);
12
18
  else process.stderr.write(`${event.message}\n${event.detail ? event.detail + '\n' : ''}`);
@@ -0,0 +1,59 @@
1
+ import path from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
+ import { ideDirectory, ideDigest, ideResultContext, readIdeJson, writeIdeJson } from './ide-session.mjs';
4
+
5
+ export function parseIdeResults(response) {
6
+ const raw = typeof response === 'string' ? response : response?.output ?? response?.stdout ?? response?.aggregated_output
7
+ ?? response?.content?.filter(item => item.type === 'text').map(item => item.text).join('\n');
8
+ if (typeof raw !== 'string') return [];
9
+ return raw.split(/\r?\n/u).flatMap(line => {
10
+ try { const value = JSON.parse(line); return value && typeof value === 'object' ? [value] : []; }
11
+ catch { return []; }
12
+ });
13
+ }
14
+ function resultMetadata(value, depth = 0) {
15
+ if (!value || typeof value !== 'object' || depth > 3) return undefined;
16
+ const metadata = {};
17
+ for (const key of ['schemaVersion', 'requestId', 'flowId', 'status', 'taskId', 'taskNumber', 'taskStatus']) {
18
+ if (typeof value[key] === 'string' && value[key].length <= 200) metadata[key] = value[key];
19
+ }
20
+ for (const key of ['output', 'result']) if (value[key]) metadata[key] = resultMetadata(value[key], depth + 1);
21
+ for (const key of ['nextStep', 'nextAction']) if (typeof value[key]?.operation === 'string') metadata[key] = { operation: value[key].operation };
22
+ if (value.interaction) metadata.interaction = true;
23
+ if (Array.isArray(value.reports)) metadata.reports = value.reports.map(report => ({ verdict: report.verdict }));
24
+ if (value.feedback?.acknowledgement?.stored === true) metadata.feedback = { receiptId: value.feedback.receiptId, acknowledgement: { stored: true } };
25
+ return metadata;
26
+ }
27
+ const resultFile = (directory, sessionId, toolUseId) => path.join(directory, `result-${ideDigest({ sessionId, toolUseId })}.json`);
28
+ export async function persistIdeResults(context, values) {
29
+ if (!context || !values.length) return;
30
+ const directory = await ideDirectory(context.home);
31
+ await writeIdeJson(resultFile(directory, context.sessionId, context.toolUseId), {
32
+ schemaVersion: 'entrorove.ide-result/1.0', sessionId: context.sessionId, repositoryRoot: context.repositoryRoot,
33
+ toolUseId: context.toolUseId, inputDigest: context.inputDigest, accountDigest: context.accountDigest,
34
+ values: values.map(value => resultMetadata(value)), createdAt: new Date().toISOString()
35
+ });
36
+ }
37
+ export async function consumeIdeResults(event, state, pending, home) {
38
+ const file = resultFile(await ideDirectory(home), state.sessionId, event.tool_use_id), proof = await readIdeJson(file);
39
+ if (!proof) return [];
40
+ if (proof.schemaVersion !== 'entrorove.ide-result/1.0' || proof.sessionId !== state.sessionId
41
+ || proof.repositoryRoot !== state.repositoryRoot || proof.toolUseId !== pending.toolUseId
42
+ || proof.inputDigest !== pending.inputDigest || proof.accountDigest !== state.accountDigest
43
+ || !Array.isArray(proof.values)) throw new Error('CLI completion does not belong to this approved call');
44
+ await fs.unlink(file);
45
+ return proof.values;
46
+ }
47
+ export function captureIdeResults() {
48
+ const original = process.stdout.write, chunks = [];
49
+ let size = 0;
50
+ process.stdout.write = function(chunk, ...rest) {
51
+ size += Buffer.byteLength(chunk);
52
+ if (size <= 8_000_000) chunks.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk);
53
+ return original.call(this, chunk, ...rest);
54
+ };
55
+ return async () => {
56
+ process.stdout.write = original;
57
+ if (size <= 8_000_000) await persistIdeResults(ideResultContext(), parseIdeResults(chunks.join('')));
58
+ };
59
+ }
@@ -0,0 +1,86 @@
1
+ import { constants, promises as fs } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { credentialDirectory, readCredential } from './local-credential.mjs';
6
+ import { withStateLock } from './local-state-lock.mjs';
7
+ import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
8
+
9
+ export const ideDigest = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
10
+ export const ideError = (code, message) => Object.assign(new Error(message), { code });
11
+ let approvedResultContext;
12
+ export const ideResultContext = () => approvedResultContext;
13
+ export async function ideDirectory(home = os.homedir(), create = false) {
14
+ const parent = await credentialDirectory(home, create), directory = path.join(parent, 'ide');
15
+ if (create) await fs.mkdir(directory, { recursive: true, mode: 0o700 });
16
+ const stat = await fs.lstat(directory);
17
+ if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== 'win32' && (stat.uid !== process.getuid() || stat.mode & 0o077)) throw ideError('ENTROROVE_IDE_STORE', 'IDE state must be an owned private directory');
18
+ return directory;
19
+ }
20
+ export async function readIdeJson(file) {
21
+ let handle;
22
+ try {
23
+ handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
24
+ const stat = await handle.stat();
25
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1_000_000 || process.platform !== 'win32' && (stat.uid !== process.getuid() || stat.mode & 0o077)) throw ideError('ENTROROVE_IDE_STORE', 'IDE state is not a private regular file');
26
+ return JSON.parse(await handle.readFile('utf8'));
27
+ } catch (cause) { if (cause.code === 'ENOENT') return null; throw cause; }
28
+ finally { await handle?.close(); }
29
+ }
30
+ export async function writeIdeJson(file, value) {
31
+ const stage = `${file}.${randomUUID()}.tmp`;
32
+ await fs.writeFile(stage, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
33
+ try { await fs.rename(stage, file); } finally { await fs.rm(stage, { force: true }); }
34
+ }
35
+ export async function ideSession(event, action, home = os.homedir()) {
36
+ if (typeof event.session_id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/u.test(event.session_id) || !path.isAbsolute(event.cwd ?? '')) throw ideError('ENTROROVE_IDE_IDENTITY', 'Host hook requires its actual session and repository');
37
+ const directory = await ideDirectory(home, true), file = path.join(directory, `session-${ideDigest(event.session_id)}.json`);
38
+ return withStateLock(file, async () => {
39
+ const root = await fs.realpath(event.cwd), prior = await readIdeJson(file);
40
+ if (prior && (prior.schemaVersion !== 'entrorove.ide-session/1.0' || prior.sessionId !== event.session_id || prior.repositoryRoot !== root)) throw ideError('ENTROROVE_IDE_IDENTITY', 'A session cannot replace its original physical repository');
41
+ const state = prior ?? { schemaVersion: 'entrorove.ide-session/1.0', sessionId: event.session_id, repositoryRoot: root, active: false, phase: 'entry', seen: [] };
42
+ const result = await action(state);
43
+ await writeIdeJson(file, { ...state, observedAt: new Date().toISOString() });
44
+ return result;
45
+ });
46
+ }
47
+ export async function bindIdeAccount(state, home = os.homedir()) {
48
+ const token = await readCredential(home), digest = ideDigest(token);
49
+ if (state.accountDigest && state.accountDigest !== digest) {
50
+ const runtime = await ideRuntime(home);
51
+ const originalRequest = state.pendingCall?.requestId ?? state.remoteRequestId ?? state.requestId;
52
+ const route = state.taskId ? `/api/cloud-sandbox/tasks/${state.taskId}` : originalRequest ? `/api/skill/status/${originalRequest}` : null;
53
+ if (!route) throw ideError('ENTROROVE_IDE_ACCOUNT', 'Cannot prove the rotated key owns the original task');
54
+ const response = await requestTransport(runtime.apiOrigin ?? 'https://entrorove.com', route,
55
+ { headers: { authorization: `Bearer ${token}` }, timeoutMs: 5000 });
56
+ const value = parseTransportResponse(response, 'original account binding');
57
+ if (!response.ok || (state.taskId ? value.task?.id !== state.taskId : value.output?.requestId !== originalRequest
58
+ || state.flowId && value.output?.flowId !== state.flowId)) throw ideError('ENTROROVE_IDE_ACCOUNT', 'Account changed; the original task cannot inherit another account');
59
+ }
60
+ state.accountDigest = digest;
61
+ }
62
+ export async function ideRuntime(home = os.homedir()) {
63
+ const directory = await ideDirectory(home), runtime = await readIdeJson(path.join(directory, 'runtime.json'));
64
+ if (!runtime || runtime.schemaVersion !== 'entrorove.ide-runtime/1.0' || !path.isAbsolute(runtime.node ?? '') || !path.isAbsolute(runtime.cli ?? '') || !/^\d+\.\d+\.\d+$/u.test(runtime.version ?? '')) throw ideError('ENTROROVE_IDE_RUNTIME', 'Install the Entrorove IDE runtime before executing');
65
+ const cli = await fs.readFile(runtime.cli);
66
+ if (ideDigest(cli.toString('utf8')) !== runtime.cliDigest) throw ideError('ENTROROVE_IDE_RUNTIME', 'Installed CLI changed; reinstall the managed runtime');
67
+ return runtime;
68
+ }
69
+ export function ideContext(state) {
70
+ return `Entrorove 已强制接管本任务。${state.taskNumber ? `云端任务 ${state.taskNumber}。` : '尚未获得云端任务编号,不得声称已进入云端。'}${state.flowId ? `继续原 flowId=${state.flowId},` : ''}${state.requestId ? `原 requestId=${state.requestId}。` : ''}当前阶段 ${state.phase}。必须读 SKILL.md 并执行返回的 nextStep;禁止在失败后替换执行器。源文件、数据及终端写入必须走原任务的受控入口。`;
71
+ }
72
+ export async function assertIdeExecution(args, input, { home = os.homedir(), environment = process.env } = {}) {
73
+ const host = environment.ENTROROVE_IDE ?? (environment.CODEX_THREAD_ID ? 'Codex' : environment.CURSOR_TRACE_ID ? 'Cursor' : environment.CLAUDE_CODE_ENTRYPOINT ? 'Claude Code' : null);
74
+ if (!host || host === 'Entrorove CLI') return;
75
+ if (host !== 'Codex' || !environment.CODEX_THREAD_ID) throw ideError('ENTROROVE_IDE_GUARD_UNSUPPORTED', 'This IDE has no verified tool guard adapter. Execution is blocked; never continue with another executor.');
76
+ const directory = await ideDirectory(home), state = await readIdeJson(path.join(directory, `session-${ideDigest(environment.CODEX_THREAD_ID)}.json`));
77
+ const expected = ideDigest({ args, input });
78
+ const approved = [state?.pendingCall, ...Object.values(state?.readCalls ?? {})].find(call => call?.inputDigest === expected);
79
+ if (!state?.active || !approved || state.sessionId !== environment.CODEX_THREAD_ID
80
+ || Date.now() - Date.parse(state.observedAt) > 120_000) {
81
+ throw ideError('ENTROROVE_IDE_GUARD_REQUIRED', 'Codex tool guard has not approved this exact call. Run install, review Entrorove in /hooks, and resume the original task.');
82
+ }
83
+ await bindIdeAccount(state, home);
84
+ approvedResultContext = { home, sessionId: state.sessionId, repositoryRoot: state.repositoryRoot,
85
+ accountDigest: state.accountDigest, toolUseId: approved.toolUseId, inputDigest: expected };
86
+ }
@@ -6,6 +6,9 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { performance } from 'node:perf_hooks';
8
8
  import { assertBudgetRunnerAllowed } from './local-budget-reads.mjs';
9
+ import { validateRunnerParallelPaths, captureRunnerEquivalence } from './local-equivalence-runner.mjs';
10
+ import { disciplineApprovedPolicy, disciplineDataSnapshotsBound } from './local-discipline-contracts.mjs';
11
+ import { runnerDisciplineBefore, runnerDisciplineAfter } from './local-runner-discipline.mjs';
9
12
 
10
13
  export const RUNNER_SCHEMA = 'entrorove.validator-local-runner/1.0';
11
14
  export const PLAN_SCHEMA = 'validator.execution-plan/1.0';
@@ -33,7 +36,15 @@ function validatePlan(plan, artifactDigest = planArtifact) {
33
36
  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');
34
37
  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'); }
35
38
  if (artifactDigest(plan.files) !== plan.artifactSha256) fail('ENTROROVE_PLAN_MANIFEST', 'artifact manifest digest mismatch');
36
- const policy = exactObject(plan.policy, ['executable', 'executableSha256', 'args', 'environment', 'timeoutMs', 'maxOutputBytes', 'requiredExitCode'], 'execution policy');
39
+ const policy = exactObject(plan.policy, ['executable', 'executableSha256', 'args', 'environment', 'timeoutMs', 'maxOutputBytes', 'requiredExitCode',
40
+ ...['parallelPaths', 'discipline'].filter(key => Object.hasOwn(plan.policy, key))], 'execution policy');
41
+ validateRunnerParallelPaths(plan);
42
+ if (policy.discipline) {
43
+ const reviewed = disciplineApprovedPolicy(policy.discipline);
44
+ if (!disciplineDataSnapshotsBound(reviewed, plan.files)) {
45
+ fail('ENTROROVE_DISCIPLINE_DATA_SNAPSHOT_REQUIRED', 'Existing data operations require their actual frozen snapshot artifact before execution');
46
+ }
47
+ }
37
48
  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');
38
49
  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');
39
50
  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');
@@ -95,13 +106,16 @@ function runCommand(authority) {
95
106
  });
96
107
  });
97
108
  }
98
- 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, ...(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 } : {}) } : {}) }, goldenBaseline: authority.plan.goldenBaseline, contracts: authority.plan.contracts, execution: { exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, outputLimitExceeded: result.outputLimitExceeded } }; }
109
+ async function subject(authority, result) {
110
+ const equivalence = await captureRunnerEquivalence(authority, result, projectFile);
111
+ 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 } }; }
99
112
  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: authority.plan.evidencePolicy?.kind ?? 'test', runner: authority.signing ? authority.plan.evidencePolicy?.runner ?? 'trusted-runner' : 'local', ...(authority.plan.evidencePolicy ? { flowId: authority.plan.evidencePolicy.flowId, stepId: authority.plan.evidencePolicy.stepId } : {}), command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), exitCode: result.exitCode === null ? 1 : result.exitCode, durationMs: result.durationMs, summary, artifactSha256: authority.plan.artifactSha256, subject: sub, subjectDigest: digest(canonical(sub)) };
100
113
  if (!authority.signing || !Number.isInteger(base.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 === null ? 1 : result.exitCode, durationMs: result.durationMs, summary } }; const receipt = { ...receiptCore, signature: sign(null, Buffer.from(canonical(receiptCore)), privateKey).toString('base64url') }; return { ...base, receipt };
101
114
  }
102
115
 
103
- 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; }
104
- 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' };
116
+ export async function runApprovedPlan(input) { const owner = await authority(input); const comparison = await runnerDisciplineBefore(input, owner); const result = await runCommand(owner); try { const after = await authority(input); if (after.planFile.sha256 !== owner.planFile.sha256) fail('ENTROROVE_PLAN_CHANGED', 'plan changed during execution'); } catch (cause) { const execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', error: cause.message, process: result, evidence: [] }; const error = new Error(cause.message); error.code = cause.code; error.execution = execution; throw error; }
117
+ owner.disciplineComparison = await runnerDisciplineAfter(comparison, owner);
118
+ const sub = await subject(owner, result); const item = evidence(owner, result, sub); const status = !owner.signing && result.status === 'succeeded' ? 'incomplete' : result.status; return { schemaVersion: RUNNER_SCHEMA, status, planSha256: owner.planFile.sha256, approvalSha256: owner.approval.sha256, approvedBy: owner.approval.approval.approvedBy, subject: sub, process: result, evidence: [item], receipt: item.receipt ?? null, ...(owner.signing ? { receiptPublicKey: createPublicKey(owner.signing.privateKey).export({ format: 'der', type: 'spki' }).toString('base64') } : {}), signingBoundary: owner.signing ? 'configured-signature-only' : 'local-evidence-only' };
105
119
  }
106
120
 
107
121
  async function objectAuthority(input, afterExecution = false) {
@@ -123,13 +137,14 @@ async function objectAuthority(input, afterExecution = false) {
123
137
  }
124
138
 
125
139
  export async function runApprovedPlanObject(input) {
126
- const owner = await objectAuthority(input); const result = await runCommand(owner);
140
+ const owner = await objectAuthority(input); const comparison = await runnerDisciplineBefore(input, owner); const result = await runCommand(owner);
127
141
  try { const after = await objectAuthority(input, true); if (after.planFile.sha256 !== owner.planFile.sha256) fail('ENTROROVE_PLAN_CHANGED', 'plan changed during execution'); }
128
142
  catch (cause) {
129
143
  const error = new Error(cause.message); error.code = cause.code;
130
144
  error.execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', error: cause.message, process: result, evidence: [], effects: 'unknown' }; throw error;
131
145
  }
132
- const sub = subject(owner, result); const item = evidence(owner, result, sub);
146
+ owner.disciplineComparison = await runnerDisciplineAfter(comparison, owner);
147
+ const sub = await subject(owner, result); const item = evidence(owner, result, sub);
133
148
  return { schemaVersion: RUNNER_SCHEMA, status: !owner.signing && result.status === 'succeeded' ? 'incomplete' : result.status,
134
149
  planSha256: owner.planFile.sha256, approvalSha256: owner.approval.sha256, approvedBy: owner.approval.approval.approvedBy,
135
150
  subject: sub, process: result, evidence: [item], receipt: item.receipt ?? null,
@@ -285,8 +285,13 @@ function syntaxDiagnostics(source) {
285
285
  target: ts.ScriptTarget.Latest, jsx: ts.JsxEmit.Preserve }, host);
286
286
  return program.getSyntacticDiagnostics(source);
287
287
  }
288
+ export function archguardScriptSource(file) {
289
+ const kind = SCRIPT_KINDS.get(file.path.split('.').at(-1));
290
+ if (kind === undefined) return null;
291
+ return ts.createSourceFile(file.path, file.content, ts.ScriptTarget.Latest, true, kind);
292
+ }
288
293
  function analyzeScriptFile(file, rules) {
289
- const source = ts.createSourceFile(file.path, file.content, ts.ScriptTarget.Latest, true, SCRIPT_KINDS.get(file.path.split('.').at(-1)));
294
+ const source = archguardScriptSource(file);
290
295
  const diagnostics = syntaxDiagnostics(source).map(diagnostic => {
291
296
  const at = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
292
297
  return { code: diagnostic.code, start: diagnostic.start ?? 0, end: (diagnostic.start ?? 0) + (diagnostic.length ?? 0),
@@ -7,6 +7,13 @@ const grammars = new Map();
7
7
  for (const language of ['css', 'python', 'java', 'rust']) {
8
8
  grammars.set(language, await Parser.Language.load(require.resolve(`tree-sitter-wasms/out/tree-sitter-${language}.wasm`)));
9
9
  }
10
+ export async function withArchguardSyntaxTree(file, language, inspect) {
11
+ if (!['css','python','java','rust','php'].includes(language)) throw new Error('ARCH-AST-LANGUAGE-UNSUPPORTED');
12
+ if (!grammars.has(language)) grammars.set(language, await Parser.Language.load(require.resolve(`tree-sitter-wasms/out/tree-sitter-${language}.wasm`)));
13
+ const parser = new Parser(); parser.setLanguage(grammars.get(language));
14
+ const tree = parser.parse(file.content);
15
+ try { return inspect(tree.rootNode); } finally { tree.delete(); parser.delete(); }
16
+ }
10
17
  const EXTENSIONS = new Map([['css', 'css'], ['wxss', 'css'], ['py', 'python'], ['java', 'java'], ['rs', 'rust']]);
11
18
  const ROOTS = { css: 'stylesheet', python: 'module', java: 'program', rust: 'source_file' };
12
19
  const FUNCTIONS = { css: [], python: ['function_definition', 'lambda'], java: ['method_declaration', 'constructor_declaration', 'lambda_expression'],
package/local-cli.mjs CHANGED
@@ -23,6 +23,8 @@ import { LOCAL_BUDGET_TRANSITION_OPERATIONS } from './local-budget-transition.mj
23
23
  import { ARCHGUARD_LOCAL_OPERATIONS } from './local-archguard-operations.mjs';
24
24
  import { LOCAL_BUDGET_HANDOFF_OPERATIONS } from './local-budget-handoff.mjs';
25
25
  import { LOCAL_CHAIN_OPERATIONS } from './local-chain-operations.mjs';
26
+ import { DISCIPLINE_LOCAL_OPERATIONS } from './local-discipline-operations.mjs';
27
+ import { assertIdeExecution } from './ide-session.mjs';
26
28
  import { runManagedWorkflowLocalStep } from './workflow-local-client.mjs';
27
29
  import { localErrorEnvelope } from './local-error.mjs';
28
30
 
@@ -37,6 +39,7 @@ async function stdinJson() {
37
39
  }
38
40
 
39
41
  const operations = {
42
+ ...DISCIPLINE_LOCAL_OPERATIONS,
40
43
  'snapshot-plan': snapshotPlan, 'snapshot-verify': snapshotVerify, 'mutation-snapshot-attest': attestMutationSnapshot14, 'mutate-gate': mutateGate,
41
44
  'gate-issue': issueMutationPass, 'gate-verify': verifyMutationPass, 'guarded-write': guardedWrite,
42
45
  'budget-init': budgetInit, 'budget-context': budgetContext, 'budget-read': budgetRead, 'budget-status': budgetStatus,
@@ -76,7 +79,7 @@ function capabilitiesFor(group) {
76
79
  async function checkedLocalInput(operation, input) {
77
80
  try { return validateLocalInput(operation, input); }
78
81
  catch (cause) {
79
- if (['guarded-write', 'gate-issue', 'mutate-gate', 'mutation-snapshot-attest'].includes(operation)) await recordMutationDenied14(input, cause, operation);
82
+ if (['guarded-write', 'guarded-delete', 'guarded-move', 'gate-issue', 'mutate-gate', 'mutation-snapshot-attest'].includes(operation)) await recordMutationDenied14(input, cause, operation);
80
83
  throw cause;
81
84
  }
82
85
  }
@@ -91,6 +94,7 @@ export async function runLocal(args = [], dependencies) {
91
94
  const handler = operations[operation];
92
95
  if (!handler) throw new Error(`Usage: entrorove local <${['capabilities', ...Object.keys(operations)].join('|')}> [repositoryRoot]`);
93
96
  const input = await stdinJson();
97
+ await assertIdeExecution(['local', ...args], input);
94
98
  if (args[1] && input.repositoryRoot === undefined) input.repositoryRoot = args[1];
95
99
  const validated = await checkedLocalInput(operation, input);
96
100
  const supplied = operation === 'workflow-run-step' || Object.hasOwn(LOCAL_CHAIN_OPERATIONS, operation) ? dependencies : undefined;
@@ -105,6 +109,7 @@ export async function runLocal(args = [], dependencies) {
105
109
  export async function runLocalInput(operation, input, dependencies) {
106
110
  if (operation === 'capabilities' || operation === 'help') { validateLocalInput(operation, input); return LOCAL_CAPABILITIES; }
107
111
  const handler = operations[operation];
112
+ await assertIdeExecution(['local', operation], input);
108
113
  if (!handler) throw new Error(`Unsupported local operation: ${operation}`);
109
114
  const validated = await checkedLocalInput(operation, input);
110
115
  const supplied = operation === 'workflow-run-step' || Object.hasOwn(LOCAL_CHAIN_OPERATIONS, operation) ? dependencies : undefined;
@@ -8,6 +8,7 @@ import { MERGE_LOCAL_OPERATION_SCHEMAS } from './local-merge-contracts.mjs';
8
8
  import { WORKFLOW_LOCAL_OPERATION_SCHEMA } from './workflow-local-contracts.mjs';
9
9
  import { HANDOFF_WRITE_PASS_SCHEMA, HANDOFF_PROVISIONAL_LEASE_SCHEMA, HANDOFF_SNAPSHOT_CANDIDATE_SCHEMA } from './local-task-handoff-contracts.mjs';
10
10
  import { LOCAL_CHAIN_OPERATION_SCHEMAS } from './local-chain-contracts.mjs';
11
+ import { DISCIPLINE_DECLARATION_SCHEMA } from './local-discipline-contracts.mjs';
11
12
  import { array, binding, change, confirmation, enumeration, evidence, evidenceArray, executionContext, executionPlan,
12
13
  formula, formulaDefinitions, formulaEntry, decimalValues, identifier, integer, ledgerIdentifier, modes, nullable, object, paths, record, relativePath, sha256, text } from './local-contract-types.mjs';
13
14
 
@@ -31,7 +32,9 @@ add('mutation-snapshot-attest', { ...step, taskId: identifier, snapshotId: ident
31
32
  ['repositoryRoot', 'flowId', 'stepId', 'snapshotId', 'challenge', 'signerConfigPath']);
32
33
  const write = { ...step, taskId: identifier, generation: integer(1), executionAttemptNonce: identifier, gatePassPath: text, targetPath: relativePath, workerId: identifier, leaseId: identifier };
33
34
  add('gate-verify', write, ['repositoryRoot', 'flowId', 'stepId', 'gatePassPath', 'targetPath']);
34
- add('guarded-write', { ...write, content: {}, expectedSha256: nullable(sha256) }, ['repositoryRoot', 'flowId', 'stepId', 'gatePassPath', 'targetPath', 'content']);
35
+ add('discipline-review|discipline-check', write, ['repositoryRoot', 'flowId', 'stepId', 'gatePassPath', 'targetPath']);
36
+ add('guarded-delete|guarded-move', { ...write, declaration: DISCIPLINE_DECLARATION_SCHEMA }, ['repositoryRoot', 'flowId', 'stepId', 'gatePassPath', 'targetPath', 'declaration']);
37
+ add('guarded-write', { ...write, content: {}, expectedSha256: nullable(sha256), declaration: DISCIPLINE_DECLARATION_SCHEMA }, ['repositoryRoot', 'flowId', 'stepId', 'gatePassPath', 'targetPath', 'content']);
35
38
  schemas['guarded-write'] = { oneOf: [schemas['guarded-write'], object({ ...step, targetPath: relativePath, content: {},
36
39
  expectedSha256: nullable(sha256), handoffId: ledgerIdentifier, epoch: integer(1, 2147483647),
37
40
  handoffPass: HANDOFF_WRITE_PASS_SCHEMA, provisionalLease: HANDOFF_PROVISIONAL_LEASE_SCHEMA },