@entrorove/skill 0.1.23 → 0.1.25

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 (167) hide show
  1. package/AGREEMENT.md +6 -6
  2. package/CAPABILITY-MAP.md +6 -0
  3. package/CHANGELOG.md +10 -0
  4. package/SKILL.md +18 -4
  5. package/archguard-runtime-build.mjs +15 -0
  6. package/blueprint-criterion-runner.mjs +110 -0
  7. package/blueprint-criterion-wire.mjs +78 -0
  8. package/calctool-runtime-build.mjs +59 -0
  9. package/calctool-runtime-entry.mjs +92 -0
  10. package/cli.mjs +35 -83
  11. package/local-approved-plan.mjs +74 -15
  12. package/local-archguard-ast.mjs +371 -0
  13. package/local-archguard-checkpoint.mjs +123 -0
  14. package/local-archguard-components.mjs +177 -0
  15. package/local-archguard-contract.mjs +109 -0
  16. package/local-archguard-contracts.mjs +42 -0
  17. package/local-archguard-evaluate.mjs +120 -0
  18. package/local-archguard-language-ast.mjs +165 -0
  19. package/local-archguard-mutation-fence.mjs +28 -0
  20. package/local-archguard-operations.mjs +25 -0
  21. package/local-archguard-scope.mjs +64 -0
  22. package/local-archguard-signing.mjs +52 -0
  23. package/local-archguard-snapshot.mjs +126 -0
  24. package/local-archguard-state.mjs +200 -0
  25. package/local-archguard.mjs +74 -0
  26. package/local-budget-git-attributes.mjs +49 -0
  27. package/local-budget-git-loader.mjs +61 -0
  28. package/local-budget-git-native-attributes.mjs +81 -0
  29. package/local-budget-git-native-darwin.mjs +104 -0
  30. package/local-budget-git-native-linux.mjs +93 -0
  31. package/local-budget-git-native-reads.mjs +88 -0
  32. package/local-budget-git-native-request.mjs +53 -0
  33. package/local-budget-git-native-runtime.mjs +18 -0
  34. package/local-budget-git-native.c +313 -0
  35. package/local-budget-git-native.mjs +109 -0
  36. package/local-budget-git-outcome.mjs +33 -0
  37. package/local-budget-git-scratch-loader.mjs +158 -0
  38. package/local-budget-git-scratch.mjs +112 -0
  39. package/local-budget-git-system-attributes.mjs +44 -0
  40. package/local-budget-git.mjs +92 -0
  41. package/local-budget-grants.mjs +82 -0
  42. package/local-budget-handoff-completion.mjs +70 -0
  43. package/local-budget-handoff-contracts.mjs +53 -0
  44. package/local-budget-handoff.mjs +229 -0
  45. package/local-budget-lock.mjs +55 -0
  46. package/local-budget-reads.mjs +135 -0
  47. package/local-budget-renewal.mjs +86 -34
  48. package/local-budget-source-bindings.mjs +51 -0
  49. package/local-budget-store.mjs +131 -0
  50. package/local-budget-transition.mjs +46 -0
  51. package/local-budget.mjs +185 -0
  52. package/local-calctool-cli.mjs +24 -0
  53. package/local-calctool-contracts.mjs +42 -0
  54. package/local-calctool-runtime.mjs +10 -0
  55. package/local-calctool-storage.mjs +101 -0
  56. package/local-calctool.mjs +103 -0
  57. package/local-chain-bindings.mjs +93 -0
  58. package/local-chain-broker.mjs +61 -0
  59. package/local-chain-contracts.mjs +21 -0
  60. package/local-chain-controller.mjs +321 -0
  61. package/local-chain-human.mjs +48 -0
  62. package/local-chain-lock.mjs +54 -0
  63. package/local-chain-operations.mjs +7 -0
  64. package/local-chain-plan.mjs +232 -0
  65. package/local-chain-release-labels.mjs +12 -0
  66. package/local-chain-results.mjs +75 -0
  67. package/local-cli.mjs +65 -24
  68. package/local-contract-types.mjs +46 -0
  69. package/local-contracts.mjs +126 -0
  70. package/local-credential.mjs +101 -0
  71. package/local-decimal.mjs +85 -0
  72. package/local-error.mjs +23 -0
  73. package/local-evidence-verification.mjs +199 -0
  74. package/local-execution.mjs +115 -304
  75. package/local-final-gate16-bundle.mjs +21 -0
  76. package/local-final-gate16-checks.mjs +88 -0
  77. package/local-final-gate16-contract.mjs +15 -0
  78. package/local-final-gate16-plan.mjs +66 -0
  79. package/local-final-gate16-proof.mjs +75 -0
  80. package/local-final-gate16-release.mjs +17 -0
  81. package/local-final-gate16-state.mjs +70 -0
  82. package/local-final-gate16-worker.mjs +19 -0
  83. package/local-final-gate16.mjs +137 -0
  84. package/local-formula-ast.mjs +106 -0
  85. package/local-formula-engine.mjs +125 -0
  86. package/local-formula-tests.mjs +61 -0
  87. package/local-formula.mjs +25 -125
  88. package/local-integrity.mjs +22 -41
  89. package/local-invocations.mjs +142 -0
  90. package/local-ledger.mjs +15 -9
  91. package/local-merge-archguard-verification.d.mts +44 -0
  92. package/local-merge-archguard-verification.mjs +240 -0
  93. package/local-merge-archguard.mjs +138 -0
  94. package/local-merge-artifacts.mjs +86 -0
  95. package/local-merge-authority.mjs +89 -0
  96. package/local-merge-contracts.mjs +56 -0
  97. package/local-merge-controller.mjs +170 -0
  98. package/local-merge-head.mjs +56 -0
  99. package/local-merge-journal.mjs +77 -0
  100. package/local-merge-mutation.mjs +97 -0
  101. package/local-merge-rollback.mjs +126 -0
  102. package/local-merge-snapshot-rollback.mjs +26 -0
  103. package/local-merge-storage.mjs +103 -0
  104. package/local-merge-targets.mjs +47 -0
  105. package/local-merge-transaction.mjs +133 -0
  106. package/local-mutation-audit.mjs +34 -0
  107. package/local-mutation-authority.mjs +103 -0
  108. package/local-mutation-batch-receipt.mjs +84 -0
  109. package/local-mutation-batch.mjs +143 -0
  110. package/local-mutation-binding.mjs +89 -0
  111. package/local-mutation-files.mjs +270 -0
  112. package/local-mutation-pass-store.mjs +63 -0
  113. package/local-mutation-snapshot.mjs +200 -0
  114. package/local-mutation-wire.mjs +89 -0
  115. package/local-probe.mjs +133 -11
  116. package/local-scope-candidate.mjs +28 -0
  117. package/local-scope-context.mjs +28 -0
  118. package/local-scope-files.mjs +106 -0
  119. package/local-scope-git-attributes.mjs +200 -0
  120. package/local-scope-git-comparison.mjs +8 -0
  121. package/local-scope-git-history.mjs +72 -0
  122. package/local-scope-git-reads.mjs +66 -0
  123. package/local-scope-git.mjs +124 -0
  124. package/local-scope-repository.mjs +249 -0
  125. package/local-scope-signature.mjs +70 -0
  126. package/local-scope-snapshot.mjs +39 -0
  127. package/local-scope-state.mjs +204 -0
  128. package/local-scope-workspaces.mjs +83 -0
  129. package/local-swarm-authority.mjs +143 -0
  130. package/local-swarm-baseline.mjs +32 -0
  131. package/local-swarm-completion.mjs +102 -0
  132. package/local-swarm-contracts.mjs +47 -0
  133. package/local-swarm-mutation.mjs +142 -0
  134. package/local-swarm-priority.mjs +237 -0
  135. package/local-swarm-state.mjs +102 -0
  136. package/local-swarm-storage.mjs +135 -0
  137. package/local-swarm.mjs +179 -0
  138. package/local-task-handoff-completion-wire.mjs +71 -0
  139. package/local-task-handoff-contracts.mjs +66 -0
  140. package/local-task-handoff-fence.mjs +478 -0
  141. package/local-task-handoff-observation.mjs +41 -0
  142. package/local-task-handoff-overlay-wire.mjs +94 -0
  143. package/local-task-handoff-read-authority.mjs +236 -0
  144. package/local-task-handoff-storage.mjs +73 -0
  145. package/local-task-handoff-write-archive.mjs +80 -0
  146. package/local-task-handoff-write-constraints.mjs +66 -0
  147. package/local-task-handoff-write-receipts.mjs +99 -0
  148. package/local-task-handoff-write-wire.mjs +99 -0
  149. package/local-task-routing.mjs +202 -22
  150. package/manifest.json +1 -1
  151. package/package.json +164 -3
  152. package/release-state.json +164 -18
  153. package/release.mjs +242 -4
  154. package/releases.json +12 -0
  155. package/remote-client.mjs +127 -36
  156. package/remote-transport.mjs +80 -0
  157. package/review-client.mjs +38 -0
  158. package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
  159. package/runtime/calctool-runtime-v7.0.43.mjs +1910 -0
  160. package/runtime/git-native-darwin-v1.mjs +5 -0
  161. package/runtime/git-native-linux-v1.mjs +5 -0
  162. package/task-message-cli.mjs +47 -0
  163. package/task-message-entry.mjs +159 -0
  164. package/workflow-local-client.mjs +35 -0
  165. package/workflow-local-contracts.mjs +58 -0
  166. package/workflow-local-request.mjs +53 -0
  167. package/workflow-local-runner.mjs +350 -0
@@ -0,0 +1,94 @@
1
+ import { createPublicKey, verify } from 'node:crypto';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import Ajv from 'ajv';
4
+ import { HANDOFF_PASS_FIELDS, HANDOFF_LEASE_FIELDS, HANDOFF_CANDIDATE_FIELDS, HANDOFF_SCOPE_CONTRACT_SCHEMA,
5
+ HANDOFF_ARCHITECTURE_AUTHORITY_SCHEMA } from './local-task-handoff-contracts.mjs';
6
+ export { HANDOFF_PASS_FIELDS, HANDOFF_LEASE_FIELDS, HANDOFF_CANDIDATE_FIELDS } from './local-task-handoff-contracts.mjs';
7
+
8
+
9
+
10
+ export const HANDOFF_RESTORATION_FIELDS = ['schemaVersion', 'handoffId', 'messageRequestId', 'ownerId', 'taskId', 'flowId', 'taskDigest',
11
+ 'chainDigest', 'localRepositoryDigest', 'epoch', 'previousEpoch', 'receiverOriginalScopeDigest', 'paths', 'claimedPassDigest',
12
+ 'claimedLeaseDigest', 'revokedPassIds', 'revokedLeaseIds', 'completionReceiptId', 'completionIdleReceiptDigest', 'baselineReceiptDigest', 'issuedAt', 'expiresAt'];
13
+
14
+ function fail(message) { const error = new Error(message); error.code = 'ENTROROVE_HANDOFF_OVERLAY'; throw error; }
15
+ export function canonicalHandoffSignature(value) {
16
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{86}$/.test(value)) fail('handoff signature encoding must be canonical Ed25519 base64url');
17
+ const bytes = Buffer.from(value, 'base64url');
18
+ if (bytes.length !== 64 || bytes.toString('base64url') !== value) fail('handoff signature encoding must round-trip exactly');
19
+ return bytes;
20
+ }
21
+ export function overlayAuthority(value, fields, schema, live = true) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join('\0') !== [...fields, 'authorityReceipt'].sort().join('\0')) fail('handoff overlay authority fields are invalid');
23
+ const { authorityReceipt: receipt, ...payload } = value;
24
+ if (Object.hasOwn(payload, 'messageRequestId') && (typeof payload.messageRequestId !== 'string'
25
+ || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(payload.messageRequestId))) fail('handoff original message request identifier is invalid');
26
+ const encoded = process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY; let key, der;
27
+ try { der = Buffer.from(encoded ?? '', 'base64'); key = createPublicKey({ key: der, format: 'der', type: 'spki' }); }
28
+ catch { fail('handoff overlay cloud public identity is unavailable'); }
29
+ const bytes = Buffer.from(canonical(payload));
30
+ if (key.asymmetricKeyType !== 'ed25519' || key.export({ format: 'der', type: 'spki' }).toString('base64') !== encoded
31
+ || payload.schemaVersion !== schema || !receipt || Object.keys(receipt).sort().join('\0') !== ['schemaVersion', 'keyId', 'payloadDigest', 'signature'].sort().join('\0')
32
+ || receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0' || receipt.keyId !== sha256(der)
33
+ || receipt.payloadDigest !== sha256(bytes) || !verify(null, bytes, key, canonicalHandoffSignature(receipt.signature))) fail('handoff overlay cloud signature or original binding is invalid');
34
+ const issued = Date.parse(payload.issuedAt), expires = Date.parse(payload.expiresAt);
35
+ if (!Number.isFinite(issued) || !Number.isFinite(expires) || issued > Date.now() || expires <= issued || expires - issued > 90_000
36
+ || (live && expires <= Date.now())) fail('handoff overlay authority is expired');
37
+ return payload;
38
+ }
39
+ const permissionAjv = new Ajv({ strict: true });
40
+ const scopeValidator = permissionAjv.compile(HANDOFF_SCOPE_CONTRACT_SCHEMA);
41
+ const architectureValidator = permissionAjv.compile(HANDOFF_ARCHITECTURE_AUTHORITY_SCHEMA);
42
+ const permissionFields = ['previousOwnerScopeContract', 'scopeContract', 'architectureAuthority'];
43
+ function receiverPermissions(pass, challenge, live) {
44
+ const original = pass.previousOwnerScopeContract, scope = pass.scopeContract, architecture = pass.architectureAuthority;
45
+ const matches = (target, paths) => paths.some(prefix => target === prefix || target.startsWith(`${prefix}/`));
46
+ if (!scopeValidator(original) || !scopeValidator(scope) || sha256(canonical(original)) !== pass.previousOwnerScopeDigest
47
+ || sha256(canonical(scope)) !== pass.scopeContractDigest || canonical(scope) !== canonical({ ...original, allowedPaths: pass.paths })
48
+ || pass.paths.some(target => !matches(target, original.allowedPaths) || matches(target, original.forbiddenPaths))) fail('handoff full scope permission changed its original digest, whitelist or frozen mutation limits');
49
+ if (architecture === null) {
50
+ if (pass.architectureContractDigest !== null) fail('handoff permission omitted its original architecture authority');
51
+ return;
52
+ }
53
+ if (!architectureValidator(architecture) || architecture.flowId !== pass.previousOwnerFlowId || architecture.ownerId !== pass.ownerId
54
+ || architecture.taskDigest !== challenge.previousOwnerTaskDigest || architecture.chainDigest !== challenge.previousOwnerChainDigest
55
+ || architecture.contractDigest !== pass.architectureContractDigest || sha256(canonical(architecture.contract)) !== architecture.contractDigest
56
+ || architecture.revision !== architecture.contract.revision || sha256(canonical(architecture.ledger)) !== architecture.ledgerDigest
57
+ || architecture.driftEpochStart > architecture.ledger.length
58
+ || (live && (architecture.interrupted || architecture.driftStatus === 'red' || architecture.decisions.some(row => row.status === 'pending')))) fail('handoff architecture permission changed its original source identity, contract, ledger or active drift boundary');
59
+ for (const row of architecture.decisions) {
60
+ if (row.interactionDigest !== sha256(canonical(row.interaction)) || canonical(row.intent) !== canonical(row.interaction.callback.payload)
61
+ || row.actorId !== architecture.ownerId || !row.answer || !row.answerRequestId || !row.answeredAt
62
+ || row.interaction.callback.operation !== (row.intent.action === 'update' ? 'contract-update' : 'resume')) fail('handoff architecture permission lost its actual complete decision history binding');
63
+ }
64
+ }
65
+ export function receiverClaim(value, ctx, live = true) {
66
+ const present = permissionFields.filter(field => Object.hasOwn(value?.pass ?? {}, field));
67
+ if (present.length !== 3 && (present.length || live)) fail('live handoff requires full original scope and architecture permissions; incomplete grants are history only');
68
+ const fields = present.length ? HANDOFF_PASS_FIELDS : HANDOFF_PASS_FIELDS.filter(field => !permissionFields.includes(field));
69
+ const pass = overlayAuthority(value?.pass, fields, 'entrorove.task-handoff-write-pass/1.0', live);
70
+ const lease = overlayAuthority(value?.lease, HANDOFF_LEASE_FIELDS, 'entrorove.task-handoff-provisional-lease/1.0', live);
71
+ const challenge = overlayAuthority(value?.challenge, HANDOFF_CANDIDATE_FIELDS, 'entrorove.task-handoff-scope-challenge/1.0', false);
72
+ const common = ['handoffId', 'messageRequestId', 'ownerId', 'flowId', 'taskDigest', 'chainDigest', 'runnerKeyId', 'localRepositoryDigest', 'epoch', 'receiverEpoch', 'paths'];
73
+ if (pass.flowId !== ctx.flowId || pass.localRepositoryDigest !== ctx.repo || common.some(key => canonical(pass[key]) !== canonical(lease[key]) || canonical(pass[key]) !== canonical(challenge[key]))
74
+ || pass.receiverTaskId !== lease.taskId || pass.receiverTaskId !== challenge.receiverTaskId || lease.purpose !== 'takeover-write'
75
+ || lease.mutationAllowed !== true || lease.generation !== 1 || pass.passId !== lease.passId || lease.passDigest !== sha256(canonical(value.pass))
76
+ || pass.receiptId !== lease.claimReceiptId || lease.challengeId !== challenge.challengeId || pass.previousOwnerTaskId !== challenge.previousOwnerTaskId
77
+ || pass.previousOwnerFlowId !== challenge.previousOwnerFlowId || pass.previousOwnerScopeDigest !== challenge.originalScopeDigest
78
+ || pass.receiverOriginalScopeDigest !== challenge.receiverOriginalScopeDigest || !Array.isArray(pass.paths) || !pass.paths.length
79
+ || new Set(pass.paths).size !== pass.paths.length || !Number.isSafeInteger(pass.receiverEpoch) || pass.receiverEpoch < 1) fail('handoff pass, lease and fresh scope challenge do not bind one receiver takeover');
80
+ if (present.length) receiverPermissions(pass, challenge, live);
81
+ return { pass, lease, challenge };
82
+ }
83
+ export function receiverRestoration(value, claim, ctx, live = true) {
84
+ const payload = overlayAuthority(value, HANDOFF_RESTORATION_FIELDS, 'entrorove.task-handoff-receiver-restoration/1.0', live);
85
+ const { pass, lease } = receiverClaim(claim, ctx, false);
86
+ if (payload.flowId !== ctx.flowId || payload.localRepositoryDigest !== ctx.repo || payload.previousEpoch !== pass.receiverEpoch
87
+ || payload.epoch !== pass.receiverEpoch + 1 || payload.taskId !== pass.receiverTaskId
88
+ || ['handoffId', 'messageRequestId', 'ownerId', 'taskDigest', 'chainDigest', 'receiverOriginalScopeDigest'].some(key => payload[key] !== pass[key])
89
+ || payload.claimedPassDigest !== sha256(canonical(claim.pass)) || payload.claimedLeaseDigest !== sha256(canonical(claim.lease))
90
+ || !Array.isArray(payload.revokedPassIds) || !payload.revokedPassIds.includes(pass.passId)
91
+ || !Array.isArray(payload.revokedLeaseIds) || !payload.revokedLeaseIds.includes(lease.leaseId)
92
+ || !Array.isArray(payload.paths) || !payload.paths.length || new Set(payload.paths).size !== payload.paths.length) fail('handoff restoration does not revoke the actual claimed pass and lease or restore its original receiver scope');
93
+ return payload;
94
+ }
@@ -0,0 +1,236 @@
1
+ import { createPublicKey, randomUUID, sign, verify } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { withStateLock } from './local-state-lock.mjs';
6
+ import { atomic, context, readRegistry, regular } from './local-task-handoff-storage.mjs';
7
+ import { readTaskHandoffFence } from './local-task-handoff-fence.mjs';
8
+ import { loadTaskHandoffSigner } from './local-task-handoff-observation.mjs';
9
+ import { canonicalHandoffSignature, HANDOFF_CANDIDATE_FIELDS, overlayAuthority } from './local-task-handoff-overlay-wire.mjs';
10
+
11
+ const SCHEMA = 'entrorove.task-handoff-read-registrations/1.0';
12
+ const RECEIPT = 'entrorove.task-handoff-read-registration/1.0';
13
+ const LEASE_FIELDS = ['schemaVersion', 'purpose', 'leaseId', 'handoffId', 'messageRequestId', 'ownerId', 'taskId',
14
+ 'flowId', 'stepId', 'taskDigest', 'chainDigest', 'challengeId', 'runnerKeyId', 'localRepositoryDigest', 'epoch',
15
+ 'receiverEpoch', 'baselineReceiptDigest', 'paths', 'mutationAllowed', 'issuedAt', 'expiresAt'];
16
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/, SHA = /^[0-9a-f]{64}$/;
17
+ const BINDING_FIELDS = ['taskId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId', 'localRepositoryDigest'];
18
+ const RECEIPT_FIELDS = ['schemaVersion', 'registrationId', ...BINDING_FIELDS, 'repositoryRoot', 'readHome', 'epoch',
19
+ 'challengeId', 'handoffId', 'messageRequestId', 'candidateDigest', 'leaseDigest', 'sourceCheckpointDigest',
20
+ 'receiverCheckpointDigest', 'originalScopeDigest', 'receiverOriginalScopeDigest', 'previousOwnerAcceptanceReceiptId',
21
+ 'receiverAcceptanceReceiptId', 'paths', 'sequence', 'previousReceiptDigest', 'initialRegistration',
22
+ 'previousRegistryDigest', 'committedRegistryDigest', 'initialFenceDigest', 'registeredAt', 'keyId', 'signature'];
23
+ function fail(message) { throw Object.assign(new Error(message), { code: 'ENTROROVE_HANDOFF_READ_AUTHORITY' }); }
24
+ function exact(value, fields, label) {
25
+ if (!value || typeof value !== 'object' || Array.isArray(value)
26
+ || canonical(Object.keys(value).sort()) !== canonical([...fields].sort())) fail(`${label} fields are invalid`);
27
+ }
28
+ function pinnedRunner() {
29
+ try {
30
+ const encoded = process.env.ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY, der = Buffer.from(encoded ?? '', 'base64');
31
+ const key = createPublicKey({ key: der, type: 'spki', format: 'der' });
32
+ if (key.asymmetricKeyType !== 'ed25519' || key.export({ type: 'spki', format: 'der' }).toString('base64') !== encoded) fail('configured read runner key is not canonical');
33
+ return { key, keyId: sha256(der) };
34
+ } catch { fail('configured trusted read runner is unavailable'); }
35
+ }
36
+ function safePaths(values) {
37
+ if (!Array.isArray(values) || !values.length || values.length > 512 || new Set(values).size !== values.length
38
+ || canonical([...values].sort()) !== canonical(values)) fail('inspection paths must be exact, sorted and unique');
39
+ for (const value of values) {
40
+ if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value)
41
+ || /^[A-Za-z]:/.test(value) || /[\\\u0000-\u001f\u007f]/.test(value)
42
+ || value.split('/').some(part => !part || part === '.' || part === '..')) fail('inspection path is unsafe');
43
+ }
44
+ }
45
+ function proof(input, live = true) {
46
+ const candidate = overlayAuthority(input.handoffCandidate, HANDOFF_CANDIDATE_FIELDS, 'entrorove.task-handoff-scope-challenge/1.0', live);
47
+ const lease = overlayAuthority(input.provisionalLease, LEASE_FIELDS, 'entrorove.task-handoff-provisional-lease/1.0', live);
48
+ for (const [field, value] of Object.entries(candidate)) {
49
+ if (field.endsWith('Digest') || field === 'runnerKeyId') {
50
+ if (!(field === 'localRepositoryDigest' ? /^[0-9a-f]{32}$/ : SHA).test(value ?? '')) fail('inspection original digest binding is invalid');
51
+ } else if (HANDOFF_CANDIDATE_FIELDS.includes(field) && !['schemaVersion', 'paths', 'epoch', 'receiverEpoch', 'issuedAt', 'expiresAt'].includes(field)
52
+ && (typeof value !== 'string' || !ID.test(value))) fail('inspection original identifier is invalid');
53
+ }
54
+ safePaths(candidate.paths);
55
+ const common = ['handoffId', 'messageRequestId', 'ownerId', 'flowId', 'stepId', 'taskDigest', 'chainDigest', 'challengeId',
56
+ 'runnerKeyId', 'localRepositoryDigest', 'epoch', 'receiverEpoch', 'baselineReceiptDigest', 'paths', 'issuedAt', 'expiresAt'];
57
+ if (common.some(field => canonical(candidate[field]) !== canonical(lease[field])) || lease.taskId !== candidate.receiverTaskId
58
+ || lease.purpose !== 'scope-inspection' || lease.mutationAllowed !== false || lease.leaseId !== `handoff-read-${candidate.challengeId}`
59
+ || candidate.stepId !== `handoff-${candidate.handoffId}` || candidate.runnerKeyId !== pinnedRunner().keyId
60
+ || !Number.isSafeInteger(candidate.epoch) || candidate.epoch < 1 || !Number.isSafeInteger(candidate.receiverEpoch)
61
+ || candidate.receiverEpoch < 1) fail('read lease does not bind one immutable scope-inspection candidate');
62
+ return { candidate, lease, candidateDigest: sha256(canonical(input.handoffCandidate)), leaseDigest: sha256(canonical(input.provisionalLease)) };
63
+ }
64
+ function binding(candidate) {
65
+ return Object.fromEntries(BINDING_FIELDS.map(field => [field, field === 'taskId' ? candidate.receiverTaskId : candidate[field]]));
66
+ }
67
+ const fileFor = ctx => path.join(path.dirname(ctx.file), 'read-authorities.json');
68
+ const markerPrefix = ctx => `inspection:${sha256(ctx.registryKey)}:`;
69
+ const markerFor = (ctx, record) => `${markerPrefix(ctx)}${sha256(canonical([record.receipt.candidateDigest, record.receipt.leaseDigest]))}`;
70
+ function verifyRecord(record, ctx, sequence, previous, home) {
71
+ exact(record, ['handoffCandidate', 'provisionalLease', 'receipt'], 'persisted read registration');
72
+ const parsed = proof(record, false), receipt = record.receipt; exact(receipt, RECEIPT_FIELDS, 'read registration receipt');
73
+ const { signature, ...core } = receipt, runner = pinnedRunner(), original = binding(parsed.candidate);
74
+ const signed = Buffer.from(canonical(core)), at = Date.parse(receipt.registeredAt);
75
+ if (receipt.schemaVersion !== RECEIPT || receipt.keyId !== runner.keyId || receipt.runnerKeyId !== runner.keyId
76
+ || !verify(null, signed, runner.key, canonicalHandoffSignature(signature)) || !ID.test(receipt.registrationId ?? '')
77
+ || receipt.sequence !== sequence || receipt.previousReceiptDigest !== previous || !Number.isFinite(at)
78
+ || at < Date.parse(parsed.candidate.issuedAt) || at >= Date.parse(parsed.candidate.expiresAt) || at > Date.now()
79
+ || receipt.repositoryRoot !== ctx.root || receipt.localRepositoryDigest !== ctx.repo || receipt.flowId !== ctx.flowId
80
+ || typeof receipt.readHome !== 'string' || !path.isAbsolute(receipt.readHome) || path.resolve(receipt.readHome) !== receipt.readHome
81
+ || receipt.readHome !== home || receipt.epoch !== parsed.candidate.receiverEpoch - 1
82
+ || BINDING_FIELDS.some(field => receipt[field] !== original[field])
83
+ || receipt.candidateDigest !== parsed.candidateDigest || receipt.leaseDigest !== parsed.leaseDigest
84
+ || typeof receipt.initialRegistration !== 'boolean' || !SHA.test(receipt.previousRegistryDigest ?? '')
85
+ || !SHA.test(receipt.committedRegistryDigest ?? '') || (receipt.initialRegistration ? !SHA.test(receipt.initialFenceDigest ?? '') : receipt.initialFenceDigest !== null)) fail('persisted read registration changed its original signed home or proof chain');
86
+ for (const field of ['challengeId', 'handoffId', 'messageRequestId', 'sourceCheckpointDigest', 'receiverCheckpointDigest',
87
+ 'originalScopeDigest', 'receiverOriginalScopeDigest', 'previousOwnerAcceptanceReceiptId', 'receiverAcceptanceReceiptId', 'paths']) {
88
+ if (canonical(receipt[field]) !== canonical(parsed.candidate[field])) fail('read registration lost its full original scope and acceptance binding');
89
+ }
90
+ return original;
91
+ }
92
+ async function history(ctx, registry) {
93
+ const markers = registry.consumed.filter(value => typeof value === 'string' && value.startsWith(markerPrefix(ctx)));
94
+ let value;
95
+ try {
96
+ const control = await regular(fileFor(ctx), 8 * 1024 * 1024);
97
+ if ((process.platform !== 'win32' && (control.stat.mode & 0o077))
98
+ || (typeof process.getuid === 'function' && control.stat.uid !== process.getuid())) fail('read authority history must be owned and private');
99
+ value = JSON.parse(control.bytes.toString('utf8'));
100
+ } catch (error) { if (error.code !== 'ENOENT') throw error; if (markers.length) fail('registered read authority history is missing; recovery is required'); return null; }
101
+ exact(value, ['schemaVersion', 'records', 'pending'], 'read authority history');
102
+ if (value.schemaVersion !== SCHEMA || !Array.isArray(value.records) || value.records.length > 1024
103
+ || (!value.records.length && !value.pending)) fail('read authority history is invalid');
104
+ const all = [...value.records, ...(value.pending ? [value.pending] : [])], home = await fs.realpath(ctx.home);
105
+ let previous = null, original = null; const seen = new Set();
106
+ for (let index = 0; index < all.length; index++) {
107
+ const record = all[index], current = verifyRecord(record, ctx, index + 1, previous, home);
108
+ if (original && canonical(original) !== canonical(current)) fail('read history replaced its original receiver registration');
109
+ original = current; previous = sha256(canonical(record.receipt)); const marker = markerFor(ctx, record);
110
+ if (seen.has(marker) || (index < value.records.length && !markers.includes(marker))) fail('read authority history was duplicated or lost its external anchor');
111
+ seen.add(marker);
112
+ }
113
+ if (markers.some(marker => !seen.has(marker)) || new Set(markers).size !== markers.length) fail('read authority history dropped a retained external record');
114
+ const registered = registry.bindings[ctx.registryKey];
115
+ if (registered ? BINDING_FIELDS.some(field => registered[field] !== original[field]) : value.records.length || !value.pending.receipt.initialRegistration) fail('read authority lost its original actual runner registration');
116
+ return value;
117
+ }
118
+ // Called by the shared native context before any scope snapshot, claim or execution operation.
119
+ // It validates controls only; expired inspection history can bind home but never authorize a read.
120
+ export async function verifyTaskHandoffReadHomeAtContext(ctx) {
121
+ const value = await history(ctx, await readRegistry(ctx.registry));
122
+ return value ? { readHome: value.records[0]?.receipt.readHome ?? value.pending.receipt.readHome } : null;
123
+ }
124
+ function freshState(ctx) {
125
+ return { schemaVersion: 'entrorove.task-handoff-local-state/1.0', flowId: ctx.flowId, localRepositoryDigest: ctx.repo,
126
+ status: 'active', epoch: 0, activities: [], revokedPassIds: [], revokedLeaseIds: [], releaseReceipt: null,
127
+ baselineReceipt: null, resumeAuthorization: null, receiverClaim: null, receiverRestoration: null, receiverCompletion: null,
128
+ executionAttempts: [], writeArchive: null, writeArchiveHistory: [] };
129
+ }
130
+ function current(state, parsed, original, home) {
131
+ if (state.status !== 'active' || state.epoch !== parsed.candidate.receiverEpoch - 1
132
+ || (state.receiverClaim && !state.receiverRestoration)
133
+ || BINDING_FIELDS.some(field => original[field] !== binding(parsed.candidate)[field])
134
+ || state.executionAttempts.some(item => item.executionHome !== home)) fail('inspection cannot replace the current original receiver epoch or execution home');
135
+ }
136
+ function view(record) {
137
+ return { schemaVersion: RECEIPT, registered: true, mutationAllowed: false, readHome: record.receipt.readHome,
138
+ epoch: record.receipt.epoch, candidateDigest: record.receipt.candidateDigest, leaseDigest: record.receipt.leaseDigest, receipt: record.receipt };
139
+ }
140
+ async function finishPending(ctx, value, registry, input, home) {
141
+ const record = value.pending, receipt = record.receipt, parsed = proof(input);
142
+ if (canonical(record.handoffCandidate) !== canonical(input.handoffCandidate) || canonical(record.provisionalLease) !== canonical(input.provisionalLease)
143
+ || receipt.readHome !== home) fail('pending control registration requires its exact original signed proof and home');
144
+ const before = sha256(canonical(registry)), original = binding(parsed.candidate), initial = freshState(ctx);
145
+ if (![receipt.previousRegistryDigest, receipt.committedRegistryDigest].includes(before)) fail('pending registration cannot replace changed native registry history');
146
+ if (receipt.initialRegistration) {
147
+ let retained;
148
+ try { retained = JSON.parse((await regular(ctx.file, 9 * 1024 * 1024)).bytes.toString('utf8')); }
149
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
150
+ if (sha256(canonical(initial)) !== receipt.initialFenceDigest
151
+ || (retained && canonical(retained) !== canonical(initial))) fail('pending inspection cannot rebuild an existing execution or handoff history');
152
+ if (!retained) await atomic(ctx.file, initial);
153
+ } else current(await readTaskHandoffFence(input), parsed, original, home);
154
+ const marker = markerFor(ctx, record), next = { ...registry, bindings: { ...registry.bindings, [ctx.registryKey]: registry.bindings[ctx.registryKey] ?? original },
155
+ consumed: registry.consumed.includes(marker) ? registry.consumed : [...registry.consumed, marker] };
156
+ if (sha256(canonical(next)) !== receipt.committedRegistryDigest) fail('pending control registration changed its original commit');
157
+ if (before !== receipt.committedRegistryDigest) await atomic(ctx.registry, next);
158
+ await atomic(fileFor(ctx), { ...value, records: [...value.records, record], pending: null });
159
+ return view(record);
160
+ }
161
+ export async function registerTaskHandoffReadAuthority(input) {
162
+ const parsed = proof(input), ctx = await context(input, true), home = await fs.realpath(ctx.home);
163
+ if (parsed.candidate.flowId !== ctx.flowId || parsed.candidate.localRepositoryDigest !== ctx.repo) fail('inspection proof belongs to another actual flow or repository');
164
+ return withStateLock(ctx.registry, () => withStateLock(ctx.file, async () => {
165
+ const registry = await readRegistry(ctx.registry), retained = await history(ctx, registry);
166
+ if (retained?.pending) return finishPending(ctx, retained, registry, input, home);
167
+ const original = binding(parsed.candidate), registered = registry.bindings[ctx.registryKey];
168
+ let state;
169
+ if (registered) state = await readTaskHandoffFence(input);
170
+ else {
171
+ if (parsed.candidate.receiverEpoch !== 1) fail('an inspection lease cannot reconstruct a lost later receiver epoch');
172
+ try { await fs.lstat(ctx.file); fail('unregistered native history cannot be replaced by a scope inspection'); }
173
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
174
+ state = freshState(ctx);
175
+ }
176
+ current(state, parsed, registered ?? original, home);
177
+ const existing = retained?.records.find(record => record.receipt.candidateDigest === parsed.candidateDigest && record.receipt.leaseDigest === parsed.leaseDigest);
178
+ if (existing) return view(existing);
179
+ const records = retained?.records ?? [];
180
+ if (records.length >= 1024 || registry.consumed.length >= 50000) fail('read authority registry is full; retain and reconcile its history');
181
+ const marker = `${markerPrefix(ctx)}${sha256(canonical([parsed.candidateDigest, parsed.leaseDigest]))}`;
182
+ const next = { ...registry, bindings: { ...registry.bindings, [ctx.registryKey]: registered ?? original }, consumed: [...registry.consumed, marker] };
183
+ const core = { schemaVersion: RECEIPT, registrationId: randomUUID(), ...original, repositoryRoot: ctx.root, readHome: home,
184
+ epoch: state.epoch, ...Object.fromEntries(['challengeId', 'handoffId', 'messageRequestId', 'sourceCheckpointDigest', 'receiverCheckpointDigest',
185
+ 'originalScopeDigest', 'receiverOriginalScopeDigest', 'previousOwnerAcceptanceReceiptId', 'receiverAcceptanceReceiptId', 'paths'].map(field => [field, parsed.candidate[field]])),
186
+ candidateDigest: parsed.candidateDigest, leaseDigest: parsed.leaseDigest, sequence: records.length + 1,
187
+ previousReceiptDigest: records.length ? sha256(canonical(records.at(-1).receipt)) : null,
188
+ initialRegistration: !registered, previousRegistryDigest: sha256(canonical(registry)), committedRegistryDigest: sha256(canonical(next)),
189
+ initialFenceDigest: registered ? null : sha256(canonical(state)), registeredAt: new Date().toISOString(), keyId: parsed.candidate.runnerKeyId };
190
+ const signer = await loadTaskHandoffSigner(ctx, process.env.ENTROROVE_TASK_HANDOFF_WRITE_SIGNER_CONFIG, core.keyId);
191
+ const record = { handoffCandidate: input.handoffCandidate, provisionalLease: input.provisionalLease,
192
+ receipt: { ...core, signature: sign(null, Buffer.from(canonical(core)), signer.privateKey).toString('base64url') } };
193
+ proof(input); verifyRecord(record, ctx, core.sequence, core.previousReceiptDigest, home);
194
+ const pending = { schemaVersion: SCHEMA, records, pending: record };
195
+ if (Buffer.byteLength(canonical(pending)) > 8 * 1024 * 1024) fail('read authority history exceeds its explicit limit');
196
+ await atomic(fileFor(ctx), pending); return finishPending(ctx, pending, registry, input, home);
197
+ }));
198
+ }
199
+ export async function readTaskHandoffReadAuthority(input) {
200
+ const parsed = proof(input), ctx = await context(input), registry = await readRegistry(ctx.registry), retained = await history(ctx, registry);
201
+ if (!retained || retained.pending) fail('scope inspection has no completed native read registration');
202
+ const home = await fs.realpath(ctx.home), original = registry.bindings[ctx.registryKey];
203
+ if (parsed.candidate.flowId !== ctx.flowId || parsed.candidate.localRepositoryDigest !== ctx.repo) fail('read inspection belongs to another actual flow or repository');
204
+ current(await readTaskHandoffFence(input), parsed, original, home);
205
+ const record = retained.records.find(item => item.receipt.candidateDigest === parsed.candidateDigest && item.receipt.leaseDigest === parsed.leaseDigest);
206
+ if (!record) fail('inspection proof was never registered at this actual home');
207
+ return view(record);
208
+ }
209
+ export async function assertTaskHandoffReadHome(input) {
210
+ const ctx = await context(input), registry = await readRegistry(ctx.registry), retained = await history(ctx, registry);
211
+ if (!retained || retained.pending || !retained.records.length) fail('original receiver read home has no completed trusted registration');
212
+ const original = registry.bindings[ctx.registryKey], state = await readTaskHandoffFence(input), home = await fs.realpath(ctx.home);
213
+ if (input.taskId !== original.taskId || input.ownerId !== original.ownerId
214
+ || state.executionAttempts.some(item => item.executionHome !== home)) fail('read-home lookup changed its original receiver identity or execution home');
215
+ return { schemaVersion: RECEIPT, taskId: original.taskId, ownerId: original.ownerId, readHome: home,
216
+ epoch: state.epoch, mutationAllowed: false, registrationId: retained.records[0].receipt.registrationId };
217
+ }
218
+ export async function assertTaskHandoffReadMutationAuthority(ctx, state) {
219
+ const retained = await history(ctx, await readRegistry(ctx.registry));
220
+ if (!retained) return;
221
+ if (retained.pending) fail('pending scope inspection registration cannot grant a mutation');
222
+ if (state.receiverClaim && !state.receiverRestoration) return; // The native caller has already checked its live H pass and lease.
223
+ const home = await fs.realpath(ctx.home);
224
+ for (const item of state.executionAttempts) {
225
+ const grant = item.authority;
226
+ if (grant.epoch !== state.epoch || item.executionHome !== home || item.status === 'revoked'
227
+ || state.revokedLeaseIds.includes(grant.leaseId) || Object.hasOwn(grant, 'handoffId')) continue;
228
+ if (item.status === 'ready' && Date.parse(grant.expiresAt) > Date.now()) return;
229
+ if (item.status !== 'used') continue;
230
+ const activity = state.activities.find(value => value.executionId === item.executionId
231
+ && value.leaseId === grant.leaseId && value.epoch === state.epoch);
232
+ if (!activity || !Number.isSafeInteger(activity.pid) || activity.pid < 1) continue;
233
+ try { process.kill(activity.pid, 0); return; } catch (error) { if (error.code === 'EPERM') return; }
234
+ }
235
+ fail('scope inspection registration is read-only; a current unrevoked business authority is required for ordinary mutation');
236
+ }
@@ -0,0 +1,73 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants, promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { canonical, sha256 } from './local-integrity.mjs';
6
+ import { readBudgetedProjectFile } from './local-budget-reads.mjs';
7
+ function fail(message) { const error = new Error(message); error.code = 'ENTROROVE_HANDOFF_FENCE'; throw error; }
8
+ function id(value, label) { if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) fail(`${label} identifier is invalid`); return value; }
9
+ function exact(value, fields, label) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)
11
+ || Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) fail(`${label} fields are invalid`);
12
+ return value;
13
+ }
14
+ export async function regular(file, limit = 1024 * 1024, sourceBinding = null) {
15
+ const before = await fs.lstat(file);
16
+ if (!before.isFile() || before.isSymbolicLink() || before.size > limit) fail('handoff state/signer must be a bounded regular file');
17
+ const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
18
+ try {
19
+ const opened = await handle.stat(); if (opened.dev !== before.dev || opened.ino !== before.ino) fail('handoff file changed during open');
20
+ const bytes = sourceBinding
21
+ ? await readBudgetedProjectFile(sourceBinding.input, file, { sourcePath: sourceBinding.sourcePath })
22
+ : await handle.readFile(); const after = await handle.stat();
23
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || bytes.length > limit) fail('handoff file changed during read');
24
+ return { bytes, stat: after };
25
+ } finally { await handle.close(); }
26
+ }
27
+ export async function json(file, limit) { return JSON.parse((await regular(file, limit)).bytes.toString('utf8')); }
28
+ export async function atomic(file, value) {
29
+ const temporary = `${file}.${randomUUID()}.tmp`;
30
+ const handle = await fs.open(temporary, 'wx', 0o600);
31
+ try { await handle.writeFile(`${canonical(value)}\n`); await handle.sync(); } finally { await handle.close(); }
32
+ try { await fs.rename(temporary, file); } finally { await fs.rm(temporary, { force: true }); }
33
+ const directory = await fs.open(path.dirname(file), 'r'); try { await directory.sync(); } finally { await directory.close(); }
34
+ }
35
+ export async function context(input, create = false) {
36
+ if (typeof input.repositoryRoot !== 'string' || !path.isAbsolute(input.repositoryRoot)) fail('handoff repository root must be absolute');
37
+ const stat = await fs.lstat(input.repositoryRoot); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('handoff repository root must be real');
38
+ const root = await fs.realpath(input.repositoryRoot); const home = input.home ?? os.homedir();
39
+ if (!path.isAbsolute(home)) fail('handoff home must be absolute');
40
+ const repo = sha256(root).slice(0, 32); const flowId = id(input.flowId, 'flowId');
41
+ const anchor = process.env.ENTROROVE_TASK_HANDOFF_STATE_DIRECTORY;
42
+ if (!anchor || !path.isAbsolute(anchor)) fail('trusted handoff state directory is unavailable; configure the registered runner anchor');
43
+ const anchorStat = await fs.lstat(anchor);
44
+ const realAnchor = await fs.realpath(anchor); const relativeRoot = path.relative(root, realAnchor);
45
+ if (!anchorStat.isDirectory() || anchorStat.isSymbolicLink() || realAnchor !== path.resolve(anchor)
46
+ || relativeRoot === '' || (!relativeRoot.startsWith(`..${path.sep}`) && relativeRoot !== '..' && !path.isAbsolute(relativeRoot))
47
+ || (process.platform !== 'win32' && (anchorStat.mode & 0o077))
48
+ || (typeof process.getuid === 'function' && anchorStat.uid !== process.getuid())) fail('trusted runner anchor must be external, real, owned and private');
49
+ const registry = path.join(realAnchor, 'registry.json'); await readRegistry(registry);
50
+ const directory = path.join(realAnchor, 'flows', repo, flowId);
51
+ const relative = path.relative(realAnchor, directory); let current = realAnchor;
52
+ for (const part of relative.split(path.sep)) {
53
+ current = path.join(current, part);
54
+ try { if (create) await fs.mkdir(current, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
55
+ try { const value = await fs.lstat(current); if (!value.isDirectory() || value.isSymbolicLink()) fail('handoff state directory must be real'); }
56
+ catch (error) { if (error.code !== 'ENOENT' || create) throw error; }
57
+ }
58
+ const ctx = { root, home, repo, flowId, registry, registryKey: `${repo}:${flowId}`, file: path.join(directory, 'fence.json') };
59
+ const { verifyTaskHandoffReadHomeAtContext } = await import('./local-task-handoff-read-authority.mjs');
60
+ await verifyTaskHandoffReadHomeAtContext(ctx);
61
+ return ctx;
62
+ }
63
+ export async function readRegistry(file) {
64
+ let value;
65
+ try { const control = await regular(file, 8 * 1024 * 1024);
66
+ if ((process.platform !== 'win32' && (control.stat.mode & 0o077)) || (typeof process.getuid === 'function' && control.stat.uid !== process.getuid())) fail('trusted registry must be owned and private');
67
+ value = JSON.parse(control.bytes.toString('utf8')); }
68
+ catch (error) { if (error.code === 'ENOENT') fail('trusted runner registry is missing; cloud recovery is required'); throw error; }
69
+ exact(value, ['schemaVersion', 'bindings', 'consumed'], 'trusted runner registry');
70
+ if (value.schemaVersion !== 'entrorove.task-handoff-runner-registry/1.0' || !value.bindings || typeof value.bindings !== 'object'
71
+ || Array.isArray(value.bindings) || !Array.isArray(value.consumed) || value.consumed.length > 50000 || Object.keys(value.bindings).length > 2048) fail('trusted runner registry is invalid');
72
+ return value;
73
+ }
@@ -0,0 +1,80 @@
1
+ import { canonical, sha256, verifySnapshotManifest } from './local-integrity.mjs';
2
+ import { receiverClaim } from './local-task-handoff-overlay-wire.mjs';
3
+ import { evaluateArchguardDrift } from './local-archguard-evaluate.mjs';
4
+ import { handoffWriteFail as fail, verifyWriteReceipt, verifyWriteNativeResult } from './local-task-handoff-write-wire.mjs';
5
+
6
+ const ARCHIVE = 'entrorove.task-handoff-write-archive/1.0';
7
+ const same = (left, right) => canonical(left) === canonical(right);
8
+ const emptyHash = sha256('');
9
+ function exact(value, fields, name) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)
11
+ || Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) fail(`${name} fields are invalid`);
12
+ }
13
+ export function newHandoffWriteArchive(claim, manifest, ctx) {
14
+ const { pass, lease, challenge } = receiverClaim(claim, ctx, false);
15
+ const binding = { snapshotId: pass.snapshotId, flowId: pass.flowId, stepId: lease.stepId, repositoryDigest: pass.localRepositoryDigest };
16
+ if (!verifySnapshotManifest(manifest, binding).verified || manifest.snapshotDigest !== pass.scopeSnapshotDigest
17
+ || !same(manifest.paths, pass.paths) || manifest.handoffEpoch !== pass.receiverEpoch
18
+ || manifest.handoffBaselineReceiptDigest !== challenge.baselineReceiptDigest) fail('write archive lost its accepted original snapshot');
19
+ return { schemaVersion: ARCHIVE, claim: structuredClone(claim), manifest: structuredClone(manifest),
20
+ receipts: [], pending: null, interrupted: false };
21
+ }
22
+ export async function verifyHandoffWriteArchive(archive, ctx) {
23
+ exact(archive, ['schemaVersion', 'claim', 'manifest', 'receipts', 'pending', 'interrupted'], 'write archive');
24
+ if (archive.schemaVersion !== ARCHIVE || !Array.isArray(archive.receipts) || archive.receipts.length > 4096
25
+ || Buffer.byteLength(canonical(archive.receipts)) > 768_000) fail('complete ordered receipt batch exceeds its explicit limit');
26
+ const initialized = newHandoffWriteArchive(archive.claim, archive.manifest, ctx);
27
+ const { pass } = receiverClaim(archive.claim, ctx, false), authority = pass.architectureAuthority;
28
+ const files = new Map(initialized.manifest.files.map(file => [file.path, { exists: file.exists, sha256: file.exists ? file.sha256 : emptyHash }]));
29
+ const entries = [], nonces = new Set(), blocks = new Set(), ids = new Set();
30
+ let last = null, drift = authority ? await evaluateArchguardDrift(authority.ledger.slice(authority.driftEpochStart)) : null;
31
+ for (const receipt of archive.receipts) {
32
+ if (drift?.trend === 'red') fail('interrupted native ledger cannot append another result');
33
+ verifyWriteReceipt(receipt, archive.claim, ctx);
34
+ if (receipt.sequence !== ids.size + 1 || receipt.previousReceiptDigest !== (last ? sha256(canonical(last)) : null)
35
+ || nonces.has(receipt.nonce) || blocks.has(receipt.blockId) || ids.has(receipt.receiptId)
36
+ || (last && Date.parse(receipt.observedAt) < Date.parse(last.observedAt))) fail('ordered sequence, previous receipt, nonce or observation changed');
37
+ const before = files.get(receipt.path);
38
+ if (!before || before.sha256 !== receipt.beforeSha256 || before.exists !== receipt.beforeExists
39
+ || (!receipt.beforeExists && receipt.beforeSha256 !== emptyHash) || (!receipt.actualAfterExists && receipt.actualAfterSha256 !== emptyHash)) fail('actual byte history lost its original or previous baseline');
40
+ if (receipt.writeOutcome === 'blocked' ? receipt.actualAfterSha256 !== receipt.beforeSha256 || receipt.actualAfterExists !== receipt.beforeExists
41
+ : receipt.actualAfterSha256 !== receipt.proposedAfterSha256 || !receipt.actualAfterExists) fail('physical write outcome contradicts actual observed bytes');
42
+ const nativeDrift = await verifyWriteNativeResult(receipt, authority, entries);
43
+ if (receipt.ledgerEntry) { entries.push(receipt.ledgerEntry); drift = nativeDrift; }
44
+ files.set(receipt.path, { exists: receipt.actualAfterExists, sha256: receipt.actualAfterSha256 });
45
+ nonces.add(receipt.nonce); blocks.add(receipt.blockId); ids.add(receipt.receiptId); last = receipt;
46
+ }
47
+ if (archive.interrupted !== (drift?.trend === 'red')) fail('actual local interruption state changed');
48
+ if (archive.pending !== null) {
49
+ exact(archive.pending, ['blockId', 'path', 'beforeSha256', 'beforeExists', 'proposedAfterSha256', 'observedAt', 'nonce', 'sequence', 'previousReceiptDigest'], 'pending actual write');
50
+ const before = files.get(archive.pending.path);
51
+ if (!before || before.sha256 !== archive.pending.beforeSha256 || before.exists !== archive.pending.beforeExists
52
+ || archive.pending.sequence !== archive.receipts.length + 1
53
+ || archive.pending.previousReceiptDigest !== (last ? sha256(canonical(last)) : null)) fail('unknown pending write lost its original current byte baseline');
54
+ }
55
+ return { files, entries, drift, receiptsDigest: sha256(canonical(archive.receipts)), interrupted: archive.interrupted };
56
+ }
57
+ export async function verifyTaskHandoffWriteArchives(state, ctx) {
58
+ if (!Array.isArray(state.writeArchiveHistory) || Buffer.byteLength(canonical([state.writeArchive, state.writeArchiveHistory])) > 8 * 1024 * 1024) fail('retained write archive history exceeds its explicit limit');
59
+ const claims = new Set(), nonces = new Set(), receiptIds = new Set();
60
+ for (const archive of [...state.writeArchiveHistory, ...(state.writeArchive ? [state.writeArchive] : [])]) {
61
+ const original = archive.claim?.pass?.handoffId;
62
+ if (!original || claims.has(original)) fail('retained original handoff archive is duplicated');
63
+ claims.add(original); await verifyHandoffWriteArchive(archive, ctx);
64
+ for (const receipt of archive.receipts) {
65
+ if (nonces.has(receipt.nonce) || receiptIds.has(receipt.receiptId)) fail('retained cross-handoff signed nonce or receipt is duplicated');
66
+ nonces.add(receipt.nonce); receiptIds.add(receipt.receiptId);
67
+ }
68
+ }
69
+ if (state.writeArchive && !same(state.writeArchive.claim, state.receiverClaim)) fail('current write archive lost its exact signed receiver claim');
70
+ if (state.receiverClaim && !state.writeArchive) fail('receiver write archive is missing; explicit recovery is required');
71
+ }
72
+ export async function handoffWriteCompletionBatch(state, ctx, finalFiles) {
73
+ const archive = state.writeArchive;
74
+ if (!archive || archive.pending) fail('unknown or pending physical write prevents completion');
75
+ const verified = await verifyHandoffWriteArchive(archive, ctx);
76
+ if (!Array.isArray(finalFiles) || finalFiles.length !== verified.files.size || new Set(finalFiles.map(file => file.path)).size !== finalFiles.length
77
+ || finalFiles.some(file => !same(verified.files.get(file.path), { exists: file.exists !== false, sha256: file.sha256 }))) fail('final actual files differ from the complete trusted byte chain');
78
+ return { architectureAuthorityDigest: archive.claim.pass.architectureAuthority ? sha256(canonical(archive.claim.pass.architectureAuthority)) : null,
79
+ writeCheckpointReceipts: structuredClone(archive.receipts), writeCheckpointReceiptsDigest: verified.receiptsDigest };
80
+ }
@@ -0,0 +1,66 @@
1
+ import { readSnapshotCopy14 } from './local-mutation-snapshot.mjs';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { scopeRead, scopeTarget } from './local-scope-files.mjs';
4
+
5
+ function fail(message) { throw Object.assign(new Error(message), { code: 'ENTROROVE_HANDOFF_WRITE_CONSTRAINT' }); }
6
+ function lines(text) { return text.match(/[^\n]*\n|[^\n]+$/g) ?? []; }
7
+ function changedLines(before, after, budget) {
8
+ const a = lines(before), b = lines(after);
9
+ if (Math.abs(a.length - b.length) > budget) return budget + 1;
10
+ let frontier = new Map([[1, 0]]);
11
+ for (let distance = 0; distance <= Math.min(a.length + b.length, budget); distance++) {
12
+ const next = new Map();
13
+ for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
14
+ let x = diagonal === -distance || (diagonal !== distance
15
+ && (frontier.get(diagonal - 1) ?? -1) < (frontier.get(diagonal + 1) ?? -1))
16
+ ? frontier.get(diagonal + 1) ?? 0 : (frontier.get(diagonal - 1) ?? 0) + 1;
17
+ let y = x - diagonal;
18
+ while (x < a.length && y < b.length && a[x] === b[y]) { x++; y++; }
19
+ if (x >= a.length && y >= b.length) return distance;
20
+ next.set(diagonal, x);
21
+ }
22
+ frontier = next;
23
+ }
24
+ return budget + 1;
25
+ }
26
+ function text(bytes) {
27
+ const value = bytes.toString('utf8');
28
+ if (bytes.includes(0) || !Buffer.from(value, 'utf8').equals(bytes)) fail('Inherited scope budget requires actual valid UTF-8 source; binary changes need independent authorization');
29
+ return value;
30
+ }
31
+ function allowed(file, paths) { return paths.some(item => file === item || file.startsWith(`${item}/`)); }
32
+ export async function checkHandoffWriteConstraints({ pass, manifest, root, snapshotDirectory, targetPath, content,
33
+ history, blockId, beforeSha256, readContext }) {
34
+ const scope = pass.scopeContract, original = pass.previousOwnerScopeContract;
35
+ if (!scope || !original || sha256(canonical(scope)) !== pass.scopeContractDigest
36
+ || sha256(canonical(original)) !== pass.previousOwnerScopeDigest
37
+ || !Number.isSafeInteger(scope.maxChangedLines) || scope.maxChangedLines < 0) fail('Original full inherited scope budget is missing or changed');
38
+ if (!allowed(targetPath, scope.allowedPaths) || allowed(targetPath, scope.forbiddenPaths)) fail('Original inherited scope forbids this physical write');
39
+ const proposed = typeof content === 'string' ? content : JSON.stringify(content);
40
+ if (typeof proposed !== 'string' || Buffer.byteLength(proposed) > 2_097_152) fail('Actual takeover content exceeds its bounded source limit');
41
+ let changed = 0;
42
+ for (const file of manifest.files) {
43
+ if (!pass.paths.includes(file.path) || !allowed(file.path, scope.allowedPaths) || allowed(file.path, scope.forbiddenPaths)) fail('Inherited scope cannot add or read another target path');
44
+ const preserved = await readSnapshotCopy14(readContext, snapshotDirectory, manifest, file.path);
45
+ const copy = file.exists ? preserved.content : Buffer.alloc(0);
46
+ if (file.exists && sha256(copy) !== file.sha256) fail('Original inherited scope snapshot copy changed');
47
+ const actual = await scopeTarget(root, file.path, true);
48
+ if (!file.exists && actual.exists && !scope.allowNewFiles) fail('Original inherited scope forbids new files');
49
+ if (file.exists && !actual.exists && !scope.allowDeleteFiles) fail('Original inherited scope forbids deleting files');
50
+ if (file.path === targetPath && !file.exists && !scope.allowNewFiles) fail('Original inherited scope forbids creating this file');
51
+ const candidate = file.path === targetPath ? proposed : actual.exists ? text((await scopeRead(root, file.path, readContext)).content) : '';
52
+ changed += changedLines(text(copy), candidate, scope.maxChangedLines - changed);
53
+ if (changed > scope.maxChangedLines) throw Object.assign(
54
+ new Error('Actual cumulative changed lines exceed the original owner inherited scope budget'),
55
+ { code: 'ENTROROVE_HANDOFF_SCOPE_BUDGET' });
56
+ }
57
+ const architecture = pass.architectureAuthority;
58
+ if (!architecture) return { changedLines: changed, architectureCheckpoint: null };
59
+ const { evaluateArchguardCheckpoint } = await import('./local-archguard-evaluate.mjs');
60
+ const baseline = manifest.files.find(file => file.path === targetPath);
61
+ const result = await evaluateArchguardCheckpoint(architecture.contract,
62
+ { blockId: blockId ?? `${pass.passId}:${sha256(proposed)}`, path: targetPath, content: proposed,
63
+ beforeSha256: beforeSha256 ?? baseline.sha256 },
64
+ history ?? architecture.ledger.slice(architecture.driftEpochStart));
65
+ return { changedLines: changed, architectureCheckpoint: result };
66
+ }