@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/release.mjs CHANGED
@@ -28,7 +28,26 @@ const coordination17SourceFiles = Object.freeze([
28
28
  'local-validator-isolation17.mjs',
29
29
  'local-calctool-composition17.mjs',
30
30
  ])
31
+ const preservedMutationSourceFiles = Object.freeze([
32
+ 'local-preserved-mutation-protection.mjs', 'local-preserved-mutation-signer.mjs',
33
+ 'local-preserved-mutation-state.mjs', 'local-preserved-mutation-wire.mjs',
34
+ 'local-delegated-modify-analysis.mjs', 'mutation-client.mjs', 'cloud-mutation-authority.mjs',
35
+ ])
36
+ const memberContinuationSourceFiles = Object.freeze([
37
+ 'connected-cli.mjs',
38
+ 'local-approved-process.mjs',
39
+ 'local-approved-supervisor.mjs',
40
+ 'local-mutation-consumption.mjs',
41
+ 'workflow-observation-client.mjs',
42
+ 'workflow-observation-connection.mjs',
43
+ 'workflow-observation-remote.mjs',
44
+ 'workflow-observation-runner.mjs',
45
+ 'workflow-observation-state.mjs',
46
+ 'workflow-observation-wire.mjs',
47
+ ])
31
48
  const sourceFiles = [
49
+ ...memberContinuationSourceFiles,
50
+ ...preservedMutationSourceFiles,
32
51
  ...REUSE_DISCIPLINE_SOURCE_FILES,
33
52
  ...FINAL_GATE16_PACKAGE_FILES,
34
53
  'local-merge-archguard-verification.d.mts',
@@ -65,6 +84,8 @@ const sourceFiles = [
65
84
  'local-scope-context.mjs',
66
85
  'local-scope-files.mjs',
67
86
  'local-scope-repository.mjs',
87
+ 'local-scope-create-require.mjs',
88
+ 'local-scope-require-resolution.mjs',
68
89
  'local-scope-git.mjs',
69
90
  'local-scope-git-reads.mjs',
70
91
  'local-git-executable.mjs',
@@ -212,6 +233,12 @@ const calctoolReleaseLabels = Object.freeze({
212
233
  })
213
234
 
214
235
  const mutationReleaseLabels = Object.freeze({
236
+ ...Object.fromEntries(memberContinuationSourceFiles.map(file => [file, {
237
+ zh: '原任务连续编辑与本地测试观察', en: 'original-task continuation and local command observation',
238
+ }])),
239
+ ...Object.fromEntries(preservedMutationSourceFiles.map(file => [file, {
240
+ zh: '保留运行时依赖的受限本地编辑授权', en: 'restricted local editing with preserved runtime dependencies',
241
+ }])),
215
242
  'local-mutation-files.mjs': { zh: '原任务短时写入授权与文件快照隔离', en: 'Original task short-lived mutation authority and file snapshot isolation' },
216
243
  'local-mutation-snapshot.mjs': { zh: '原任务短时写入授权与文件快照隔离', en: 'Original task short-lived mutation authority and file snapshot isolation' },
217
244
  'local-mutation-audit.mjs': { zh: '原任务短时写入授权与文件快照隔离', en: 'Original task short-lived mutation authority and file snapshot isolation' },
@@ -340,6 +367,8 @@ function releaseLabels() {
340
367
  'local-scope-context.mjs': { zh: '精确符号范围绑定', en: 'exact symbol scope binding' },
341
368
  'local-scope-files.mjs': { zh: '范围文件与安全读取', en: 'scope files and guarded reads' },
342
369
  'local-scope-repository.mjs': { zh: '真实仓库范围事实', en: 'actual repository scope facts' },
370
+ 'local-scope-create-require.mjs': { zh: '静态 createRequire 原目录绑定', en: 'static createRequire source-directory binding' },
371
+ 'local-scope-require-resolution.mjs': { zh: '实际 CommonJS 依赖只读解析', en: 'read-only installed CommonJS dependency resolution' },
343
372
  'local-scope-config-imports.mjs': { zh: 'tsconfig 范围路径与依赖只读引用', en: 'tsconfig scope paths and read-only dependency references' },
344
373
  'local-scope-config-extends.mjs': { zh: 'tsconfig npm 配置继承解析', en: 'tsconfig npm configuration inheritance resolution' },
345
374
  'local-scope-git.mjs': { zh: '实际Git范围与变更事实', en: 'actual Git scope and change facts' },
package/releases.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.1.54",
4
+ "date": "2026-10-17",
5
+ "zh": "修复受管 Node 只读 SHA-256 命令在 Codex 工具门禁中的静态识别与安全改写;按已证明的 createRequire 原目录解析实际安装依赖并记录只读元数据,保留未知上下文和越界拒绝。",
6
+ "en": "Fixed static recognition and safe rewriting of managed Node SHA-256 reads in Codex tool guards; resolved installed dependencies from proven createRequire anchors with read-only metadata while preserving unknown-context and path-boundary refusals."
7
+ },
8
+ {
9
+ "version": "0.1.53",
10
+ "date": "2026-10-17",
11
+ "zh": "补齐原任务受限编辑、真实写后结算与连续修改,保留未知依赖和独立验证边界;沙箱关闭后可运行原批准命令并保存本地观察。修复前置条件、断线恢复和停止循环,独立监督器约束崩溃后的子进程。本地成功不代表独立验证或交付。",
12
+ "en": "Added original-task restricted editing, real write settlement and subsequent modifications while preserving unknown dependencies and independent validation. With cloud sandbox disabled, retain actual observations of the approved command. Fixed prerequisite recovery, disconnections and Stop loops; an independent supervisor bounds child processes after CLI failure. Local success does not certify independent verification or delivery."
13
+ },
2
14
  {
3
15
  "version": "0.1.52",
4
16
  "date": "2026-10-17",
package/remote-client.mjs CHANGED
@@ -37,7 +37,8 @@ function discoveredAjv() {
37
37
  export function buildInvocation(operation, input) {
38
38
  if (!['capabilities', 'task-describe', 'message-route', 'message-accept', 'task-resume', 'task-checkpoint', 'flow-cancel', 'flow-abandon',
39
39
  'message-status', 'message-delivery-start', 'message-delivery-report', 'message-resolve', 'message-complete',
40
- 'handoff-prepare', 'handoff-release', 'handoff-claim', 'handoff-status', 'handoff-resume', 'handoff-execution-authority', 'mutation-snapshot-challenge', 'mutation-pass-issue',
40
+ 'handoff-prepare', 'handoff-release', 'handoff-claim', 'handoff-status', 'handoff-resume', 'handoff-execution-authority', 'mutation-snapshot-challenge', 'mutation-pass-issue', 'mutation-pass-settle',
41
+ 'member-observation-admit', 'member-observation-report', 'member-observation-status',
41
42
  'scope-runner-prepare', 'scope-runner-register', 'scope-runner-challenge', 'scope-runner-attest',
42
43
  'start', 'continue'].includes(operation)) throw new Error('Invalid entrorove operation');
43
44
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Input must be a JSON object');
@@ -0,0 +1,44 @@
1
+ import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
2
+ import { observationInput } from './workflow-observation-wire.mjs';
3
+
4
+ const safeId = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
5
+ const safeRequest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
6
+ const messages = {
7
+ ENTROROVE_LOCAL_OBSERVATION_UNKNOWN: 'Original observation outcome is unknown; recover its original GET without repeating execution',
8
+ ENTROROVE_LOCAL_OBSERVATION_NOT_SENT: 'Observation was not transmitted; repair its local or discovery prerequisite and reuse the original input',
9
+ ENTROROVE_LOCAL_OBSERVATION_PREREQUISITE: 'Local observation requires cloud sandbox disabled and its original live execution lease',
10
+ ENTROROVE_SCOPE_RECEIPT_EXPIRED: 'Refresh the original scope evidence and reuse the original observation input',
11
+ ENTROROVE_SCOPE_AFTER_WRITE_REQUIRED: 'Observe the settled modifications in fresh scope evidence before local testing',
12
+ };
13
+ function blocked(error, input) {
14
+ const cause = error && typeof error === 'object' ? error : {};
15
+ const code = typeof cause.code === 'string' && /^ENTROROVE_[A-Z0-9_]{1,100}$/.test(cause.code)
16
+ ? cause.code : 'ENTROROVE_LOCAL_OBSERVATION';
17
+ const output = cause.output, requestId = safeRequest(cause.requestId) ? cause.requestId : undefined;
18
+ const recoverable = output?.status === 'blocked' && output.recoverable === true
19
+ && (output.observationPrerequisite?.effect === 'none' || ['ENTROROVE_SCOPE_RECEIPT_EXPIRED',
20
+ 'ENTROROVE_SCOPE_RUNNER_REQUIRED', 'ENTROROVE_SCOPE_SNAPSHOT_REQUIRED', 'ENTROROVE_SCOPE_AFTER_WRITE_REQUIRED'].includes(code));
21
+ const reason = ['sandbox-enabled', 'lease-expired', 'receipt-expired', 'after-write-observation-required'].includes(output?.reason)
22
+ ? output.reason : undefined;
23
+ return { schemaVersion: 'entrorove.local-observation-connection/1.0', status: 'blocked',
24
+ ...(safeId(input?.flowId) ? { flowId: input.flowId } : {}), independentValidation: 'pending', code,
25
+ message: messages[code] ?? 'Local observation could not continue; its original command and result remain preserved',
26
+ ...(recoverable ? { prerequisite: { status: 'blocked', code, recoverable: true, ...(reason ? { reason } : {}),
27
+ nextAction: { operation: 'workflow-observe-step', reuseOriginalInput: true,
28
+ ...(safeRequest(output.requestId) ? { resumeRequestId: output.requestId } : {}) } } } : {}),
29
+ ...(cause.outcomeUnknown === true && requestId ? { outcomeUnknown: true, requestId, repeatPost: false,
30
+ nextAction: { operation: 'workflow-observe-step', reuseOriginalInput: true, method: 'GET' } } : {}),
31
+ ...(cause.knownBeforeTransmission === true && requestId ? { requestId, knownBeforeTransmission: true } : {}) };
32
+ }
33
+ export async function runWorkflowObserveStep(repositoryRoot, input, dependencies) {
34
+ try {
35
+ observationInput(input);
36
+ await dependencies.install();
37
+ if (!await dependencies.agreementAccepted(dependencies.home)) return dependencies.agreementRequired(dependencies.home);
38
+ await bootstrapCloudMutationAuthority(dependencies);
39
+ const { connectWorkflowObservation } = await import('./workflow-observation-connection.mjs');
40
+ return await connectWorkflowObservation(repositoryRoot, input, dependencies);
41
+ } catch (error) {
42
+ return blocked(error, input);
43
+ }
44
+ }
@@ -0,0 +1,35 @@
1
+ import { canonical, sha256 } from './local-integrity.mjs';
2
+ import { withStateLock } from './local-state-lock.mjs';
3
+ import { trustedOrigin } from './remote-transport.mjs';
4
+ import { observationAdmission, observationFail } from './workflow-observation-wire.mjs';
5
+ import { observationLocation, readObservationState } from './workflow-observation-state.mjs';
6
+ import { runWorkflowObservation } from './workflow-observation-runner.mjs';
7
+ import { observationRemote, authorizeObservation } from './workflow-observation-remote.mjs';
8
+
9
+ async function connect(input, state, location, dependencies) {
10
+ const original = Object.fromEntries(['taskId', 'flowId', 'stepId', 'leaseId', 'generation', 'settlementId'].map(key => [key, input[key]]));
11
+ const admitted = state.admitted ?? await observationRemote('admitted', 'member-observation-admit', original, state, location, dependencies);
12
+ const admission = observationAdmission(admitted.admission, input, { allowExpired: Boolean(state.result) });
13
+ await authorizeObservation(admission, dependencies, !state.result);
14
+ const cached = Boolean(state.result), result = await runWorkflowObservation(input, admission, state, location,
15
+ { authorize: () => authorizeObservation(admission, dependencies, true) });
16
+ if (result.runner !== 'local' || result.receipt !== null || result.independentValidation !== 'pending'
17
+ || result.executionIsolation !== 'host-permissions' || result.externalEffects !== 'unverified'
18
+ || !['incomplete', 'failed'].includes(result.status) || result.admissionDigest !== sha256(canonical(admission))) {
19
+ observationFail('Local observation cannot replace independent validation or its original admitted result');
20
+ }
21
+ const reported = state.reported ?? await observationRemote('reported', 'member-observation-report', {
22
+ observationId: admission.observationId, admissionDigest: sha256(canonical(admission)), result,
23
+ }, state, location, dependencies);
24
+ if (reported.observationId !== admission.observationId || reported.observationStatus !== 'pending-independent'
25
+ || reported.resultDigest !== sha256(canonical(result)) || canonical(reported.result) !== canonical(result)) {
26
+ observationFail('Stored local observation differs from its actual original result');
27
+ }
28
+ return { ...result, taskId: input.taskId, flowId: input.flowId, stepId: input.stepId, cached, stored: true,
29
+ resultDigest: sha256(canonical(result)), grantsExecutionAuthority: false, grantsValidatorAuthority: false };
30
+ }
31
+ export async function connectWorkflowObservation(repositoryRoot, original, dependencies) {
32
+ const location = await observationLocation(repositoryRoot, original, dependencies.home, trustedOrigin(dependencies.apiOrigin()));
33
+ const input = { ...original, repositoryRoot: location.root, home: location.home };
34
+ return withStateLock(location.file, async () => connect(input, await readObservationState(location), location, dependencies));
35
+ }
@@ -0,0 +1,84 @@
1
+ import { canonical } from './local-integrity.mjs';
2
+ import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
3
+ import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
4
+ import { ideClientHeaders } from './ide-presentation.mjs';
5
+ import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFeedback } from './automatic-feedback-client.mjs';
6
+ import { saveObservationState } from './workflow-observation-state.mjs';
7
+ import { observationFail } from './workflow-observation-wire.mjs';
8
+ import { readInvocations } from './local-invocations.mjs';
9
+
10
+ const unknown = requestId => Object.assign(new Error('Original local observation request outcome is unknown; recover its GET without repeating execution'),
11
+ { code: 'ENTROROVE_LOCAL_OBSERVATION_UNKNOWN', outcomeUnknown: true, requestId });
12
+ const notSent = requestId => Object.assign(new Error('Original local observation was not transmitted'),
13
+ { code: 'ENTROROVE_LOCAL_OBSERVATION_NOT_SENT', knownBeforeTransmission: true, requestId });
14
+ async function knownNotSent(pending, home) {
15
+ try {
16
+ const records = (await readInvocations(home)).filter(value => value.kind === 'remote'
17
+ && value.operation === pending.operation && value.requestId === pending.requestId);
18
+ return !records.length || records.at(-1).event === 'not-sent';
19
+ } catch (error) { return error?.code === 'ENOENT'; }
20
+ }
21
+ async function recover(pending, dependencies) {
22
+ const token = await dependencies.readCredential(dependencies.home), origin = dependencies.apiOrigin();
23
+ let response, result;
24
+ try {
25
+ response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, { headers: {
26
+ authorization: `Bearer ${token}`, ...ideClientHeaders() } });
27
+ result = parseTransportResponse(response, 'original local observation status');
28
+ if (!response.ok || result?.output?.requestId !== pending.requestId
29
+ || ['pending', 'running'].includes(result.output.status)) throw unknown(pending.requestId);
30
+ result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
31
+ verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
32
+ result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
33
+ } catch { throw unknown(pending.requestId); }
34
+ return { response, result };
35
+ }
36
+ function prerequisite(output) {
37
+ return output?.status === 'blocked' && output.recoverable === true && (output.observationPrerequisite?.effect === 'none'
38
+ || ['ENTROROVE_SCOPE_RECEIPT_EXPIRED', 'ENTROROVE_SCOPE_RUNNER_REQUIRED', 'ENTROROVE_SCOPE_SNAPSHOT_REQUIRED',
39
+ 'ENTROROVE_SCOPE_AFTER_WRITE_REQUIRED'].includes(output.code));
40
+ }
41
+ export async function observationRemote(stage, operation, input, state, location, dependencies) {
42
+ const requestId = buildInvocation(operation, input).requestId;
43
+ let response, result;
44
+ if (state.pending) {
45
+ if (state.pending.stage !== stage || state.pending.operation !== operation || state.pending.requestId !== requestId
46
+ || canonical(state.pending.input) !== canonical(input)) observationFail('Original observation request must finish before another operation');
47
+ ({ response, result } = await recover(state.pending, dependencies));
48
+ } else {
49
+ state.pending = { stage, operation, input, requestId };
50
+ try { await saveObservationState(location, state); } catch { throw notSent(requestId); }
51
+ try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
52
+ catch {
53
+ if (await knownNotSent(state.pending, dependencies.home)) {
54
+ delete state.pending;
55
+ try { await saveObservationState(location, state); } catch { throw notSent(requestId); }
56
+ throw notSent(requestId);
57
+ }
58
+ throw unknown(requestId);
59
+ }
60
+ }
61
+ if (!response.ok || !['reported', 'succeeded'].includes(result?.output?.status)) {
62
+ if (operation === 'member-observation-admit' && prerequisite(result?.output)) {
63
+ delete state.pending; await saveObservationState(location, state);
64
+ throw Object.assign(new Error(result.output.message ?? 'Original local observation prerequisite is unresolved'),
65
+ { code: result.output.code, output: result.output });
66
+ }
67
+ throw unknown(requestId);
68
+ }
69
+ state[stage] = result.output; delete state.pending; await saveObservationState(location, state);
70
+ return result.output;
71
+ }
72
+ export async function authorizeObservation(admission, dependencies, launching) {
73
+ const token = await dependencies.readCredential(dependencies.home);
74
+ const response = await requestTransport(dependencies.apiOrigin(), `/api/skill/flows/${admission.flowId}/status`, {
75
+ headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
76
+ const status = parseTransportResponse(response, 'original local observation account');
77
+ if (!response.ok || status?.schemaVersion !== 'entrorove.flow-status/1.0'
78
+ || status.flow?.flowId !== admission.flowId || status.flow.ownerId !== admission.ownerId
79
+ || launching && (status.flow.stepId !== admission.stepId || status.flow.status !== 'running'
80
+ || status.workflow?.phase !== 'execute' || status.workflow.terminalAt
81
+ || ['pending', 'declined'].includes(status.workflow.authorization))) {
82
+ observationFail('Current account cannot confirm its original local observation task and execution step');
83
+ }
84
+ }
@@ -0,0 +1,87 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { scopeRead, scopeTarget } from './local-scope-files.mjs';
4
+ import { regular } from './local-task-handoff-storage.mjs';
5
+ import { assertBudgetRunnerAllowed, withBudgetReadContext } from './local-budget-reads.mjs';
6
+ import { disciplineRepositoryObservation, disciplineRepositoryDifferences } from './local-discipline-repository.mjs';
7
+ import { runApprovedProcess } from './local-approved-process.mjs';
8
+ import { observationAdmission, observationFail } from './workflow-observation-wire.mjs';
9
+ import { saveObservationState } from './workflow-observation-state.mjs';
10
+
11
+ async function observeFile(input, expected) {
12
+ const before = await scopeTarget(input.repositoryRoot, expected.path, false, true);
13
+ const read = await scopeRead(input.repositoryRoot, expected.path, input);
14
+ const after = await scopeTarget(input.repositoryRoot, expected.path, false, true);
15
+ if (read.sha256 !== expected.sha256 || expected.mode !== undefined && expected.mode !== null && (after.stat.mode & 511) !== expected.mode
16
+ || before.stat.dev !== after.stat.dev || before.stat.ino !== after.stat.ino || before.stat.mode !== after.stat.mode) {
17
+ observationFail('Local observation source or dependency differs from its admitted bytes and permissions');
18
+ }
19
+ return { path: expected.path, sha256: read.sha256, mode: after.stat.mode & 511,
20
+ realPath: await fs.realpath(after.target), dev: after.stat.dev, ino: after.stat.ino };
21
+ }
22
+ async function files(input, manifest) {
23
+ const result = [];
24
+ for (const expected of manifest) result.push(await observeFile(input, expected));
25
+ return result;
26
+ }
27
+ async function executable(admission) {
28
+ const { bytes, stat } = await regular(admission.command.executable, 256 * 1024 * 1024);
29
+ if (sha256(bytes) !== admission.command.executableSha256) observationFail('Local observation executable differs from its frozen command hash');
30
+ return { mode: stat.mode, dev: stat.dev, ino: stat.ino };
31
+ }
32
+ const same = (before, after) => canonical(before) === canonical(after);
33
+ async function remains(action, expected) { try { return same(expected, await action()); } catch { return false; } }
34
+ function processMetadata(process) {
35
+ return Object.fromEntries(['status', 'started', 'startedAt', 'finishedAt', 'exitCode', 'signal', 'timedOut',
36
+ 'outputLimitExceeded', 'durationMs', 'stdoutSha256', 'stderrSha256', 'capturedBytes', 'droppedBytes'].map(key => [key, process[key]]));
37
+ }
38
+ async function execute(input, admission, state, location, options) {
39
+ if (state.run) observationFail('Original local observation already started or has an unknown outcome; a new process is forbidden');
40
+ observationAdmission(admission, input);
41
+ await assertBudgetRunnerAllowed(input);
42
+ const originalSources = await files(input, admission.sourceManifest), originalProtected = await files(input, admission.protectedFiles);
43
+ const originalExecutable = await executable(admission);
44
+ const before = (await disciplineRepositoryObservation(input, input.repositoryRoot)).comparison;
45
+ observationAdmission(admission, input);
46
+ state.run = { phase: 'running', pid: process.pid, startedAt: new Date().toISOString() };
47
+ await saveObservationState(location, state);
48
+ let processResult;
49
+ try {
50
+ await assertBudgetRunnerAllowed(input);
51
+ if (!same(originalSources, await files(input, admission.sourceManifest))
52
+ || !same(originalProtected, await files(input, admission.protectedFiles))
53
+ || !same(originalExecutable, await executable(admission))) observationFail('Local observation inputs changed immediately before execution');
54
+ observationAdmission(admission, input);
55
+ if (options.authorize) await options.authorize();
56
+ observationAdmission(admission, input);
57
+ processResult = await runApprovedProcess({ root: input.repositoryRoot, policy: admission.command,
58
+ objectPlan: true, startDeadlineAt: admission.expiresAt });
59
+ } catch (error) {
60
+ if (!error?.execution?.process) {
61
+ state.run.phase = 'aborted'; await saveObservationState(location, state);
62
+ observationFail('Local observation stopped before a completed process; recover the original observation without rerunning');
63
+ }
64
+ processResult = error.execution.process;
65
+ }
66
+ let after = before, budgetAllowed = true;
67
+ try { await assertBudgetRunnerAllowed(input); after = (await disciplineRepositoryObservation(input, input.repositoryRoot)).comparison; }
68
+ catch { budgetAllowed = false; }
69
+ const checks = { sourceManifestUnchanged: budgetAllowed && await remains(() => files(input, admission.sourceManifest), originalSources),
70
+ protectedFilesUnchanged: budgetAllowed && await remains(() => files(input, admission.protectedFiles), originalProtected),
71
+ repositoryUnchanged: budgetAllowed && disciplineRepositoryDifferences(before, after).length === 0
72
+ && await remains(() => executable(admission), originalExecutable) };
73
+ state.result = { schemaVersion: 'entrorove.local-observation-result/1.0', observationId: admission.observationId,
74
+ admissionDigest: sha256(canonical(admission)), status: processResult.status === 'succeeded' && Object.values(checks).every(Boolean) ? 'incomplete' : 'failed',
75
+ independentValidation: 'pending', runner: 'local', receipt: null,
76
+ executionIsolation: 'host-permissions', externalEffects: 'unverified', process: processMetadata(processResult),
77
+ sourceManifestDigest: sha256(canonical(admission.sourceManifest)), checks,
78
+ beforeRepositoryDigest: sha256(canonical(before)), afterRepositoryDigest: sha256(canonical(after)) };
79
+ state.resultDigest = sha256(canonical(state.result)); state.run.phase = 'finished';
80
+ await saveObservationState(location, state);
81
+ return state.result;
82
+ }
83
+ export async function runWorkflowObservation(input, admission, state, location, options = {}) {
84
+ observationAdmission(admission, input, { allowExpired: Boolean(state.result) });
85
+ if (state.result) return state.result;
86
+ return withBudgetReadContext(input, () => execute(input, admission, state, location, options));
87
+ }
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { mutationRoot14, mutationHome14, mutationStateRoot14, privateMutationDirectory14,
4
+ readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
5
+ import { observationExact, observationFail } from './workflow-observation-wire.mjs';
6
+
7
+ export async function observationLocation(repositoryRoot, input, home, apiOrigin) {
8
+ const root = await mutationRoot14(repositoryRoot), actualHome = await mutationHome14(home);
9
+ const relative = path.relative(root, actualHome);
10
+ if (!relative || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
11
+ observationFail('Local observation state must be outside the observed repository');
12
+ }
13
+ const identity = { ...input, repositoryRoot: root, home: actualHome, apiOrigin };
14
+ const stateRoot = await mutationStateRoot14({ home: actualHome });
15
+ const directory = path.join(stateRoot, 'local-observations', sha256(root).slice(0, 32), input.flowId, sha256(canonical(identity)));
16
+ await privateMutationDirectory14(directory, { privateFrom: stateRoot });
17
+ return { root, home: actualHome, identity, stateRoot, file: path.join(directory, 'journal.json') };
18
+ }
19
+ export async function readObservationState(location) {
20
+ let state;
21
+ try { state = await readMutationJson14(location.file); }
22
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
23
+ if (!state) return { schemaVersion: 'entrorove.local-observation-state/1.0', identity: location.identity };
24
+ if (state.schemaVersion !== 'entrorove.local-observation-state/1.0' || canonical(state.identity) !== canonical(location.identity)) {
25
+ observationFail('Local observation journal lost its original repository, home, task or settlement');
26
+ }
27
+ if (state.result && state.resultDigest !== sha256(canonical(state.result))) observationFail('Local observation stored result integrity changed');
28
+ if (state.run) {
29
+ observationExact(state.run, ['phase', 'pid', 'startedAt']);
30
+ if (!['running', 'finished', 'aborted'].includes(state.run.phase) || !Number.isSafeInteger(state.run.pid)
31
+ || !Number.isFinite(Date.parse(state.run.startedAt))) observationFail('Local observation running state is invalid');
32
+ }
33
+ return state;
34
+ }
35
+ export const saveObservationState = (location, state) => atomicMutationJson14(location.file, state,
36
+ { privateFrom: location.stateRoot, replace: true });
@@ -0,0 +1,69 @@
1
+ import { createPublicKey, verify } from 'node:crypto';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { mutationId14, mutationPaths14 } from './local-mutation-files.mjs';
4
+
5
+ export const observationFail = message => { throw Object.assign(new Error(message), { code: 'ENTROROVE_LOCAL_OBSERVATION' }); };
6
+ export const observationIdentityFields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', 'settlementId'];
7
+ export function observationExact(value, fields) {
8
+ if (!value || typeof value !== 'object' || Array.isArray(value)
9
+ || canonical(Object.keys(value).sort()) !== canonical([...fields].sort())) observationFail('Local observation fields are invalid');
10
+ }
11
+ export function observationInput(input) {
12
+ observationExact(input, observationIdentityFields);
13
+ for (const field of observationIdentityFields.filter(field => field !== 'generation')) mutationId14(input[field], field);
14
+ if (!Number.isSafeInteger(input.generation) || input.generation < 1) observationFail('Local observation generation is invalid');
15
+ return input;
16
+ }
17
+ function command(value) {
18
+ observationExact(value, ['executable', 'executableSha256', 'args', 'environment', 'timeoutMs', 'maxOutputBytes', 'requiredExitCode']);
19
+ if (typeof value.executable !== 'string' || !value.executable.startsWith('/') || /[\u0000-\u001f\u007f]/.test(value.executable)
20
+ || !/^[a-f0-9]{64}$/.test(value.executableSha256) || !Array.isArray(value.args) || value.args.length > 128
21
+ || value.args.some(arg => typeof arg !== 'string' || arg.length > 4096 || arg.includes('\0'))
22
+ || !value.environment || typeof value.environment !== 'object' || Array.isArray(value.environment)
23
+ || Object.keys(value.environment).length > 64 || Object.entries(value.environment).some(([key, val]) =>
24
+ !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof val !== 'string' || val.length > 16_384 || val.includes('\0'))
25
+ || !Number.isSafeInteger(value.timeoutMs) || value.timeoutMs < 1 || value.timeoutMs > 300_000
26
+ || !Number.isSafeInteger(value.maxOutputBytes) || value.maxOutputBytes < 1 || value.maxOutputBytes > 1_048_576
27
+ || value.requiredExitCode !== 0) observationFail('Local observation command differs from its bounded frozen policy');
28
+ }
29
+ function files(values, modes) {
30
+ if (!Array.isArray(values) || !values.length || values.length > (modes ? 512 : 2_000)) observationFail('Local observation manifest is invalid');
31
+ const paths = mutationPaths14(values.map(file => file.path));
32
+ if (canonical(paths) !== canonical(values.map(file => file.path))) observationFail('Local observation manifest must be sorted and unique');
33
+ for (const file of values) {
34
+ observationExact(file, ['path', 'sha256', ...(modes ? ['mode'] : [])]);
35
+ if (!/^[a-f0-9]{64}$/.test(file.sha256) || modes && file.mode !== null
36
+ && (!Number.isSafeInteger(file.mode) || file.mode < 0 || file.mode > 511)) observationFail('Local observation file baseline is invalid');
37
+ }
38
+ }
39
+ function signature(value) {
40
+ try {
41
+ const encoded = process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY, der = Buffer.from(encoded ?? '', 'base64');
42
+ const key = createPublicKey({ key: der, type: 'spki', format: 'der' }), receipt = value.authorityReceipt;
43
+ observationExact(receipt, ['schemaVersion', 'keyId', 'payloadDigest', 'signature']);
44
+ const { authorityReceipt: _, ...core } = value, bytes = Buffer.from(canonical(core));
45
+ const signed = Buffer.from(receipt.signature, 'base64url');
46
+ if (key.asymmetricKeyType !== 'ed25519' || key.export({ type: 'spki', format: 'der' }).toString('base64') !== encoded
47
+ || receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0' || receipt.keyId !== sha256(der)
48
+ || receipt.payloadDigest !== sha256(bytes) || bytes.length > 1_048_576
49
+ || signed.length !== 64 || signed.toString('base64url') !== receipt.signature || !verify(null, bytes, key, signed)) throw new Error();
50
+ } catch { observationFail('Local observation requires its original pinned public cloud admission signature'); }
51
+ }
52
+ export function observationAdmission(value, input, { allowExpired = false } = {}) {
53
+ observationExact(value, [...observationIdentityFields, 'schemaVersion', 'purpose', 'observationId', 'ownerId', 'taskDigest',
54
+ 'chainDigest', 'epoch', 'planId', 'planDigest', 'executionPlanSha256', 'repositoryDigest', 'settlementDigest', 'command',
55
+ 'sourceManifest', 'protectedFiles', 'issuedAt', 'expiresAt', 'authorityReceipt']);
56
+ signature(value);
57
+ const start = Date.parse(value.issuedAt), end = Date.parse(value.expiresAt);
58
+ if (value.schemaVersion !== 'entrorove.local-observation-admission/1.0' || value.purpose !== 'observe-approved-command'
59
+ || value.epoch !== 0 || observationIdentityFields.some(field => value[field] !== input[field])
60
+ || value.repositoryDigest !== sha256(input.repositoryRoot).slice(0, 32) || !Number.isFinite(start) || !Number.isFinite(end)
61
+ || start > Date.now() || end <= start || end - start > 600_000 || !allowExpired && end <= Date.now()
62
+ || !/^[a-f0-9-]{36}$/.test(value.ownerId)
63
+ || ['taskDigest', 'chainDigest', 'planDigest', 'executionPlanSha256', 'settlementDigest'].some(field => !/^[a-f0-9]{64}$/.test(value[field]))) {
64
+ observationFail('Local observation admission is expired or lost its original task, repository or settlement');
65
+ }
66
+ mutationId14(value.observationId, 'observationId'); mutationId14(value.planId, 'planId');
67
+ command(value.command); files(value.sourceManifest, true); files(value.protectedFiles, false);
68
+ return value;
69
+ }