@entrorove/skill 0.1.22 → 0.1.24

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 (155) hide show
  1. package/AGREEMENT.md +6 -6
  2. package/CHANGELOG.md +10 -0
  3. package/SKILL.md +12 -4
  4. package/archguard-runtime-build.mjs +15 -0
  5. package/blueprint-criterion-runner.mjs +110 -0
  6. package/blueprint-criterion-wire.mjs +78 -0
  7. package/calctool-runtime-build.mjs +59 -0
  8. package/calctool-runtime-entry.mjs +92 -0
  9. package/cli.mjs +18 -83
  10. package/local-approved-plan.mjs +74 -15
  11. package/local-archguard-ast.mjs +371 -0
  12. package/local-archguard-checkpoint.mjs +123 -0
  13. package/local-archguard-components.mjs +177 -0
  14. package/local-archguard-contract.mjs +109 -0
  15. package/local-archguard-contracts.mjs +42 -0
  16. package/local-archguard-evaluate.mjs +120 -0
  17. package/local-archguard-language-ast.mjs +165 -0
  18. package/local-archguard-mutation-fence.mjs +28 -0
  19. package/local-archguard-operations.mjs +25 -0
  20. package/local-archguard-scope.mjs +64 -0
  21. package/local-archguard-signing.mjs +52 -0
  22. package/local-archguard-snapshot.mjs +126 -0
  23. package/local-archguard-state.mjs +200 -0
  24. package/local-archguard.mjs +74 -0
  25. package/local-budget-git-attributes.mjs +49 -0
  26. package/local-budget-git-loader.mjs +61 -0
  27. package/local-budget-git-native-attributes.mjs +81 -0
  28. package/local-budget-git-native-darwin.mjs +104 -0
  29. package/local-budget-git-native-linux.mjs +93 -0
  30. package/local-budget-git-native-reads.mjs +88 -0
  31. package/local-budget-git-native-request.mjs +53 -0
  32. package/local-budget-git-native-runtime.mjs +18 -0
  33. package/local-budget-git-native.c +313 -0
  34. package/local-budget-git-native.mjs +109 -0
  35. package/local-budget-git-outcome.mjs +33 -0
  36. package/local-budget-git-scratch-loader.mjs +158 -0
  37. package/local-budget-git-scratch.mjs +112 -0
  38. package/local-budget-git-system-attributes.mjs +44 -0
  39. package/local-budget-git.mjs +92 -0
  40. package/local-budget-grants.mjs +82 -0
  41. package/local-budget-handoff-completion.mjs +70 -0
  42. package/local-budget-handoff-contracts.mjs +53 -0
  43. package/local-budget-handoff.mjs +229 -0
  44. package/local-budget-lock.mjs +55 -0
  45. package/local-budget-reads.mjs +135 -0
  46. package/local-budget-renewal.mjs +86 -34
  47. package/local-budget-source-bindings.mjs +51 -0
  48. package/local-budget-store.mjs +131 -0
  49. package/local-budget-transition.mjs +46 -0
  50. package/local-budget.mjs +185 -0
  51. package/local-calctool-cli.mjs +22 -0
  52. package/local-calctool-contracts.mjs +42 -0
  53. package/local-calctool-runtime.mjs +10 -0
  54. package/local-calctool-storage.mjs +101 -0
  55. package/local-calctool.mjs +103 -0
  56. package/local-cli.mjs +49 -16
  57. package/local-contract-types.mjs +46 -0
  58. package/local-contracts.mjs +124 -0
  59. package/local-credential.mjs +101 -0
  60. package/local-decimal.mjs +85 -0
  61. package/local-evidence-verification.mjs +199 -0
  62. package/local-execution.mjs +115 -304
  63. package/local-final-gate16-bundle.mjs +21 -0
  64. package/local-final-gate16-checks.mjs +88 -0
  65. package/local-final-gate16-contract.mjs +15 -0
  66. package/local-final-gate16-plan.mjs +66 -0
  67. package/local-final-gate16-proof.mjs +75 -0
  68. package/local-final-gate16-release.mjs +17 -0
  69. package/local-final-gate16-state.mjs +70 -0
  70. package/local-final-gate16-worker.mjs +19 -0
  71. package/local-final-gate16.mjs +137 -0
  72. package/local-formula-ast.mjs +97 -0
  73. package/local-formula-engine.mjs +111 -0
  74. package/local-formula-tests.mjs +61 -0
  75. package/local-formula.mjs +25 -125
  76. package/local-integrity.mjs +22 -41
  77. package/local-invocations.mjs +107 -0
  78. package/local-ledger.mjs +2 -7
  79. package/local-merge-archguard-verification.d.mts +44 -0
  80. package/local-merge-archguard-verification.mjs +240 -0
  81. package/local-merge-archguard.mjs +138 -0
  82. package/local-merge-artifacts.mjs +86 -0
  83. package/local-merge-authority.mjs +89 -0
  84. package/local-merge-contracts.mjs +56 -0
  85. package/local-merge-controller.mjs +170 -0
  86. package/local-merge-head.mjs +56 -0
  87. package/local-merge-journal.mjs +77 -0
  88. package/local-merge-mutation.mjs +97 -0
  89. package/local-merge-rollback.mjs +126 -0
  90. package/local-merge-snapshot-rollback.mjs +26 -0
  91. package/local-merge-storage.mjs +103 -0
  92. package/local-merge-targets.mjs +47 -0
  93. package/local-merge-transaction.mjs +133 -0
  94. package/local-mutation-audit.mjs +34 -0
  95. package/local-mutation-authority.mjs +96 -0
  96. package/local-mutation-batch-receipt.mjs +84 -0
  97. package/local-mutation-batch.mjs +143 -0
  98. package/local-mutation-binding.mjs +86 -0
  99. package/local-mutation-files.mjs +245 -0
  100. package/local-mutation-pass-store.mjs +63 -0
  101. package/local-mutation-snapshot.mjs +200 -0
  102. package/local-mutation-wire.mjs +89 -0
  103. package/local-probe.mjs +133 -11
  104. package/local-scope-candidate.mjs +28 -0
  105. package/local-scope-context.mjs +28 -0
  106. package/local-scope-files.mjs +106 -0
  107. package/local-scope-git-attributes.mjs +200 -0
  108. package/local-scope-git-comparison.mjs +8 -0
  109. package/local-scope-git-history.mjs +72 -0
  110. package/local-scope-git-reads.mjs +66 -0
  111. package/local-scope-git.mjs +124 -0
  112. package/local-scope-repository.mjs +249 -0
  113. package/local-scope-signature.mjs +70 -0
  114. package/local-scope-snapshot.mjs +39 -0
  115. package/local-scope-state.mjs +204 -0
  116. package/local-scope-workspaces.mjs +83 -0
  117. package/local-swarm-authority.mjs +143 -0
  118. package/local-swarm-baseline.mjs +32 -0
  119. package/local-swarm-completion.mjs +102 -0
  120. package/local-swarm-contracts.mjs +47 -0
  121. package/local-swarm-mutation.mjs +142 -0
  122. package/local-swarm-priority.mjs +237 -0
  123. package/local-swarm-state.mjs +102 -0
  124. package/local-swarm-storage.mjs +135 -0
  125. package/local-swarm.mjs +179 -0
  126. package/local-task-handoff-completion-wire.mjs +71 -0
  127. package/local-task-handoff-contracts.mjs +66 -0
  128. package/local-task-handoff-fence.mjs +478 -0
  129. package/local-task-handoff-observation.mjs +38 -0
  130. package/local-task-handoff-overlay-wire.mjs +94 -0
  131. package/local-task-handoff-read-authority.mjs +236 -0
  132. package/local-task-handoff-storage.mjs +73 -0
  133. package/local-task-handoff-write-archive.mjs +80 -0
  134. package/local-task-handoff-write-constraints.mjs +66 -0
  135. package/local-task-handoff-write-receipts.mjs +99 -0
  136. package/local-task-handoff-write-wire.mjs +99 -0
  137. package/local-task-routing.mjs +202 -22
  138. package/manifest.json +1 -1
  139. package/package.json +153 -3
  140. package/release-state.json +153 -17
  141. package/release.mjs +238 -4
  142. package/releases.json +12 -0
  143. package/remote-client.mjs +127 -36
  144. package/remote-transport.mjs +80 -0
  145. package/review-client.mjs +38 -0
  146. package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
  147. package/runtime/calctool-runtime-v7.0.43.mjs +1887 -0
  148. package/runtime/git-native-darwin-v1.mjs +5 -0
  149. package/runtime/git-native-linux-v1.mjs +5 -0
  150. package/task-message-cli.mjs +47 -0
  151. package/task-message-entry.mjs +159 -0
  152. package/workflow-local-client.mjs +35 -0
  153. package/workflow-local-contracts.mjs +58 -0
  154. package/workflow-local-request.mjs +53 -0
  155. package/workflow-local-runner.mjs +350 -0
@@ -0,0 +1,200 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { canonical, sha256, snapshotHandoffBinding, verifySnapshotManifest } from './local-integrity.mjs';
5
+ import { budgetBindingForRead } from './local-budget-reads.mjs';
6
+ import { atomicMutationJson14, cleanupMutationDirectory14, mutationFail14, mutationId14, mutationPaths14,
7
+ mutationPath14, mutationRead14, mutationRoot14, mutationStateRoot14, mutationTarget14, privateMutationDirectory14,
8
+ readMutationJson14, writeMutationFile14 } from './local-mutation-files.mjs';
9
+
10
+ const LOCAL_SCHEMA = 'entrorove.local-execution/1.0';
11
+ const SNAPSHOT_SCHEMA = 'entrorove.snapshot/1.0';
12
+ const RECEIPT_SCHEMA = 'entrorove.snapshot-receipt/1.0';
13
+ const COPY_LAYOUT = 'files/1.0';
14
+ const SHA256 = /^[0-9a-f]{64}$/;
15
+ const MISSING_SHA256 = sha256('entrorove.missing-file/1.0');
16
+ const repoKey = root => sha256(root).slice(0, 32);
17
+ function layoutBinding(value) {
18
+ if (!Object.hasOwn(value, 'copyLayout')) return {};
19
+ if (value.copyLayout !== COPY_LAYOUT) mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', 'snapshot copy layout is invalid');
20
+ return { copyLayout: value.copyLayout };
21
+ }
22
+ function handoffBinding(value) {
23
+ try { return snapshotHandoffBinding(value); }
24
+ catch { mutationFail14('ENTROROVE_SNAPSHOT_BINDING', 'snapshot handoff epoch and baseline receipt binding are invalid'); }
25
+ }
26
+ function receiptCore(receipt) {
27
+ return { schemaVersion: RECEIPT_SCHEMA, snapshotId: receipt.snapshotId, flowId: receipt.flowId, stepId: receipt.stepId,
28
+ repositoryDigest: receipt.repositoryDigest, snapshotDigest: receipt.snapshotDigest, contractDigest: receipt.contractDigest ?? null,
29
+ paths: receipt.paths, fileManifestDigest: receipt.fileManifestDigest, ...handoffBinding(receipt), ...layoutBinding(receipt) };
30
+ }
31
+ export function loadSnapshotReceipt14(receipt) {
32
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) || receipt.schemaVersion !== RECEIPT_SCHEMA
33
+ || !SHA256.test(String(receipt.snapshotDigest)) || !SHA256.test(String(receipt.fileManifestDigest))
34
+ || !SHA256.test(String(receipt.receiptDigest)) || !/^[0-9a-f]{32}$/.test(String(receipt.repositoryDigest))
35
+ || receipt.contractDigest !== null && receipt.contractDigest !== undefined && !SHA256.test(String(receipt.contractDigest))) {
36
+ mutationFail14('ENTROROVE_RECEIPT_INVALID', 'snapshot receipt fields are invalid');
37
+ }
38
+ for (const field of ['snapshotId', 'flowId', 'stepId']) mutationId14(receipt[field], field);
39
+ const paths = mutationPaths14(receipt.paths);
40
+ if (canonical(paths) !== canonical(receipt.paths)) mutationFail14('ENTROROVE_RECEIPT_INVALID', 'snapshot receipt paths must be sorted');
41
+ const core = receiptCore(receipt);
42
+ if (receipt.receiptDigest !== sha256(canonical(core))) mutationFail14('ENTROROVE_RECEIPT_INVALID', 'snapshot receipt digest is invalid');
43
+ return { ...core, paths: [...paths], receiptDigest: receipt.receiptDigest };
44
+ }
45
+ async function location(input, root, binding = input) {
46
+ for (const field of ['flowId', 'stepId', 'snapshotId']) mutationId14(binding[field], field);
47
+ const stateRoot = await mutationStateRoot14(input);
48
+ const snapshotRoot = path.join(stateRoot, 'snapshots', repoKey(root), binding.flowId, binding.snapshotId);
49
+ return { stateRoot, snapshotRoot, manifestPath: path.join(snapshotRoot, 'manifest.json') };
50
+ }
51
+ function checkManifest(manifest, root, binding) {
52
+ const integrity = verifySnapshotManifest(manifest, { snapshotId: binding.snapshotId, flowId: binding.flowId,
53
+ stepId: binding.stepId, repositoryDigest: repoKey(root) });
54
+ if (!integrity.verified) mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', integrity.reason ?? 'snapshot manifest is invalid');
55
+ if (canonical(mutationPaths14(manifest.paths)) !== canonical(manifest.paths)) mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', 'snapshot paths are invalid');
56
+ layoutBinding(manifest);
57
+ for (const file of manifest.files) {
58
+ if (file.exists ? !Number.isInteger(file.mode) || file.mode < 0 || file.mode > 0o777
59
+ : file.mode !== null || file.sha256 !== MISSING_SHA256) mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', 'snapshot file existence or mode binding is invalid');
60
+ }
61
+ return manifest;
62
+ }
63
+ function copyRoot(snapshotRoot, manifest) {
64
+ return manifest.copyLayout === COPY_LAYOUT ? path.join(snapshotRoot, 'files') : snapshotRoot;
65
+ }
66
+ export function snapshotCopyPath14(manifest, snapshotRoot, relative) {
67
+ layoutBinding(manifest); mutationPath14(relative);
68
+ if (!Array.isArray(manifest.paths) || !manifest.paths.includes(relative)) mutationFail14('ENTROROVE_SNAPSHOT_BINDING', 'copy path is outside its snapshot path set');
69
+ return path.join(copyRoot(snapshotRoot, manifest), relative);
70
+ }
71
+ async function readCopy(input, snapshotRoot, root, relative) {
72
+ if (root === snapshotRoot || !await budgetBindingForRead(input)) return mutationRead14(input, root, relative);
73
+ // The unchanged budget reader recognizes its original snapshot path shape. A
74
+ // private, transient hardlink preserves that shape and charges the exact copy
75
+ // bytes under the original source path, including manifest.json user files.
76
+ const before = await mutationTarget14(root, relative);
77
+ const stateRoot = await mutationStateRoot14(input);
78
+ const repository = await mutationRoot14(input.repositoryRoot);
79
+ mutationId14(input.flowId, 'flowId');
80
+ const proxyRoot = path.join(stateRoot, 'snapshots', repoKey(repository), input.flowId, `read-${randomUUID()}`);
81
+ await privateMutationDirectory14(path.dirname(proxyRoot), { privateFrom: stateRoot });
82
+ const owned = await privateMutationDirectory14(proxyRoot, { exclusive: true, privateFrom: stateRoot });
83
+ let read;
84
+ try {
85
+ const proxy = path.join(proxyRoot, relative);
86
+ await privateMutationDirectory14(path.dirname(proxy), { privateFrom: stateRoot });
87
+ await fs.link(before.target, proxy);
88
+ read = await mutationRead14(input, proxyRoot, relative);
89
+ } finally { await cleanupMutationDirectory14(owned); }
90
+ const after = await mutationTarget14(root, relative);
91
+ if (before.stat.dev !== after.stat.dev || before.stat.ino !== after.stat.ino || read.stat.dev !== after.stat.dev
92
+ || read.stat.ino !== after.stat.ino || before.stat.size !== after.stat.size || before.stat.mtimeMs !== after.stat.mtimeMs) {
93
+ mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', 'snapshot copy changed during its budgeted read');
94
+ }
95
+ return { ...read, target: after.target, stat: after.stat, mode: after.stat.mode & 0o777 };
96
+ }
97
+ export async function readSnapshotCopy14(input, snapshotRoot, manifest, relative) {
98
+ const expected = manifest.files?.find(file => file.path === relative);
99
+ const target = snapshotCopyPath14(manifest, snapshotRoot, relative);
100
+ if (!expected || typeof expected.exists !== 'boolean') mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', 'snapshot copy file entry is invalid');
101
+ const root = copyRoot(snapshotRoot, manifest); await mutationRoot14(root);
102
+ const copy = await mutationTarget14(root, relative, true);
103
+ if (copy.exists !== expected.exists) mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', `${relative} snapshot copy existence is invalid`);
104
+ if (!expected.exists) {
105
+ if (expected.sha256 !== MISSING_SHA256 || expected.mode !== null) mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', 'missing snapshot copy binding is invalid');
106
+ return { target, path: relative, exists: false, content: null, sha256: MISSING_SHA256, mode: null, stat: null };
107
+ }
108
+ if (!Number.isInteger(expected.mode) || expected.mode < 0 || expected.mode > 0o777) mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', 'snapshot source mode is invalid');
109
+ const read = await readCopy(input, snapshotRoot, root, relative);
110
+ if (read.sha256 !== expected.sha256 || read.stat.nlink !== 1) mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', `${relative} snapshot copy is invalid`);
111
+ if (manifest.copyLayout && process.platform !== 'win32' && read.mode !== 0o600) mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', 'snapshot copies must have private file permissions');
112
+ return { ...read, exists: true, sourceMode: expected.mode };
113
+ }
114
+ export async function preserveSnapshotCopies14(input, snapshotRoot, manifest, destination) {
115
+ const stateRoot = await mutationStateRoot14(input);
116
+ await privateMutationDirectory14(copyRoot(destination, manifest), { privateFrom: stateRoot });
117
+ for (const expected of manifest.files) {
118
+ const observed = await readSnapshotCopy14(input, snapshotRoot, manifest, expected.path);
119
+ if (expected.exists) await writeMutationFile14(snapshotCopyPath14(manifest, destination, expected.path),
120
+ observed.content, { privateFrom: stateRoot });
121
+ }
122
+ }
123
+ async function copyFacts(input, snapshotRoot, manifest) {
124
+ const files = [];
125
+ for (const expected of manifest.files) {
126
+ await readSnapshotCopy14(input, snapshotRoot, manifest, expected.path);
127
+ files.push({ path: expected.path, sourceHash: expected.sha256, snapshotHash: expected.sha256 });
128
+ }
129
+ return files;
130
+ }
131
+ export async function verifySnapshotSources14(input, root, manifest, { paths = manifest.paths } = {}) {
132
+ const selected = mutationPaths14(paths);
133
+ if (selected.some(relative => !manifest.paths.includes(relative))) mutationFail14('ENTROROVE_SNAPSHOT_BINDING', 'source verification is outside the snapshot path set');
134
+ for (const expected of manifest.files.filter(file => selected.includes(file.path))) {
135
+ const source = await mutationTarget14(root, expected.path, true);
136
+ if (source.exists !== expected.exists) mutationFail14('ENTROROVE_SNAPSHOT_CHANGED', `${expected.path} changed after snapshot`);
137
+ if (expected.exists) {
138
+ const read = await mutationRead14(input, root, expected.path);
139
+ if (read.sha256 !== expected.sha256 || read.mode !== expected.mode) mutationFail14('ENTROROVE_SNAPSHOT_CHANGED', `${expected.path} changed after snapshot`);
140
+ } else if (expected.sha256 !== MISSING_SHA256) mutationFail14('ENTROROVE_SNAPSHOT_CORRUPT', 'missing source hash is invalid');
141
+ }
142
+ }
143
+ async function copySource(input, root, snapshotRoot, relative, stateRoot) {
144
+ const source = await mutationTarget14(root, relative, true);
145
+ if (!source.exists) return { path: relative, exists: false, sha256: MISSING_SHA256, mode: null };
146
+ const read = await mutationRead14(input, root, relative);
147
+ await writeMutationFile14(path.join(snapshotRoot, 'files', relative), read.content, { privateFrom: stateRoot });
148
+ return { path: relative, exists: true, sha256: read.sha256, mode: read.mode };
149
+ }
150
+ export async function snapshotPlan14(input, { handoffBinding: binding = {} } = {}) {
151
+ const root = await mutationRoot14(input.repositoryRoot);
152
+ const flowId = mutationId14(input.flowId, 'flowId'); const stepId = mutationId14(input.stepId, 'stepId');
153
+ const paths = mutationPaths14(input.paths); const handoff = handoffBinding(binding);
154
+ const snapshotId = mutationId14(input.snapshotId ?? `snap-${randomUUID()}`, 'snapshotId');
155
+ const contractDigest = input.contractDigest ?? null;
156
+ if (contractDigest !== null && !SHA256.test(contractDigest)) mutationFail14('ENTROROVE_CONTRACT_DIGEST', 'contractDigest must be a lowercase SHA-256 digest');
157
+ const loc = await location(input, root, { flowId, stepId, snapshotId });
158
+ await privateMutationDirectory14(path.dirname(loc.snapshotRoot), { privateFrom: loc.stateRoot });
159
+ const owned = await privateMutationDirectory14(loc.snapshotRoot, { exclusive: true, privateFrom: loc.stateRoot });
160
+ try {
161
+ await privateMutationDirectory14(path.join(loc.snapshotRoot, 'files'), { privateFrom: loc.stateRoot });
162
+ const files = [];
163
+ for (const relative of paths) files.push(await copySource(input, root, loc.snapshotRoot, relative, loc.stateRoot));
164
+ const core = { snapshotId, flowId, stepId, paths, files, contractDigest, ...handoff };
165
+ const manifest = { schemaVersion: SNAPSHOT_SCHEMA, ...core, repositoryDigest: repoKey(root), copyLayout: COPY_LAYOUT,
166
+ createdAt: new Date().toISOString(), snapshotDigest: sha256(canonical(core)) };
167
+ await atomicMutationJson14(loc.manifestPath, manifest, { privateFrom: loc.stateRoot });
168
+ await copyFacts(input, loc.snapshotRoot, manifest);
169
+ await verifySnapshotSources14(input, root, manifest);
170
+ return { schemaVersion: LOCAL_SCHEMA, snapshotId, snapshotRoot: loc.snapshotRoot, manifestPath: loc.manifestPath,
171
+ paths, contractDigest, snapshotDigest: manifest.snapshotDigest, copyLayout: COPY_LAYOUT, forbidGitBranch: true, forbidWorktree: true };
172
+ } catch (error) {
173
+ try { await cleanupMutationDirectory14(owned); } catch { /* Preserve the primary error and refuse unsafe cleanup. */ }
174
+ throw error;
175
+ }
176
+ }
177
+ export async function snapshotVerify14(input) {
178
+ const root = await mutationRoot14(input.repositoryRoot); const loc = await location(input, root);
179
+ const manifest = checkManifest(await readMutationJson14(loc.manifestPath), root, input);
180
+ const files = await copyFacts(input, loc.snapshotRoot, manifest);
181
+ await verifySnapshotSources14(input, root, manifest);
182
+ const core = receiptCore({ ...manifest, fileManifestDigest: sha256(canonical(files)) });
183
+ const receipt = { ...core, receiptDigest: sha256(canonical(core)) };
184
+ return { schemaVersion: LOCAL_SCHEMA, verified: true, receipt };
185
+ }
186
+ export async function snapshotAuthority14(input, root, value) {
187
+ const actualRoot = await mutationRoot14(input.repositoryRoot);
188
+ if (actualRoot !== root) mutationFail14('ENTROROVE_SNAPSHOT_BINDING', 'snapshot authority root differs from its repository binding');
189
+ const receipt = loadSnapshotReceipt14(value);
190
+ if (receipt.flowId !== input.flowId || receipt.stepId !== input.stepId || receipt.repositoryDigest !== repoKey(root)
191
+ || input.snapshotId !== undefined && input.snapshotId !== receipt.snapshotId) mutationFail14('ENTROROVE_SNAPSHOT_BINDING', 'snapshot receipt scope is invalid');
192
+ const loc = await location(input, root, receipt);
193
+ const manifest = checkManifest(await readMutationJson14(loc.manifestPath), root, receipt);
194
+ if (manifest.snapshotDigest !== receipt.snapshotDigest || canonical(manifest.paths) !== canonical(receipt.paths)
195
+ || manifest.contractDigest !== receipt.contractDigest || canonical(handoffBinding(manifest)) !== canonical(handoffBinding(receipt))
196
+ || canonical(layoutBinding(manifest)) !== canonical(layoutBinding(receipt))) mutationFail14('ENTROROVE_SNAPSHOT_BINDING', 'snapshot receipt does not match its manifest or layout');
197
+ const files = await copyFacts(input, loc.snapshotRoot, manifest);
198
+ if (receipt.fileManifestDigest !== sha256(canonical(files))) mutationFail14('ENTROROVE_SNAPSHOT_BINDING', 'snapshot receipt file manifest digest is invalid');
199
+ return { manifest, manifestPath: loc.manifestPath, snapshotRoot: loc.snapshotRoot, copyRoot: copyRoot(loc.snapshotRoot, manifest) };
200
+ }
@@ -0,0 +1,89 @@
1
+ import { createPublicKey, verify } from 'node:crypto';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { canonicalHandoffSignature } from './local-task-handoff-overlay-wire.mjs';
4
+ import { mutationFail14, mutationPaths14 } from './local-mutation-files.mjs';
5
+
6
+ export const MUTATION_IDENTITY_FIELDS14 = ['flowId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision',
7
+ 'acceptanceReceiptId', 'acceptanceDigest', 'routeDigest', 'receiver', 'receiverDigest', 'stepId', 'leaseId', 'leaseDigest',
8
+ 'generation', 'planId', 'planDigest', 'executionPlanSha256', 'executionAuthorityId', 'executionAuthorityDigest',
9
+ 'executionAttemptNonce', 'epoch', 'runnerId', 'runnerKeyId', 'repositoryId', 'repositoryDigest', 'scopeBindingDigest',
10
+ 'scopeArtifactDigest', 'scopeContractDigest', 'paths', 'newFilePaths', 'issuedAt', 'expiresAt'];
11
+ const CHALLENGE_FIELDS = ['schemaVersion', 'challengeId', 'snapshotId', 'nonce', ...MUTATION_IDENTITY_FIELDS14];
12
+ const PASS_FIELDS = ['schemaVersion', 'passId', 'challengeId', 'challengeDigest', 'challengeNonce', 'snapshotId', 'nonce',
13
+ ...MUTATION_IDENTITY_FIELDS14, 'handoffEpoch', 'snapshotDigest', 'snapshotReceiptDigest', 'snapshotReceipt',
14
+ 'snapshotManifestDigest', 'attestationDigest', 'coordinationRequired'];
15
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
16
+ const SHA = /^[0-9a-f]{64}$/;
17
+ function exact(value, fields) {
18
+ if (!value || typeof value !== 'object' || Array.isArray(value)
19
+ || Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) {
20
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority has invalid fields');
21
+ }
22
+ }
23
+ export function mutationLifetime14(value, { live = true } = {}) {
24
+ const start = Date.parse(value.issuedAt), end = Date.parse(value.expiresAt);
25
+ if (!Number.isFinite(start) || !Number.isFinite(end) || new Date(start).toISOString() !== value.issuedAt
26
+ || new Date(end).toISOString() !== value.expiresAt || start > Date.now() || end <= start || end - start > 90_000
27
+ || live && end <= Date.now()) mutationFail14('ENTROROVE_MUTATION_AUTHORITY_EXPIRED', 'Mutation authority is expired or has invalid issuance');
28
+ }
29
+ function pinnedAuthority() {
30
+ try {
31
+ const encoded = process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY;
32
+ if (!encoded) throw new Error();
33
+ const der = Buffer.from(encoded, 'base64'), key = createPublicKey({ key: der, type: 'spki', format: 'der' });
34
+ if (key.asymmetricKeyType !== 'ed25519' || key.export({ type: 'spki', format: 'der' }).toString('base64') !== encoded) throw new Error();
35
+ return { key, keyId: sha256(der) };
36
+ } catch { mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Pinned cloud mutation authority is unavailable or invalid'); }
37
+ }
38
+ function identity(payload) {
39
+ const ids = ['flowId', 'taskId', 'acceptanceReceiptId', 'stepId', 'leaseId', 'planId', 'executionAuthorityId',
40
+ 'executionAttemptNonce', 'runnerId', 'repositoryId', 'snapshotId', 'nonce', 'challengeId'];
41
+ const digests = ['taskDigest', 'chainDigest', 'acceptanceDigest', 'routeDigest', 'receiverDigest', 'leaseDigest',
42
+ 'planDigest', 'executionPlanSha256', 'executionAuthorityDigest', 'runnerKeyId', 'scopeBindingDigest',
43
+ 'scopeArtifactDigest', 'scopeContractDigest'];
44
+ if (ids.some(field => typeof payload[field] !== 'string' || !ID.test(payload[field]))
45
+ || digests.some(field => typeof payload[field] !== 'string' || !SHA.test(payload[field]))
46
+ || !/^[0-9a-f]{32}$/.test(payload.repositoryDigest) || !/^[0-9a-f-]{36}$/.test(payload.ownerId)
47
+ || !Number.isSafeInteger(payload.epoch) || payload.epoch < 0
48
+ || ['generation', 'chainRevision'].some(field => !Number.isSafeInteger(payload[field]) || payload[field] < 1)) {
49
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority identity is invalid');
50
+ }
51
+ const paths = mutationPaths14(payload.paths);
52
+ if (canonical(paths) !== canonical(payload.paths) || !Array.isArray(payload.newFilePaths)
53
+ || payload.newFilePaths.some(value => !paths.includes(value)) || new Set(payload.newFilePaths).size !== payload.newFilePaths.length
54
+ || canonical([...payload.newFilePaths].sort()) !== canonical(payload.newFilePaths)) {
55
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority path set is invalid');
56
+ }
57
+ exact(payload.receiver, ['taskId', 'ownerId', 'projectId', 'hostId', 'threadId', 'descriptionDigest', 'taskDigest']);
58
+ if (payload.receiver.taskId !== payload.taskId || payload.receiver.ownerId !== payload.ownerId
59
+ || payload.receiver.taskDigest !== payload.taskDigest || !SHA.test(payload.receiver.descriptionDigest)
60
+ || sha256(canonical(payload.receiver)) !== payload.receiverDigest
61
+ || ['projectId', 'hostId', 'threadId'].some(field => !ID.test(String(payload.receiver[field])))) {
62
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority lost its original cloud receiver');
63
+ }
64
+ }
65
+ function authority(value, fields, schema, options) {
66
+ exact(value, [...fields, 'authorityReceipt']);
67
+ const { authorityReceipt: receipt, ...payload } = value;
68
+ exact(receipt, ['schemaVersion', 'keyId', 'payloadDigest', 'signature']);
69
+ const bytes = Buffer.from(canonical(payload)), key = pinnedAuthority();
70
+ let signature; try { signature = canonicalHandoffSignature(receipt.signature); }
71
+ catch { mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Cloud mutation signature encoding is invalid'); }
72
+ if (payload.schemaVersion !== schema || bytes.length > 1024 * 1024
73
+ || receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0' || receipt.keyId !== key.keyId
74
+ || receipt.payloadDigest !== sha256(bytes) || !verify(null, bytes, key.key, signature)) {
75
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority requires a valid pinned cloud signature');
76
+ }
77
+ identity(payload); mutationLifetime14(payload, options); return value;
78
+ }
79
+ export function mutationChallenge14(value, options) {
80
+ return authority(value, CHALLENGE_FIELDS, 'entrorove.mutation-snapshot-challenge/1.0', options);
81
+ }
82
+ export function mutationPass14(value, options) {
83
+ const pass = authority(value, PASS_FIELDS, 'entrorove.mutation-pass/1.0', options);
84
+ if (!ID.test(pass.passId) || !ID.test(pass.challengeNonce) || pass.handoffEpoch !== pass.epoch || pass.coordinationRequired !== false
85
+ || ['challengeDigest', 'snapshotDigest', 'snapshotReceiptDigest', 'snapshotManifestDigest', 'attestationDigest'].some(field => !SHA.test(pass[field]))) {
86
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation pass has invalid snapshot or original epoch bindings');
87
+ }
88
+ return pass;
89
+ }
package/local-probe.mjs CHANGED
@@ -1,13 +1,135 @@
1
- import { promises as fs } from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
1
+ import { operationSchemasFor } from './local-contracts.mjs';
2
+ import { observedGoalRisk, observedScopeRisk, scopeFail, scopeRoot, scopeDigest } from './local-scope-files.mjs';
3
+ import { collectScopeFacts } from './local-scope-repository.mjs';
4
+ import { initializeScope, retainedScope, scopeBinding, upgradeScope } from './local-scope-state.mjs';
5
+ import { sha256 } from './local-integrity.mjs';
6
+ import { attestScopeFacts } from './local-scope-signature.mjs';
7
+ import { resolveScopePaths } from './local-scope-context.mjs';
8
+ import { readScopeSnapshot, verifyFreshScopeSnapshot } from './local-scope-snapshot.mjs';
9
+ import { rejectScopePrivileges, verifiedScopeCandidate } from './local-scope-candidate.mjs';
10
+ import { budgetBindingForRead } from './local-budget-reads.mjs';
11
+ import { budgetLocation, loadBudget } from './local-budget-store.mjs';
4
12
 
5
- const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
- function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
7
- function safe(value) { if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value) || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail('ENTROROVE_PATH_UNSAFE', 'probe path is unsafe'); return value; }
8
- async function root(value) { if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute'); const stat = await fs.lstat(value); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory'); return fs.realpath(value); }
9
- async function filesUnder(rootPath, relative, result = []) { const target = path.resolve(rootPath, ...safe(relative).split('/')); const stat = await fs.lstat(target); if (stat.isSymbolicLink()) fail('ENTROROVE_FILE_INVALID', 'probe refuses symlinks'); if (stat.isFile()) { result.push({ path: relative, bytes: stat.size }); return result; } if (!stat.isDirectory()) fail('ENTROROVE_FILE_INVALID', 'probe path is not a file or directory'); for (const name of await fs.readdir(target)) if (!['.git', 'node_modules', '.entrorove', '.aimlock', '.archguard'].includes(name)) await filesUnder(rootPath, `${relative}/${name}`, result); return result; }
10
- export async function probe(input) { const rootPath = await root(input.repositoryRoot); const requested = input.paths ?? input.targetPaths ?? ['.']; const paths = requested.length === 1 && requested[0] === '.' ? (await fs.readdir(rootPath)).filter(name => !['.git', 'node_modules', '.entrorove'].includes(name)).map(name => name) : requested.map(safe); const files = []; for (const relative of paths) await filesUnder(rootPath, relative, files); const fileCount = files.length; const bytes = files.reduce((total, file) => total + file.bytes, 0); const lines = Math.ceil(bytes / 4); const mode = fileCount <= 1 && lines <= 500 ? 'lock' : fileCount <= 3 && lines <= 500 ? 'probe' : 'swarm'; return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', repositoryRoot: rootPath, facts: { fileCount, bytes, tokenEstimate: lines, files: files.sort((left, right) => left.path.localeCompare(right.path)) }, classification: { mode, reason: mode === 'swarm' ? 'more than three files or over the 500-line budget' : mode === 'probe' ? 'bounded multi-file read' : 'single-file read' }, forbidGitBranch: true, forbidWorktree: true }; }
11
- export async function reassess(input) { const result = await probe(input); const order = ['lock', 'probe', 'swarm']; const requested = input.currentMode ?? result.classification.mode; if (!ID.test(requested) || !order.includes(requested)) fail('ENTROROVE_MODE_INVALID', 'currentMode must be lock, probe, or swarm'); const currentIndex = order.indexOf(requested); const actualIndex = order.indexOf(result.classification.mode); const nextMode = actualIndex > currentIndex ? order[Math.min(currentIndex + 1, order.length - 1)] : requested; return { ...result, operation: 'reassess', previousMode: requested, mode: nextMode, upgraded: nextMode !== requested, preservedSnapshotId: input.snapshotId ?? null }; }
13
+ const types = new Set(['implementation', 'documentation', 'calculation', 'merge', 'review', 'decision', 'diagnosis']);
14
+ const canonicalPaths = files => files.map(file => file.path).join('\0');
15
+ const scopeModes = ['lock', 'probe', 'swarm'];
16
+ export function classifyScope(facts, task) {
17
+ if (task !== undefined && (!task || typeof task !== 'object' || Array.isArray(task)
18
+ || !types.has(task.workType) || !['low', 'medium', 'high'].includes(task.difficulty)
19
+ || !['low', 'medium', 'high'].includes(task.risk) || typeof task.goal !== 'string' || !task.goal.trim())) scopeFail('ENTROROVE_TASK_INVALID', 'scope task requires goal, workType, difficulty, and risk');
20
+ const crossModule = facts.crossModule || task?.crossModule === true; const parallel = task?.parallel === true || task?.needParallel === true;
21
+ const overBudget = facts.estimatedChangedLines > 500 || facts.git.binary;
22
+ const risks = ['low', 'medium', 'high'];
23
+ const risk = risks[Math.max(risks.indexOf(task?.risk ?? 'low'), risks.indexOf(facts.risk ?? 'low'),
24
+ risks.indexOf(observedScopeRisk(facts.files)), risks.indexOf(observedGoalRisk(task?.goal ?? '')))];
25
+ const diagnosis = task?.workType === 'diagnosis';
26
+ const explicit = task?.explicitAimlockRequested === true || task?.explicitEntroroveRequested === true
27
+ || (Array.isArray(task?.userSpecifiedSkills) && task.userSpecifiedSkills.some(skill => typeof skill === 'string' && ['aimlock', 'entrorove'].includes(skill.toLowerCase())));
28
+ const mode = facts.fileCount > 3 || overBudget || crossModule || parallel ? 'swarm' : facts.fileCount > 1 || diagnosis ? 'probe' : 'lock';
29
+ const bypass = task !== undefined && !diagnosis && task.difficulty === 'low' && !overBudget && !crossModule && risk !== 'high'
30
+ && !parallel && !explicit && task.unclear !== true && task.architectureContract !== true && facts.importGraph.complete;
31
+ return { decision: bypass ? 'bypass' : 'active', mode, risk, crossModule, estimatedChangedLines: facts.estimatedChangedLines,
32
+ reason: bypass ? 'observed bounded low-risk task' : mode === 'swarm' ? 'observed scope, changed lines, package dependencies, or requested parallel work require Swarm'
33
+ : mode === 'probe' ? 'observed scope contains at most three files through 500 lines' : 'observed scope contains one file through 500 lines',
34
+ readOnly: ['diagnosis', 'review', 'calculation', 'decision'].includes(task?.workType), writeAllowed: false, workerCount: 0 };
35
+ }
36
+ async function observe(input, retained, fresh = false) {
37
+ const repositoryRoot = await scopeRoot(input.repositoryRoot);
38
+ const requested = await resolveScopePaths(input, repositoryRoot);
39
+ if (requested === undefined) scopeFail('ENTROROVE_SCOPE_EMPTY', 'explicit target paths are required');
40
+ const original = (!retained || fresh) && input.snapshotId != null ? await readScopeSnapshot(input, repositoryRoot) : null;
41
+ const facts = await collectScopeFacts(repositoryRoot, requested, input.baselineRef, fresh ? original.known : retained?.history.at(-1).facts.files ?? original?.known, input);
42
+ const classification = classifyScope(facts, input.task ?? retained?.originalTask ?? undefined);
43
+ return { schemaVersion: 'entrorove.local-execution/1.0', status: 'reported', repositoryRoot,
44
+ repositoryDigest: sha256(repositoryRoot).slice(0, 32), facts, factsDigest: scopeDigest(facts), classification,
45
+ forbidGitBranch: true, forbidWorktree: true, trusted: false };
46
+ }
47
+ async function handoffReadBudget(input) {
48
+ const binding = await budgetBindingForRead(input);
49
+ if (!binding) return false;
50
+ const loaded = await loadBudget(await budgetLocation(binding));
51
+ return loaded.state.enforced && loaded.state.grant?.purpose === 'handoff-read';
52
+ }
53
+ async function probeHandoffCandidate(input, root, candidate) {
54
+ scopeBinding(input);
55
+ const native = await import('./local-task-handoff-read-authority.mjs');
56
+ const readHome = await native.assertTaskHandoffReadHome({ ...input, taskId: candidate.receiverTaskId, ownerId: candidate.ownerId });
57
+ if (readHome.epoch !== candidate.receiverEpoch - 1 || readHome.mutationAllowed !== false) {
58
+ scopeFail('ENTROROVE_SCOPE_CANDIDATE_BINDING', 'candidate observation requires its actual current read-only receiver home');
59
+ }
60
+ const result = await observe(input, null, true);
61
+ result.classification.decision = 'active';
62
+ result.facts.snapshotEvidence = await verifyFreshScopeSnapshot(input, root, result.facts);
63
+ result.factsDigest = scopeDigest(result.facts);
64
+ await verifiedScopeCandidate(input, root, result.facts.snapshotEvidence.manifest);
65
+ return attestScopeFacts(input, { ...result, scopePurpose: 'handoff-candidate',
66
+ requiredMode: result.classification.mode, snapshotDigest: result.facts.snapshotEvidence.snapshotDigest,
67
+ preservedSnapshotDigest: null, preservedSnapshotId: null, currentSnapshotFresh: true,
68
+ receiverOrigin: { flowId: candidate.flowId, taskId: candidate.receiverTaskId,
69
+ scopeDigest: candidate.receiverOriginalScopeDigest, checkpointDigest: candidate.receiverCheckpointDigest,
70
+ acceptanceReceiptId: candidate.receiverAcceptanceReceiptId, sourceCopiesVerified: false } });
71
+ }
72
+ export async function probe(input) {
73
+ rejectScopePrivileges(input);
74
+ const root = await scopeRoot(input.repositoryRoot);
75
+ const candidate = await verifiedScopeCandidate(input, root);
76
+ if (candidate && await handoffReadBudget(input)) return probeHandoffCandidate(input, root, candidate);
77
+ const hasBinding = ['flowId', 'stepId', 'snapshotId'].some(field => input[field] !== undefined);
78
+ const retained = hasBinding ? await retainedScope(input, root, true) : null;
79
+ const fresh = !!candidate || (!!retained && retained.stepId !== input.stepId);
80
+ if (fresh && (!retained || typeof input.signerConfigPath !== 'string' || !input.signerConfigPath || typeof input.challenge !== 'string' || !input.challenge)) {
81
+ scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'a new-step observation requires its preserved original scope, configured signer and new challenge binding');
82
+ }
83
+ const result = await observe(input, retained, fresh);
84
+ if (fresh) result.classification.decision = 'active';
85
+ if (fresh && !candidate && canonicalPaths(result.facts.files) !== canonicalPaths(retained.history.at(-1).facts.files)) {
86
+ scopeFail('ENTROROVE_SCOPE_CHANGED', 'fresh original-scope observation cannot replace its original scope paths; reassess growth explicitly');
87
+ }
88
+ if (result.classification.decision === 'bypass') return result;
89
+ if (['flowId', 'stepId', 'snapshotId'].some(field => input[field] !== undefined)) {
90
+ scopeBinding(input);
91
+ result.facts.snapshotEvidence = await verifyFreshScopeSnapshot(input, result.repositoryRoot, result.facts);
92
+ result.factsDigest = scopeDigest(result.facts);
93
+ if (fresh) {
94
+ await verifiedScopeCandidate(input, result.repositoryRoot, result.facts.snapshotEvidence.manifest);
95
+ return attestScopeFacts(input, { ...result, scopePurpose: candidate ? 'handoff-candidate' : 'fresh-observation',
96
+ revision: retained.revision,
97
+ classification: { ...result.classification, mode: candidate ? result.classification.mode : retained.mode }, requiredMode: result.classification.mode,
98
+ originalBinding: { flowId: retained.flowId, stepId: retained.stepId, snapshotId: retained.originalSnapshot.snapshotId },
99
+ snapshotDigest: result.facts.snapshotEvidence.snapshotDigest, preservedSnapshotDigest: retained.originalSnapshot.digest,
100
+ preservedSnapshotId: retained.originalSnapshot.snapshotId, currentSnapshotFresh: true });
101
+ }
102
+ const saved = await initializeScope(input, result.repositoryRoot, result.facts, result.classification);
103
+ return attestScopeFacts(input, { ...result, revision: saved.state.revision, statePath: saved.statePath,
104
+ classification: { ...result.classification, mode: saved.state.mode }, requiredMode: result.classification.mode,
105
+ snapshotDigest: result.facts.snapshotEvidence.snapshotDigest, preservedSnapshotDigest: saved.state.originalSnapshot.digest,
106
+ preservedSnapshotId: saved.state.originalSnapshot.snapshotId, currentSnapshotFresh: true });
107
+ }
108
+ return attestScopeFacts(input, result);
109
+ }
110
+ function observationReassessment(input, result) {
111
+ const previousMode = input.currentMode === undefined ? result.classification.mode : input.currentMode;
112
+ const current = scopeModes.indexOf(previousMode), required = scopeModes.indexOf(result.classification.mode);
113
+ const mode = required > current ? scopeModes[Math.min(current + 1, scopeModes.length - 1)] : previousMode;
114
+ return { ...result, operation: 'reassess', previousMode, mode, requiredMode: result.classification.mode,
115
+ classification: { ...result.classification, mode }, upgraded: mode !== previousMode,
116
+ preservedSnapshotId: null, currentSnapshotFresh: false };
117
+ }
118
+ export async function reassess(input) {
119
+ rejectScopePrivileges(input);
120
+ if (Object.hasOwn(input, 'handoffCandidate')) scopeFail('ENTROROVE_SCOPE_INPUT_INVALID', 'temporary handoff candidates cannot reassess or replace the original scope');
121
+ if (input.currentMode !== undefined && !scopeModes.includes(input.currentMode)) scopeFail('ENTROROVE_MODE_INVALID', 'currentMode must be lock, probe, or swarm');
122
+ const bound = ['flowId', 'stepId', 'expectedRevision', 'taskDigest', 'ownerId', 'chainDigest', 'repositoryId', 'challenge']
123
+ .some(field => input[field] !== undefined) || input.snapshotId != null || input.signerConfigPath != null;
124
+ if (!bound) return observationReassessment(input, await observe(input, null));
125
+ scopeBinding(input); const retained = await retainedScope(input, await scopeRoot(input.repositoryRoot));
126
+ const result = await observe(input, retained);
127
+ const saved = await upgradeScope(input, result.repositoryRoot, result.facts, result.classification);
128
+ return { ...result, operation: 'reassess', previousMode: saved.previousMode, mode: saved.mode, requiredMode: saved.requiredMode,
129
+ classification: { ...result.classification, mode: saved.mode, decision: 'active' }, upgraded: saved.upgraded,
130
+ revision: saved.state.revision, statePath: saved.statePath, snapshotDigest: saved.state.originalSnapshot.digest,
131
+ preservedSnapshotId: saved.state.originalSnapshot.snapshotId, preservedFactsDigest: saved.state.originalFactsDigest,
132
+ historyDigest: saved.state.history.at(-1).recordDigest, currentSnapshotFresh: false };
133
+ }
12
134
  export const LOCAL_PROBE_OPERATIONS = Object.freeze({ probe, reassess });
13
- export const LOCAL_PROBE_SCHEMAS = Object.freeze({ probe: { type: 'object', additionalProperties: false, required: ['repositoryRoot'] }, reassess: { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'currentMode'] } });
135
+ export const LOCAL_PROBE_SCHEMAS = operationSchemasFor(LOCAL_PROBE_OPERATIONS);
@@ -0,0 +1,28 @@
1
+ import { canonical } from './local-integrity.mjs';
2
+ import { scopeFail } from './local-scope-files.mjs';
3
+ import { readTaskHandoffSnapshotBinding } from './local-task-handoff-fence.mjs';
4
+ import { HANDOFF_CANDIDATE_FIELDS, overlayAuthority } from './local-task-handoff-overlay-wire.mjs';
5
+
6
+ export function rejectScopePrivileges(input) {
7
+ if (['approved', 'verified', 'accepted', 'trusted', 'writeAllowed', 'pass', 'nonce'].some(field => Object.hasOwn(input, field))) {
8
+ scopeFail('ENTROROVE_SCOPE_INPUT_INVALID', 'scope observations reject caller approval, execution rights and receipt nonce assertions');
9
+ }
10
+ }
11
+ export async function verifiedScopeCandidate(input, root, snapshot) {
12
+ if (!Object.hasOwn(input, 'handoffCandidate')) return null;
13
+ const candidate = overlayAuthority(input.handoffCandidate, HANDOFF_CANDIDATE_FIELDS, 'entrorove.task-handoff-scope-challenge/1.0');
14
+ const fields = ['flowId', 'stepId', 'ownerId', 'taskDigest', 'chainDigest', 'repositoryId'];
15
+ if (fields.some(field => input[field] !== candidate[field]) || input.challenge !== candidate.challengeId
16
+ || typeof input.signerConfigPath !== 'string' || !input.signerConfigPath
17
+ || canonical(input.paths) !== canonical(candidate.paths)) scopeFail('ENTROROVE_SCOPE_CANDIDATE_BINDING', 'temporary scope must bind its exact original task, flow, repository, signer and real cloud challenge');
18
+ const binding = await readTaskHandoffSnapshotBinding({ ...input, repositoryRoot: root });
19
+ if (binding.handoffEpoch !== candidate.receiverEpoch || binding.handoffBaselineReceiptDigest !== candidate.baselineReceiptDigest) {
20
+ scopeFail('ENTROROVE_SCOPE_CANDIDATE_BINDING', 'temporary scope requires its registered external runner and current cloud receiver epoch');
21
+ }
22
+ if (snapshot && (snapshot.handoffEpoch !== binding.handoffEpoch || snapshot.handoffBaselineReceiptDigest !== binding.handoffBaselineReceiptDigest
23
+ || canonical(snapshot.paths) !== canonical(candidate.paths) || Date.parse(snapshot.createdAt) < Date.parse(candidate.issuedAt)
24
+ || !Number.isFinite(Date.parse(snapshot.createdAt)) || Date.parse(snapshot.createdAt) > Date.now())) {
25
+ scopeFail('ENTROROVE_SCOPE_CANDIDATE_BINDING', 'temporary scope requires actual fresh candidate copies bound to the exact cloud epoch and release baseline');
26
+ }
27
+ return candidate;
28
+ }
@@ -0,0 +1,28 @@
1
+ import path from 'node:path';
2
+ import { sha256 } from './local-integrity.mjs';
3
+ import { scopeFail, scopePath, scopeRead } from './local-scope-files.mjs';
4
+
5
+ export async function resolveScopePaths(input, root) {
6
+ const literal = Object.hasOwn(input, 'paths') ? input.paths : input.targetPaths;
7
+ if (input.targetSymbols === undefined) return literal === undefined
8
+ && !['flowId', 'stepId'].some(field => input[field] !== undefined) && input.snapshotId == null ? ['.'] : literal;
9
+ if (!Array.isArray(input.targetSymbols) || !input.targetSymbols.length || input.targetSymbols.some(symbol => typeof symbol !== 'string' || !symbol.trim())) scopeFail('ENTROROVE_CONTEXT_SYMBOL_INVALID', 'targetSymbols must contain exact non-empty symbols');
10
+ if (typeof input.contextMapPath !== 'string') scopeFail('ENTROROVE_CONTEXT_MAP_REQUIRED', 'exact symbols require a fresh ContextBase map');
11
+ let map;
12
+ try { map = JSON.parse((await scopeRead(root, scopePath(input.contextMapPath), input)).content.toString('utf8')); }
13
+ catch (error) { if (error.code) throw error; scopeFail('ENTROROVE_CONTEXT_MAP_INVALID', 'ContextBase map is invalid JSON'); }
14
+ if (map.schemaVersion !== 'entrorove.context-map/1.0' || map.repositoryDigest !== sha256(root).slice(0, 32) || !Array.isArray(map.entries)) scopeFail('ENTROROVE_CONTEXT_MAP_INVALID', 'ContextBase map is not bound to this repository');
15
+ const resolved = [];
16
+ for (const symbol of input.targetSymbols) {
17
+ const candidates = map.entries.filter(entry => entry?.symbol === symbol);
18
+ if (!candidates.length) scopeFail('ENTROROVE_CONTEXT_SYMBOL_MISSING', 'the exact symbol is absent from the map');
19
+ if (candidates.length !== 1) scopeFail('ENTROROVE_CONTEXT_SYMBOL_AMBIGUOUS', 'the exact symbol has more than one map entry');
20
+ const entry = candidates[0]; const relative = scopePath(entry.path);
21
+ const file = await scopeRead(root, relative, input);
22
+ if (file.sha256 !== entry.fileSha256) scopeFail('ENTROROVE_CONTEXT_MAP_STALE', 'the exact symbol map no longer matches the source hash');
23
+ resolved.push(relative);
24
+ }
25
+ const paths = [...new Set([...(literal ?? []), ...resolved])];
26
+ if (paths.some(relative => path.isAbsolute(relative))) scopeFail('ENTROROVE_PATH_UNSAFE', 'ContextBase map must contain repository-relative paths');
27
+ return paths;
28
+ }
@@ -0,0 +1,106 @@
1
+ import { constants, promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { canonical, sha256 } from './local-integrity.mjs';
4
+ import { readBudgetedProjectFile } from './local-budget-reads.mjs';
5
+
6
+ export function scopeFail(code, message) { throw Object.assign(new Error(message), { code }); }
7
+ export function observedScopeRisk(files) {
8
+ return files.some(file => /(?:^|\/)(?:auth|authentication|authorization|sessions?|secrets?|credentials?|payments?|billing|migrations?|sql|deploy(?:ment)?)(?:\/|[.-]|$)/i.test(file.path)
9
+ || /(?:^|\/)(?:\.env(?:\..*)?|[^/]*\.(?:pem|key))$/i.test(file.path)) ? 'high' : 'low';
10
+ }
11
+ export function observedGoalRisk(goal) {
12
+ return /生产数据|支付|用户隐私|财务|密码|密钥|线上环境|\b(?:production|payments?|billing|passwords?|secrets?|credentials?|financial|privacy)\b/i.test(goal) ? 'high' : 'low';
13
+ }
14
+ export function scopePath(value) {
15
+ if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value)
16
+ || /^[A-Za-z]:/.test(value) || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value)
17
+ || value.split('/').some(part => !part || part === '.' || part === '..')) scopeFail('ENTROROVE_PATH_UNSAFE', 'scope path is unsafe');
18
+ return value;
19
+ }
20
+ export async function scopeRoot(value) {
21
+ if (typeof value !== 'string' || !path.isAbsolute(value)) scopeFail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute');
22
+ const stat = await fs.lstat(value);
23
+ if (!stat.isDirectory() || stat.isSymbolicLink()) scopeFail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory');
24
+ return fs.realpath(value);
25
+ }
26
+ export async function scopeTarget(root, relative, allowMissing = false) {
27
+ let current = root;
28
+ const parts = scopePath(relative).split('/');
29
+ for (const [index, part] of parts.entries()) {
30
+ current = path.join(current, part);
31
+ let stat;
32
+ try { stat = await fs.lstat(current); } catch (error) {
33
+ if (error.code === 'ENOENT' && allowMissing) return { target: path.join(root, relative), exists: false, stat: null };
34
+ throw error;
35
+ }
36
+ if (stat.isSymbolicLink() || (index < parts.length - 1 && !stat.isDirectory())) scopeFail('ENTROROVE_FILE_INVALID', 'scope refuses symlink or non-directory ancestors');
37
+ if (index === parts.length - 1) return { target: current, exists: true, stat };
38
+ }
39
+ }
40
+ async function safeScopeRead(root, relative, input) {
41
+ const target = await scopeTarget(root, relative);
42
+ if (!target.stat.isFile()) scopeFail('ENTROROVE_FILE_INVALID', 'scope source must be a regular file');
43
+ if (target.stat.size > 16_777_216) scopeFail('ENTROROVE_SCOPE_LIMIT', 'scope source exceeds the 16 MiB inspection bound');
44
+ const handle = await fs.open(target.target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
45
+ try {
46
+ const before = await handle.stat();
47
+ const content = input ? await readBudgetedProjectFile(input, target.target, { sourcePath: relative }) : await handle.readFile();
48
+ const after = await handle.stat();
49
+ const latest = await fs.lstat(target.target);
50
+ if (before.ino !== target.stat.ino || before.dev !== target.stat.dev || after.size !== before.size
51
+ || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || latest.ino !== after.ino
52
+ || latest.dev !== after.dev || latest.isSymbolicLink()) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope source changed during inspection');
53
+ await scopeTarget(root, relative);
54
+ return { content, mode: after.mode & 0o777, sha256: sha256(content), bytes: content.length };
55
+ } finally { await handle.close(); }
56
+ }
57
+ export const scopeRead = (root, relative, input = { repositoryRoot: root }) => safeScopeRead(root, relative, input);
58
+ // Internal control roles only: signed manifest metadata and already validated private signer files.
59
+ export const scopeControlRead = (root, relative) => safeScopeRead(root, relative, null);
60
+ const excluded = new Set(['.git', 'node_modules', '.entrorove', '.aimlock', '.archguard']);
61
+ export async function scopeFiles(root, requested, knownFiles = []) {
62
+ if (!Array.isArray(requested) || !requested.length) scopeFail('ENTROROVE_SCOPE_EMPTY', 'a non-empty target scope is required');
63
+ const found = new Set(); const known = new Set(knownFiles.map(file => file.path)); let visited = 0;
64
+ async function visit(relative) {
65
+ if (++visited > 10_000) scopeFail('ENTROROVE_SCOPE_LIMIT', 'scope exceeds the bounded path inspection limit');
66
+ const item = await scopeTarget(root, relative, known.has(relative));
67
+ if (!item.exists) { found.add(relative); return; }
68
+ if (item.stat.isFile()) { found.add(relative); return; }
69
+ if (!item.stat.isDirectory()) scopeFail('ENTROROVE_FILE_INVALID', 'scope target must be a regular file or directory');
70
+ for (const name of (await fs.readdir(item.target)).sort()) if (!excluded.has(name)) await visit(`${relative}/${name}`);
71
+ }
72
+ for (const relative of known) if (requested.some(target => target === '.' || target === relative || relative.startsWith(`${target}/`))) found.add(relative);
73
+ for (const relative of requested) {
74
+ if (relative === '.') {
75
+ for (const name of (await fs.readdir(root)).sort()) if (!excluded.has(name)) await visit(name);
76
+ } else await visit(scopePath(relative));
77
+ }
78
+ if (!found.size) scopeFail('ENTROROVE_SCOPE_EMPTY', 'target scope contains no files');
79
+ return [...found].sort();
80
+ }
81
+ export function lineCount(content) {
82
+ if (!content.length) return 0;
83
+ return content.toString('utf8').split(/\r\n|\n|\r/).length - (/[\r\n]$/.test(content.toString('utf8')) ? 1 : 0);
84
+ }
85
+ export async function packageBoundary(root, relative, input = { repositoryRoot: root }) {
86
+ let directory = path.posix.dirname(relative);
87
+ while (true) {
88
+ const manifestPath = directory === '.' ? 'package.json' : `${directory}/package.json`;
89
+ try {
90
+ const read = await scopeRead(root, manifestPath, input); let manifest;
91
+ try { manifest = JSON.parse(read.content.toString('utf8')); } catch { scopeFail('ENTROROVE_PACKAGE_INVALID', 'package manifest is not valid JSON'); }
92
+ return { path: directory, manifestPath, manifestDigest: read.sha256, name: typeof manifest.name === 'string' ? manifest.name : null };
93
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
94
+ if (directory === '.') break;
95
+ directory = path.posix.dirname(directory);
96
+ }
97
+ return { path: relative.includes('/') ? relative.split('/')[0] : '.', manifestPath: null, manifestDigest: null, name: null };
98
+ }
99
+ export async function stableScope(root, files, input = { repositoryRoot: root }) {
100
+ for (const file of files) {
101
+ if (file.exists === false) {
102
+ if ((await scopeTarget(root, file.path, true)).exists) scopeFail('ENTROROVE_SCOPE_CHANGED', 'a deleted target reappeared during inspection');
103
+ } else if ((await scopeRead(root, file.path, input)).sha256 !== file.sha256) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope files changed before the observation completed');
104
+ }
105
+ }
106
+ export const scopeDigest = value => sha256(canonical(value));