@entrorove/skill 0.1.23 → 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.
- package/AGREEMENT.md +6 -6
- package/CHANGELOG.md +5 -0
- package/SKILL.md +12 -4
- package/archguard-runtime-build.mjs +15 -0
- package/blueprint-criterion-runner.mjs +110 -0
- package/blueprint-criterion-wire.mjs +78 -0
- package/calctool-runtime-build.mjs +59 -0
- package/calctool-runtime-entry.mjs +92 -0
- package/cli.mjs +17 -82
- package/local-approved-plan.mjs +74 -15
- package/local-archguard-ast.mjs +371 -0
- package/local-archguard-checkpoint.mjs +123 -0
- package/local-archguard-components.mjs +177 -0
- package/local-archguard-contract.mjs +109 -0
- package/local-archguard-contracts.mjs +42 -0
- package/local-archguard-evaluate.mjs +120 -0
- package/local-archguard-language-ast.mjs +165 -0
- package/local-archguard-mutation-fence.mjs +28 -0
- package/local-archguard-operations.mjs +25 -0
- package/local-archguard-scope.mjs +64 -0
- package/local-archguard-signing.mjs +52 -0
- package/local-archguard-snapshot.mjs +126 -0
- package/local-archguard-state.mjs +200 -0
- package/local-archguard.mjs +74 -0
- package/local-budget-git-attributes.mjs +49 -0
- package/local-budget-git-loader.mjs +61 -0
- package/local-budget-git-native-attributes.mjs +81 -0
- package/local-budget-git-native-darwin.mjs +104 -0
- package/local-budget-git-native-linux.mjs +93 -0
- package/local-budget-git-native-reads.mjs +88 -0
- package/local-budget-git-native-request.mjs +53 -0
- package/local-budget-git-native-runtime.mjs +18 -0
- package/local-budget-git-native.c +313 -0
- package/local-budget-git-native.mjs +109 -0
- package/local-budget-git-outcome.mjs +33 -0
- package/local-budget-git-scratch-loader.mjs +158 -0
- package/local-budget-git-scratch.mjs +112 -0
- package/local-budget-git-system-attributes.mjs +44 -0
- package/local-budget-git.mjs +92 -0
- package/local-budget-grants.mjs +82 -0
- package/local-budget-handoff-completion.mjs +70 -0
- package/local-budget-handoff-contracts.mjs +53 -0
- package/local-budget-handoff.mjs +229 -0
- package/local-budget-lock.mjs +55 -0
- package/local-budget-reads.mjs +135 -0
- package/local-budget-renewal.mjs +86 -34
- package/local-budget-source-bindings.mjs +51 -0
- package/local-budget-store.mjs +131 -0
- package/local-budget-transition.mjs +46 -0
- package/local-budget.mjs +185 -0
- package/local-calctool-cli.mjs +22 -0
- package/local-calctool-contracts.mjs +42 -0
- package/local-calctool-runtime.mjs +10 -0
- package/local-calctool-storage.mjs +101 -0
- package/local-calctool.mjs +103 -0
- package/local-cli.mjs +49 -16
- package/local-contract-types.mjs +46 -0
- package/local-contracts.mjs +124 -0
- package/local-credential.mjs +101 -0
- package/local-decimal.mjs +85 -0
- package/local-evidence-verification.mjs +199 -0
- package/local-execution.mjs +115 -304
- package/local-final-gate16-bundle.mjs +21 -0
- package/local-final-gate16-checks.mjs +88 -0
- package/local-final-gate16-contract.mjs +15 -0
- package/local-final-gate16-plan.mjs +66 -0
- package/local-final-gate16-proof.mjs +75 -0
- package/local-final-gate16-release.mjs +17 -0
- package/local-final-gate16-state.mjs +70 -0
- package/local-final-gate16-worker.mjs +19 -0
- package/local-final-gate16.mjs +137 -0
- package/local-formula-ast.mjs +97 -0
- package/local-formula-engine.mjs +111 -0
- package/local-formula-tests.mjs +61 -0
- package/local-formula.mjs +25 -125
- package/local-integrity.mjs +22 -41
- package/local-invocations.mjs +107 -0
- package/local-ledger.mjs +2 -7
- package/local-merge-archguard-verification.d.mts +44 -0
- package/local-merge-archguard-verification.mjs +240 -0
- package/local-merge-archguard.mjs +138 -0
- package/local-merge-artifacts.mjs +86 -0
- package/local-merge-authority.mjs +89 -0
- package/local-merge-contracts.mjs +56 -0
- package/local-merge-controller.mjs +170 -0
- package/local-merge-head.mjs +56 -0
- package/local-merge-journal.mjs +77 -0
- package/local-merge-mutation.mjs +97 -0
- package/local-merge-rollback.mjs +126 -0
- package/local-merge-snapshot-rollback.mjs +26 -0
- package/local-merge-storage.mjs +103 -0
- package/local-merge-targets.mjs +47 -0
- package/local-merge-transaction.mjs +133 -0
- package/local-mutation-audit.mjs +34 -0
- package/local-mutation-authority.mjs +96 -0
- package/local-mutation-batch-receipt.mjs +84 -0
- package/local-mutation-batch.mjs +143 -0
- package/local-mutation-binding.mjs +86 -0
- package/local-mutation-files.mjs +245 -0
- package/local-mutation-pass-store.mjs +63 -0
- package/local-mutation-snapshot.mjs +200 -0
- package/local-mutation-wire.mjs +89 -0
- package/local-probe.mjs +133 -11
- package/local-scope-candidate.mjs +28 -0
- package/local-scope-context.mjs +28 -0
- package/local-scope-files.mjs +106 -0
- package/local-scope-git-attributes.mjs +200 -0
- package/local-scope-git-comparison.mjs +8 -0
- package/local-scope-git-history.mjs +72 -0
- package/local-scope-git-reads.mjs +66 -0
- package/local-scope-git.mjs +124 -0
- package/local-scope-repository.mjs +249 -0
- package/local-scope-signature.mjs +70 -0
- package/local-scope-snapshot.mjs +39 -0
- package/local-scope-state.mjs +204 -0
- package/local-scope-workspaces.mjs +83 -0
- package/local-swarm-authority.mjs +143 -0
- package/local-swarm-baseline.mjs +32 -0
- package/local-swarm-completion.mjs +102 -0
- package/local-swarm-contracts.mjs +47 -0
- package/local-swarm-mutation.mjs +142 -0
- package/local-swarm-priority.mjs +237 -0
- package/local-swarm-state.mjs +102 -0
- package/local-swarm-storage.mjs +135 -0
- package/local-swarm.mjs +179 -0
- package/local-task-handoff-completion-wire.mjs +71 -0
- package/local-task-handoff-contracts.mjs +66 -0
- package/local-task-handoff-fence.mjs +478 -0
- package/local-task-handoff-observation.mjs +38 -0
- package/local-task-handoff-overlay-wire.mjs +94 -0
- package/local-task-handoff-read-authority.mjs +236 -0
- package/local-task-handoff-storage.mjs +73 -0
- package/local-task-handoff-write-archive.mjs +80 -0
- package/local-task-handoff-write-constraints.mjs +66 -0
- package/local-task-handoff-write-receipts.mjs +99 -0
- package/local-task-handoff-write-wire.mjs +99 -0
- package/local-task-routing.mjs +202 -22
- package/manifest.json +1 -1
- package/package.json +153 -3
- package/release-state.json +153 -17
- package/release.mjs +238 -4
- package/releases.json +6 -0
- package/remote-client.mjs +127 -36
- package/remote-transport.mjs +80 -0
- package/review-client.mjs +38 -0
- package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
- package/runtime/calctool-runtime-v7.0.43.mjs +1887 -0
- package/runtime/git-native-darwin-v1.mjs +5 -0
- package/runtime/git-native-linux-v1.mjs +5 -0
- package/task-message-cli.mjs +47 -0
- package/task-message-entry.mjs +159 -0
- package/workflow-local-client.mjs +35 -0
- package/workflow-local-contracts.mjs +58 -0
- package/workflow-local-request.mjs +53 -0
- package/workflow-local-runner.mjs +350 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const MERGE_ARCHGUARD_SCHEMA = 'entrorove.archguard-merge-preflight/1.0';
|
|
4
|
+
export const MERGE_PREPARATION_SCHEMA = 'entrorove.merge-preparation-receipt/1.0';
|
|
5
|
+
export const MERGE_PIN_ENV = 'ENTROROVE_ARCHGUARD_RUNNER_PUBLIC_KEY';
|
|
6
|
+
export const SHA256 = /^[0-9a-f]{64}$/;
|
|
7
|
+
export const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
8
|
+
const idValid = value => typeof value === 'string' && ID.test(value);
|
|
9
|
+
export const mergeSha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
export const MERGE_ARCHGUARD_RUNTIME_SHA256 = '836f20b270139371bef43e3934fa7232371e42d46371c513db2a5e3c5622b215';
|
|
11
|
+
const MISSING_SHA256 = mergeSha256('entrorove.missing-file/1.0');
|
|
12
|
+
const CONTEXT_FIELDS = ['repositoryDigest', 'flowId', 'stepId', 'baselineRef', 'sourceRef', 'targetRef', 'paths',
|
|
13
|
+
'sourceFiles', 'targetFiles', 'afterManifest', 'beforeFullManifest', 'mergedArtifactSha256', 'snapshotDigest',
|
|
14
|
+
'snapshotReceiptDigest', 'architectureContractDigest', 'mappings', 'mappingProof', 'mappingDigest', 'mergeLeaseDigest', 'architectureLedgerDigest'];
|
|
15
|
+
const LEASE_CONTEXT = ['mergeId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'leaseId', 'generation', 'epoch', 'scopePaths'];
|
|
16
|
+
const OPTIONAL_CONTEXT = ['handoffEpoch'];
|
|
17
|
+
|
|
18
|
+
export function mergeCanonical(value, ancestors = new Set()) {
|
|
19
|
+
if (value === null || ['string', 'boolean'].includes(typeof value)) return JSON.stringify(value);
|
|
20
|
+
if (typeof value === 'number' && Number.isFinite(value)) return JSON.stringify(value);
|
|
21
|
+
if (!value || typeof value !== 'object' || ancestors.has(value)
|
|
22
|
+
|| (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))) {
|
|
23
|
+
throw new Error('Merge evidence must be finite plain JSON');
|
|
24
|
+
}
|
|
25
|
+
const next = new Set(ancestors).add(value);
|
|
26
|
+
return Array.isArray(value) ? `[${value.map(item => mergeCanonical(item, next)).join(',')}]`
|
|
27
|
+
: `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${mergeCanonical(value[key], next)}`).join(',')}}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function configuredMergePin(publicKey) {
|
|
31
|
+
if (typeof publicKey !== 'string' || !publicKey) throw new Error('An independently configured Archguard runner pin is required');
|
|
32
|
+
const der = Buffer.from(publicKey, 'base64');
|
|
33
|
+
if (der.toString('base64') !== publicKey) throw new Error('Archguard runner pin must be canonical base64');
|
|
34
|
+
const key = createPublicKey({ key: der, type: 'spki', format: 'der' });
|
|
35
|
+
if (key.asymmetricKeyType !== 'ed25519' || !key.export({ type: 'spki', format: 'der' }).equals(der)) {
|
|
36
|
+
throw new Error('Archguard runner pin must be canonical Ed25519 SPKI');
|
|
37
|
+
}
|
|
38
|
+
return { key, der, keyId: mergeSha256(der), publicKey };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function verifyMergeSignature(value, signature, keyId, publicKey) {
|
|
42
|
+
const pin = configuredMergePin(publicKey);
|
|
43
|
+
if (typeof signature !== 'string' || !/^[A-Za-z0-9_-]{86}$/.test(signature)) throw new Error('Archguard signature must be canonical base64url');
|
|
44
|
+
const bytes = Buffer.from(signature, 'base64url');
|
|
45
|
+
if (bytes.length !== 64 || bytes.toString('base64url') !== signature || keyId !== pin.keyId) throw new Error('Archguard signature or key identity is invalid');
|
|
46
|
+
const source = mergeCanonical(value);
|
|
47
|
+
if (Buffer.byteLength(source) > 4 * 1024 * 1024 || !verify(null, Buffer.from(source), pin.key, bytes)) throw new Error('Archguard signature is invalid');
|
|
48
|
+
return pin.keyId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function verifyMergeLifetime(value, now = Date.now(), consumedNonces) {
|
|
52
|
+
const issued = Date.parse(value.issuedAt), expires = Date.parse(value.expiresAt);
|
|
53
|
+
if (!Number.isFinite(now) || typeof value.issuedAt !== 'string' || typeof value.expiresAt !== 'string'
|
|
54
|
+
|| !Number.isFinite(issued) || !Number.isFinite(expires) || issued > now || expires <= now || expires <= issued
|
|
55
|
+
|| expires - issued > 300_000 || !idValid(value.nonce) || consumedNonces?.has(value.nonce)) {
|
|
56
|
+
throw new Error('Archguard receipt lifetime, nonce, or replay state is invalid');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function inspectMergeSignedEnvelope(value, options = {}) {
|
|
61
|
+
try {
|
|
62
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || value.schemaVersion !== options.schemaVersion) throw new Error('Merge receipt schema is invalid');
|
|
63
|
+
const { signature, ...unsigned } = value;
|
|
64
|
+
verifyMergeLifetime(value, options.now, options.consumedNonces);
|
|
65
|
+
const keyId = verifyMergeSignature(unsigned, signature, value.keyId,
|
|
66
|
+
Object.hasOwn(options, 'publicKey') ? options.publicKey : process.env[MERGE_PIN_ENV]?.trim());
|
|
67
|
+
return { verified: true, keyId, nonce: value.nonce, receiptDigest: mergeSha256(mergeCanonical(value)) };
|
|
68
|
+
} catch (error) { return { verified: false, reason: error instanceof Error ? error.message : String(error) }; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function inspectLegacyArchguardPreflight(attestation, binding = {}, options = {}) {
|
|
72
|
+
try {
|
|
73
|
+
if (!attestation || attestation.schemaVersion !== 'entrorove.archguard-preflight/1.0' || attestation.status !== 'passed'
|
|
74
|
+
|| !idValid(attestation.flowId) || !idValid(attestation.stepId)
|
|
75
|
+
|| !SHA256.test(String(attestation.snapshotDigest)) || !SHA256.test(String(attestation.contractDigest))) {
|
|
76
|
+
throw new Error('Archguard preflight is missing or not passed');
|
|
77
|
+
}
|
|
78
|
+
const keys = ['flowId', 'stepId', 'snapshotDigest', 'contractDigest'];
|
|
79
|
+
for (const key of ['mergeDigest', 'preparationDigest', 'sourceRef', 'targetRef']) if (Object.hasOwn(binding, key)) keys.push(key);
|
|
80
|
+
if (keys.some(key => !Object.hasOwn(binding, key) || mergeCanonical(attestation[key]) !== mergeCanonical(binding[key]))) throw new Error('Archguard preflight frozen binding does not match');
|
|
81
|
+
const { receipt, ...unsigned } = attestation;
|
|
82
|
+
if (!receipt || receipt.schemaVersion !== 'entrorove.runner-receipt/1.0') throw new Error('Archguard signed receipt is missing');
|
|
83
|
+
verifyMergeLifetime(attestation, options.now, options.consumedNonces);
|
|
84
|
+
const keyId = verifyMergeSignature(unsigned, receipt.signature, receipt.keyId,
|
|
85
|
+
Object.hasOwn(options, 'publicKey') ? options.publicKey : process.env[MERGE_PIN_ENV]?.trim());
|
|
86
|
+
return { verified: true, attestation, keyId, nonce: attestation.nonce, receiptDigest: mergeSha256(mergeCanonical(attestation)) };
|
|
87
|
+
} catch (error) { return { verified: false, reason: error instanceof Error ? error.message : String(error) }; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireValue(condition, message) { if (!condition) throw new Error(message); }
|
|
91
|
+
function canonicalEqual(left, right) { return mergeCanonical(left) === mergeCanonical(right); }
|
|
92
|
+
function pathValid(value) {
|
|
93
|
+
return typeof value === 'string' && value.length <= 500 && value === value.trim() && value === value.normalize('NFC')
|
|
94
|
+
&& !/^[~\/]|[\\:\u0000-\u001f\u007f]/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..');
|
|
95
|
+
}
|
|
96
|
+
function validateManifest(files, source = false) {
|
|
97
|
+
requireValue(Array.isArray(files) && files.length > 0 && files.length <= 512, 'Merge file manifest is missing or unbounded');
|
|
98
|
+
const names = files.map(file => file?.path);
|
|
99
|
+
requireValue(names.every(pathValid) && canonicalEqual(names, [...new Set(names)].sort()), 'Merge manifest paths must be canonical, sorted and unique');
|
|
100
|
+
requireValue(!names.some((name, index) => names.slice(index + 1).some(other => other.startsWith(`${name}/`))), 'Merge manifest paths overlap');
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
const fields = source ? 'exists|mode|path|sha256|sourcePath' : 'exists|mode|path|sha256';
|
|
103
|
+
requireValue(Object.keys(file).sort().join('|') === fields && (!source || pathValid(file.sourcePath)), 'Merge file origin or metadata shape is invalid');
|
|
104
|
+
requireValue(file && typeof file.exists === 'boolean' && SHA256.test(String(file.sha256))
|
|
105
|
+
&& (file.exists ? Number.isSafeInteger(file.mode) && file.mode >= 0 && file.mode <= 0o777 : file.mode === null && file.sha256 === MISSING_SHA256), 'Merge manifest file metadata is invalid');
|
|
106
|
+
requireValue(!source || file.exists, 'Merge source deletion is explicitly unsupported');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function validateMapping(value, source) {
|
|
110
|
+
const proof = value.mappingProof;
|
|
111
|
+
requireValue(proof?.schemaVersion === 'entrorove.merge-artifact-mapping/1.0' && SHA256.test(String(value.mappingDigest))
|
|
112
|
+
&& mergeSha256(mergeCanonical(proof)) === value.mappingDigest, 'Merge mapping must retain its complete original frozen proof');
|
|
113
|
+
requireBinding(proof, value, ['flowId', 'ownerId', 'taskDigest', 'chainDigest']);
|
|
114
|
+
requireValue(idValid(proof.planId) && SHA256.test(String(proof.planDigest)) && SHA256.test(String(proof.scopeArtifactDigest))
|
|
115
|
+
&& proof.blueprintBinding && typeof proof.blueprintBinding === 'object' && !Array.isArray(proof.blueprintBinding)
|
|
116
|
+
&& Object.keys(proof.blueprintBinding).length > 0, 'Merge mapping original plan or Blueprint ancestry is missing');
|
|
117
|
+
requireValue(Array.isArray(proof.entries) && proof.entries.length > 0 && proof.entries.length <= 512
|
|
118
|
+
&& proof.entries.every(entry => entry && pathValid(entry.sourcePath) && pathValid(entry.targetPath)
|
|
119
|
+
&& SHA256.test(String(entry.sha256)) && idValid(entry.producerNodeRef) && idValid(entry.producerStepId) && idValid(entry.stepId)), 'Merge mapping producer entries are invalid');
|
|
120
|
+
const targets = proof.entries.map(entry => entry.targetPath), origins = proof.entries.map(entry => entry.sourcePath);
|
|
121
|
+
requireValue(canonicalEqual(targets, [...new Set(targets)].sort()) && new Set(origins).size === origins.length, 'Merge mapping origins or targets are duplicated');
|
|
122
|
+
const expected = source.map(file => ({ sourcePath: file.sourcePath, targetPath: file.path }));
|
|
123
|
+
requireValue(canonicalEqual(value.mappings, expected), 'Merge source origins do not match their exact immutable mapping');
|
|
124
|
+
for (const file of source) requireValue(proof.entries.some(entry => entry.sourcePath === file.sourcePath
|
|
125
|
+
&& entry.targetPath === file.path && entry.sha256 === file.sha256), 'Merge mapping source is not the original declared producer artifact');
|
|
126
|
+
}
|
|
127
|
+
export function mergeArtifactSha256(files) {
|
|
128
|
+
return mergeSha256(`validator.file-manifest/1.0\n${files.map(file => `${file.path}\0${file.sha256}`).join('\n')}`);
|
|
129
|
+
}
|
|
130
|
+
function validateRef(ref, role, preparation, files) {
|
|
131
|
+
requireValue(ref?.kind === 'snapshot' && idValid(ref.artifactId) && SHA256.test(String(ref.manifestDigest)), 'Merge ref must identify a frozen snapshot');
|
|
132
|
+
const manifest = { schemaVersion: 'entrorove.merge-artifact/1.0', artifactId: ref.artifactId, role,
|
|
133
|
+
repositoryDigest: preparation.repositoryDigest, flowId: preparation.flowId, stepId: preparation.stepId, files };
|
|
134
|
+
requireValue(ref.manifestDigest === mergeSha256(mergeCanonical(manifest)), 'Merge reference does not bind the exact frozen manifest');
|
|
135
|
+
}
|
|
136
|
+
export function mergePreflightBinding(preparation) {
|
|
137
|
+
const fields = [...CONTEXT_FIELDS, ...LEASE_CONTEXT, ...OPTIONAL_CONTEXT.filter(key => Object.hasOwn(preparation, key))];
|
|
138
|
+
return { ...Object.fromEntries(fields.map(key => [key, preparation[key]])),
|
|
139
|
+
preparationDigest: mergeSha256(mergeCanonical(preparation)), mergeDigest: preparation.mergedArtifactSha256 };
|
|
140
|
+
}
|
|
141
|
+
function validatePreparation(value) {
|
|
142
|
+
requireValue(/^[0-9a-f]{32}$/.test(String(value.repositoryDigest)) && idValid(value.flowId) && idValid(value.stepId), 'Merge preparation repository, flow or step is invalid');
|
|
143
|
+
requireValue(typeof value.baselineRef === 'string' && value.baselineRef.trim() === value.baselineRef && value.baselineRef.length > 0 && value.baselineRef.length <= 2_000, 'Merge baseline reference is required');
|
|
144
|
+
for (const key of ['mergedArtifactSha256', 'snapshotDigest', 'snapshotReceiptDigest', 'architectureContractDigest', 'mergeLeaseDigest', 'architectureLedgerDigest']) requireValue(SHA256.test(String(value[key])), `Merge preparation ${key} is invalid`);
|
|
145
|
+
validateManifest(value.beforeFullManifest);
|
|
146
|
+
const source = value.sourceFiles?.map(file => ({ ...file, exists: true })); validateManifest(source, true);
|
|
147
|
+
validateManifest(value.targetFiles); validateMapping(value, source);
|
|
148
|
+
const mappedPaths = source.map(file => file.path);
|
|
149
|
+
const mappedBefore = value.beforeFullManifest.filter(file => mappedPaths.includes(file.path));
|
|
150
|
+
requireValue(canonicalEqual(value.targetFiles, mappedBefore), 'Merge target batch differs from its wide frozen target');
|
|
151
|
+
const nativeFiles = source.map(({ path, sha256 }) => ({ path, sha256 }));
|
|
152
|
+
requireValue(canonicalEqual(value.afterManifest, nativeFiles) && value.mergedArtifactSha256 === mergeArtifactSha256(nativeFiles), 'Merge actual artifact hash or manifest is inconsistent');
|
|
153
|
+
requireValue(canonicalEqual(mappedPaths, value.targetFiles.map(file => file.path)), 'Merge source and target batch path sets differ');
|
|
154
|
+
const changed = source.filter(({ sourcePath, ...file }, index) => !canonicalEqual(file, value.targetFiles[index])).map(file => file.path);
|
|
155
|
+
requireValue(changed.length > 0 && canonicalEqual(value.paths, changed), 'Merge requires exact nonempty actual changes');
|
|
156
|
+
requireValue(source.some((file, index) => !value.targetFiles[index].exists || file.sha256 !== value.targetFiles[index].sha256), 'Merge requires actual nonempty byte changes; modes alone cannot authorize it');
|
|
157
|
+
validateRef(value.sourceRef, 'source', value, source); validateRef(value.targetRef, 'target', value, value.beforeFullManifest);
|
|
158
|
+
requireValue(value.sourceRef.artifactId !== value.targetRef.artifactId, 'Merge source and target refs must be distinct');
|
|
159
|
+
for (const key of ['mergeId', 'taskId', 'ownerId', 'leaseId']) requireValue(idValid(value[key]), `Merge ${key} is invalid`);
|
|
160
|
+
for (const key of ['taskDigest', 'chainDigest']) requireValue(SHA256.test(String(value[key])), `Merge ${key} is invalid`);
|
|
161
|
+
requireValue(Number.isSafeInteger(value.generation) && value.generation > 0 && Number.isSafeInteger(value.epoch) && value.epoch >= 0, 'Merge lease generation or epoch is invalid');
|
|
162
|
+
requireValue(canonicalEqual(value.scopePaths, value.beforeFullManifest.map(file => file.path)), 'Merge preparation must cover its entire wide original snapshot');
|
|
163
|
+
if (Object.hasOwn(value, 'handoffEpoch')) requireValue(Number.isSafeInteger(value.handoffEpoch) && value.handoffEpoch >= 0, 'Merge handoff epoch is invalid');
|
|
164
|
+
}
|
|
165
|
+
function requireBinding(value, binding, fields) {
|
|
166
|
+
for (const key of fields) if (Object.hasOwn(binding, key)) requireValue(canonicalEqual(value[key], binding[key]), `Merge frozen ${key} binding does not match`);
|
|
167
|
+
}
|
|
168
|
+
export function inspectMergePreparation(preparation, binding = {}, options = {}) {
|
|
169
|
+
const signed = inspectMergeSignedEnvelope(preparation, { ...options, schemaVersion: MERGE_PREPARATION_SCHEMA,
|
|
170
|
+
publicKey: options.publicKey ?? process.env.ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY?.trim() });
|
|
171
|
+
if (!signed.verified) return signed;
|
|
172
|
+
try {
|
|
173
|
+
validatePreparation(preparation); requireBinding(preparation, binding, [...CONTEXT_FIELDS, ...LEASE_CONTEXT, ...OPTIONAL_CONTEXT]);
|
|
174
|
+
return { ...signed, preparation };
|
|
175
|
+
} catch (error) { return { verified: false, reason: error instanceof Error ? error.message : String(error) }; }
|
|
176
|
+
}
|
|
177
|
+
function validateNativeCheckpoint(result, source, target, preflight) {
|
|
178
|
+
const { checkpoint, ledgerEntry } = result;
|
|
179
|
+
requireValue(result.path === source.path && result.sourcePath === source.sourcePath && result.sha256 === source.sha256 && result.beforeSha256 === target.sha256
|
|
180
|
+
&& result.contentSha256 === source.sha256 && result.exists === true, 'Archguard checkpoint input is not the actual frozen artifact');
|
|
181
|
+
requireValue(checkpoint && ['passed', 'blocked'].includes(checkpoint.status) && typeof checkpoint.writeAllowed === 'boolean'
|
|
182
|
+
&& typeof checkpoint.rollbackRequired === 'boolean' && checkpoint.writeAllowed === !checkpoint.rollbackRequired
|
|
183
|
+
&& (checkpoint.status === 'passed') === checkpoint.writeAllowed, 'Archguard checkpoint verdict is inconsistent');
|
|
184
|
+
requireValue(ledgerEntry?.schemaVersion === 'archguard.checkpoint-ledger/1.0' && SHA256.test(String(ledgerEntry.checkpointId))
|
|
185
|
+
&& idValid(ledgerEntry.blockId) && ledgerEntry.path === source.path && ledgerEntry.beforeSha256 === target.sha256
|
|
186
|
+
&& ledgerEntry.afterSha256 === source.sha256 && ledgerEntry.contractRevision === preflight.contractRevision
|
|
187
|
+
&& ledgerEntry.status === checkpoint.status && ledgerEntry.rollbackRequired === checkpoint.rollbackRequired, 'Archguard native ledger does not bind the exact checkpoint');
|
|
188
|
+
const metrics = checkpoint.metrics;
|
|
189
|
+
requireValue(metrics?.path === source.path && typeof metrics.complexityChecked === 'boolean'
|
|
190
|
+
&& ['first-party', 'third-party', 'generated', 'dependency-lockfile'].includes(metrics.inspectionScope)
|
|
191
|
+
&& ['fileLines', 'maxNestingDepth', 'maxFunctionLines', 'animations', 'transitions'].every(key => Number.isSafeInteger(metrics[key]) && metrics[key] >= 0), 'Archguard native metrics are missing or invalid');
|
|
192
|
+
requireValue(Array.isArray(checkpoint.findings) && checkpoint.findings.every(finding => finding && typeof finding.blocking === 'boolean'
|
|
193
|
+
&& typeof finding.ruleId === 'string' && finding.ruleId && finding.entityRef === source.path && ['P0', 'P1', 'P2'].includes(finding.severity)), 'Archguard native findings are malformed');
|
|
194
|
+
const rules = [...new Set(checkpoint.findings.map(finding => finding.ruleId))].sort();
|
|
195
|
+
const blocking = [...new Set(checkpoint.findings.filter(finding => finding.blocking).map(finding => finding.ruleId))].sort();
|
|
196
|
+
requireValue(canonicalEqual(ledgerEntry.ruleIds, rules) && canonicalEqual(ledgerEntry.blockingRuleIds, blocking)
|
|
197
|
+
&& (!blocking.length || checkpoint.status === 'blocked'), 'Archguard native rule verdicts disagree');
|
|
198
|
+
requireValue(checkpoint.drift && ['green', 'yellow', 'red'].includes(checkpoint.drift.trend)
|
|
199
|
+
&& (checkpoint.drift.trend !== 'red' || checkpoint.status === 'blocked'), 'Archguard native drift verdict is invalid');
|
|
200
|
+
}
|
|
201
|
+
export function inspectMergeArchguardPreflight(preflight, binding = {}, options = {}) {
|
|
202
|
+
const signed = inspectMergeSignedEnvelope(preflight, { ...options, schemaVersion: MERGE_ARCHGUARD_SCHEMA });
|
|
203
|
+
if (!signed.verified) return signed;
|
|
204
|
+
try {
|
|
205
|
+
const preparation = options.preparation;
|
|
206
|
+
const prepared = inspectMergePreparation(preparation, binding, { publicKey: options.preparationPublicKey, now: options.now,
|
|
207
|
+
consumedNonces: options.consumedPreparationNonces ?? options.consumedNonces });
|
|
208
|
+
requireValue(prepared.verified, prepared.reason ?? 'Original merge preparation is required');
|
|
209
|
+
const lease = options.mergeLease;
|
|
210
|
+
const leased = inspectMergeSignedEnvelope(lease, { schemaVersion: 'entrorove.merge-lease/1.0', now: options.now,
|
|
211
|
+
publicKey: options.authorityPublicKey ?? process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY?.trim(),
|
|
212
|
+
consumedNonces: options.consumedLeaseNonces ?? options.consumedNonces });
|
|
213
|
+
requireValue(leased.verified, leased.reason ?? 'Original signed merge lease is required');
|
|
214
|
+
const leaseFields = [...LEASE_CONTEXT, 'repositoryDigest', 'flowId', 'stepId', 'baselineRef', 'architectureContractDigest', 'mappings', 'mappingProof', 'mappingDigest'];
|
|
215
|
+
requireValue(leaseFields.every(key => Object.hasOwn(lease, key)), 'Original merge lease fields are missing');
|
|
216
|
+
requireBinding(preparation, lease, leaseFields);
|
|
217
|
+
requireValue(canonicalEqual(preparation.sourceFiles.map(file => file.path), lease.paths) && SHA256.test(String(lease.architectureLedgerDigest))
|
|
218
|
+
&& preparation.mergeLeaseDigest === leased.receiptDigest && preparation.architectureLedgerDigest === lease.architectureLedgerDigest
|
|
219
|
+
&& preflight.mergeLeaseDigest === leased.receiptDigest && preflight.architectureLedgerDigest === lease.architectureLedgerDigest
|
|
220
|
+
&& lease.mappingProof.planId === lease.planId && lease.mappingProof.planDigest === lease.planDigest
|
|
221
|
+
&& SHA256.test(String(lease.scopeContractArtifactDigest)) && lease.mappingProof.scopeArtifactDigest === lease.scopeContractArtifactDigest
|
|
222
|
+
&& preflight.scopeContractArtifactDigest === lease.scopeContractArtifactDigest,
|
|
223
|
+
'Archguard original authority lease or frozen architecture ledger binding is invalid');
|
|
224
|
+
const expected = mergePreflightBinding(preparation);
|
|
225
|
+
requireBinding(preflight, expected, Object.keys(expected)); requireBinding(preflight, binding, [...Object.keys(expected), 'contractRawDigest', 'contractRevision', 'contractId', 'nativeLedgerHeadDigest']);
|
|
226
|
+
requireValue(preflight.nativeRuntimeSha256 === MERGE_ARCHGUARD_RUNTIME_SHA256 && preflight.architectureContract?.schemaVersion === 'archguard.contract/1.0'
|
|
227
|
+
&& mergeSha256(mergeCanonical(preflight.architectureContract)) === preflight.architectureContractDigest
|
|
228
|
+
&& preflight.contractId === preflight.architectureContract.contractId && preflight.contractRevision === preflight.architectureContract.revision
|
|
229
|
+
&& Number.isSafeInteger(preflight.contractRevision) && preflight.contractRevision > 0 && SHA256.test(String(preflight.contractRawDigest))
|
|
230
|
+
&& SHA256.test(String(preflight.nativeLedgerHeadDigest)), 'Archguard locked native contract or actual native ledger head is missing or inconsistent');
|
|
231
|
+
requireValue(Date.parse(preflight.expiresAt) <= Math.min(Date.parse(preparation.expiresAt), Date.parse(lease.expiresAt))
|
|
232
|
+
&& Date.parse(preflight.issuedAt) >= Math.max(Date.parse(preparation.issuedAt), Date.parse(lease.issuedAt)), 'Archguard preflight outlives its original frozen preparation or lease');
|
|
233
|
+
requireValue(Array.isArray(preflight.results) && preflight.results.length === preparation.sourceFiles.length, 'Every actual output file requires its own native checkpoint');
|
|
234
|
+
preflight.results.forEach((result, index) => validateNativeCheckpoint(result, preparation.sourceFiles[index], preparation.targetFiles[index], preflight));
|
|
235
|
+
const passed = preflight.results.every(result => result.checkpoint.status === 'passed');
|
|
236
|
+
requireValue(preflight.status === (passed ? 'passed' : 'blocked'), 'Archguard preflight summary disagrees with native checkpoints');
|
|
237
|
+
return { ...signed, verified: passed, authentic: true, status: preflight.status, preflight,
|
|
238
|
+
...(passed ? {} : { reason: 'Actual native Archguard checkpoint blocked the merge' }) };
|
|
239
|
+
} catch (error) { return { verified: false, reason: error instanceof Error ? error.message : String(error) }; }
|
|
240
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { context, authority, readBytes, readLedger, withExclusiveLock, fail } from './local-archguard-state.mjs';
|
|
4
|
+
import { contractFile, loadContractLock } from './local-archguard-contract.mjs';
|
|
5
|
+
import { history, drift } from './local-archguard-snapshot.mjs';
|
|
6
|
+
import { evaluateArchguardCheckpoint } from './local-archguard-evaluate.mjs';
|
|
7
|
+
import { signRunnerReceipt } from './local-archguard-signing.mjs';
|
|
8
|
+
import { readFrozenMergeArtifact } from './local-merge-artifacts.mjs';
|
|
9
|
+
import { context as mergeContext, withMergeFence } from './local-merge-storage.mjs';
|
|
10
|
+
import { inspectMergeLease } from './local-merge-authority.mjs';
|
|
11
|
+
import { configuredMergePin, inspectMergePreparation, inspectMergeArchguardPreflight, mergePreflightBinding,
|
|
12
|
+
mergeCanonical, mergeSha256, SHA256, MERGE_ARCHGUARD_SCHEMA, MERGE_ARCHGUARD_RUNTIME_SHA256 } from './local-merge-archguard-verification.mjs';
|
|
13
|
+
|
|
14
|
+
const equal = (left, right) => mergeCanonical(left) === mergeCanonical(right);
|
|
15
|
+
function requireValue(condition, code, message) { if (!condition) fail(code, message); }
|
|
16
|
+
|
|
17
|
+
async function lockedContract(ctx, expected) {
|
|
18
|
+
const current = await contractFile(ctx), locked = await loadContractLock(ctx);
|
|
19
|
+
requireValue(locked.usedConfirmations?.length > 0 && locked.repositoryDigest === ctx.repositoryDigest
|
|
20
|
+
&& current.contractDigest === locked.contractDigest && current.rawDigest === locked.rawDigest
|
|
21
|
+
&& current.contract.revision === locked.revision && current.contractDigest === expected,
|
|
22
|
+
'MERGE_ARCH_CONTRACT', 'Merge preflight requires the unchanged explicitly confirmed original architecture contract');
|
|
23
|
+
return current;
|
|
24
|
+
}
|
|
25
|
+
async function artifacts(input, preparation) {
|
|
26
|
+
const source = await readFrozenMergeArtifact(input, preparation.sourceRef);
|
|
27
|
+
const target = await readFrozenMergeArtifact(input, preparation.targetRef);
|
|
28
|
+
const after = source.manifest.files.map(({ exists, ...file }) => file);
|
|
29
|
+
requireValue(source.manifest.role === 'source' && target.manifest.role === 'target'
|
|
30
|
+
&& source.manifest.files.every(file => file.exists) && equal(after, preparation.sourceFiles)
|
|
31
|
+
&& equal(target.manifest.files, preparation.beforeFullManifest),
|
|
32
|
+
'MERGE_ARCH_ARTIFACT', 'Archguard reads only the exact frozen source and target manifests');
|
|
33
|
+
return { source, target };
|
|
34
|
+
}
|
|
35
|
+
async function assertTarget(ctx, expected) {
|
|
36
|
+
for (const file of expected) {
|
|
37
|
+
const actual = await readBytes(path.join(ctx.root, file.path), !file.exists, ctx);
|
|
38
|
+
requireValue(actual.exists === file.exists && (!file.exists || mergeSha256(actual.content) === file.sha256 && actual.mode === file.mode),
|
|
39
|
+
'MERGE_ARCH_TARGET_CHANGED', 'Actual target differs from its frozen original merge snapshot');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function scanCandidate(current, source, target, previous) {
|
|
43
|
+
const results = [], entries = [...previous], decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
44
|
+
const before = new Map(target.files.map(file => [file.path, file]));
|
|
45
|
+
for (const [index, file] of source.files.entries()) {
|
|
46
|
+
const content = decoder.decode(file.bytes);
|
|
47
|
+
requireValue(mergeSha256(Buffer.from(content)) === file.sha256, 'MERGE_ARCH_ENCODING', 'Archguard requires unchanged valid UTF-8 source bytes');
|
|
48
|
+
const result = await evaluateArchguardCheckpoint(current.contract, { blockId: `merge-${index + 1}`, path: file.path,
|
|
49
|
+
content, beforeSha256: before.get(file.path).sha256 }, entries);
|
|
50
|
+
entries.push(result.ledgerEntry);
|
|
51
|
+
results.push({ path: file.path, exists: true, sha256: file.sha256, contentSha256: file.sha256,
|
|
52
|
+
sourcePath: file.sourcePath, beforeSha256: before.get(file.path).sha256, ...result });
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
async function produceLocked(ctx, input, lease) {
|
|
57
|
+
const preparation = input.preparation, pin = configuredMergePin(process.env.ENTROROVE_ARCHGUARD_RUNNER_PUBLIC_KEY?.trim()), config = await authority(ctx);
|
|
58
|
+
requireValue(config.runnerPublicKey === pin.publicKey, 'MERGE_ARCH_RUNNER_PIN', 'Original Archguard runner signer must match the independently configured preflight pin');
|
|
59
|
+
const binding = { repositoryDigest: ctx.repositoryDigest, flowId: ctx.flowId, stepId: input.stepId,
|
|
60
|
+
...Object.fromEntries(['mergeId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'leaseId', 'generation', 'epoch', 'architectureContractDigest', 'baselineRef'].map(key => [key, lease[key]])),
|
|
61
|
+
mappings: lease.mappings, mappingProof: lease.mappingProof, mappingDigest: lease.mappingDigest, scopePaths: lease.scopePaths };
|
|
62
|
+
const prepared = inspectMergePreparation(preparation, binding);
|
|
63
|
+
requireValue(prepared.verified, 'MERGE_ARCH_PREPARATION', prepared.reason ?? 'Original signed merge preparation is required');
|
|
64
|
+
const current = await lockedContract(ctx, preparation.architectureContractDigest), ledger = await readLedger(ctx);
|
|
65
|
+
requireValue(SHA256.test(String(ledger.headDigest)), 'MERGE_ARCH_LEDGER_UNMANAGED', 'Merge preflight requires an actual managed native ledger head');
|
|
66
|
+
requireValue(!drift(ledger).interrupted, 'MERGE_ARCH_RED_INTERRUPT', 'Archguard red interrupt requires trusted human direction before preflight');
|
|
67
|
+
const frozen = await artifacts(input, preparation); await assertTarget(ctx, frozen.target.files);
|
|
68
|
+
const results = await scanCandidate(current, frozen.source, frozen.target, history(ledger));
|
|
69
|
+
const fresh = await artifacts(input, preparation); await assertTarget(ctx, fresh.target.files);
|
|
70
|
+
const unchanged = await lockedContract(ctx, preparation.architectureContractDigest), latest = await readLedger(ctx);
|
|
71
|
+
requireValue(fresh.source.files.every((file, index) => file.bytes.equals(frozen.source.files[index].bytes))
|
|
72
|
+
&& unchanged.rawDigest === current.rawDigest && latest.headDigest === ledger.headDigest,
|
|
73
|
+
'MERGE_ARCH_SCAN_RACE', 'Frozen artifacts, architecture contract or checkpoint history changed during actual scan');
|
|
74
|
+
requireValue(inspectMergePreparation(preparation, binding).verified, 'MERGE_ARCH_PREPARATION_EXPIRED', 'Original preparation expired during actual Archguard scan');
|
|
75
|
+
const now = Date.now(), expires = Math.min(Date.parse(preparation.expiresAt), Date.parse(lease.expiresAt), now + 60_000);
|
|
76
|
+
const payload = { schemaVersion: MERGE_ARCHGUARD_SCHEMA, ...mergePreflightBinding(preparation),
|
|
77
|
+
status: results.every(result => result.checkpoint.status === 'passed') ? 'passed' : 'blocked',
|
|
78
|
+
architectureContract: current.contract, contractId: current.contract.contractId, contractRevision: current.contract.revision,
|
|
79
|
+
contractRawDigest: current.rawDigest, nativeLedgerHeadDigest: ledger.headDigest, nativeRuntimeSha256: MERGE_ARCHGUARD_RUNTIME_SHA256, results,
|
|
80
|
+
mergeLeaseDigest: mergeSha256(mergeCanonical(lease)), architectureLedgerDigest: lease.architectureLedgerDigest,
|
|
81
|
+
scopeContractArtifactDigest: lease.scopeContractArtifactDigest,
|
|
82
|
+
issuedAt: new Date(now).toISOString(), expiresAt: new Date(expires).toISOString(), nonce: randomUUID() };
|
|
83
|
+
const signed = await signRunnerReceipt(ctx, payload, true);
|
|
84
|
+
const checked = inspectMergeArchguardPreflight(signed, binding, { preparation, mergeLease: lease });
|
|
85
|
+
requireValue(checked.authentic, 'MERGE_ARCH_OUTPUT_INVALID', checked.reason ?? 'Produced native Archguard receipt failed its own independent inspection');
|
|
86
|
+
return signed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function produceMergeArchguardPreflight(input) {
|
|
90
|
+
const merge = await mergeContext(input);
|
|
91
|
+
return withMergeFence(merge, async (state, registration) => {
|
|
92
|
+
const lease = inspectMergeLease(input.mergeLease, merge, state, registration), ctx = await context(input);
|
|
93
|
+
return withExclusiveLock(path.join(ctx.repositoryState, 'mutation.lock'), () =>
|
|
94
|
+
withExclusiveLock(path.join(ctx.directory, 'mutation.lock'), () => produceLocked(ctx, input, lease)));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function nativeCurrent(ctx, binding) {
|
|
99
|
+
requireValue(binding && typeof binding === 'object' && !Array.isArray(binding), 'MERGE_ARCH_CONTRACT', 'Original architecture binding is required');
|
|
100
|
+
requireValue(SHA256.test(String(binding.architectureContractDigest)), 'MERGE_ARCH_CONTRACT', 'Original canonical architecture binding is required');
|
|
101
|
+
const current = await lockedContract(ctx, binding.architectureContractDigest), ledger = await readLedger(ctx);
|
|
102
|
+
requireValue(!drift(ledger).interrupted, 'MERGE_ARCH_RED_INTERRUPT', 'Native Archguard red interrupt prohibits a merge or rollback commit');
|
|
103
|
+
requireValue(SHA256.test(String(ledger.headDigest)), 'MERGE_ARCH_LEDGER_UNMANAGED', 'Actual managed native ledger head is required');
|
|
104
|
+
if (Object.hasOwn(binding, 'contractRawDigest')) requireValue(current.rawDigest === binding.contractRawDigest,
|
|
105
|
+
'MERGE_ARCH_CONTRACT', 'Actual architecture raw bytes changed after their original preflight');
|
|
106
|
+
if (Object.hasOwn(binding, 'nativeLedgerHeadDigest')) requireValue(ledger.headDigest === binding.nativeLedgerHeadDigest,
|
|
107
|
+
'MERGE_ARCH_LEDGER_CHANGED', 'Actual native checkpoint history changed after its original frozen head');
|
|
108
|
+
return { architectureContractDigest: current.contractDigest, contractRawDigest: current.rawDigest, nativeLedgerHeadDigest: ledger.headDigest };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Caller already holds the physical handoff and merge repository fences. Invoke
|
|
112
|
+
// assertCurrent inside the atomic finish/recovery boundary before publishing.
|
|
113
|
+
export async function withMergeArchguardStateFence(input, binding, action) {
|
|
114
|
+
requireValue(typeof action === 'function', 'MERGE_ARCH_ACTION', 'Native architecture fence requires its original action');
|
|
115
|
+
const ctx = await context(input);
|
|
116
|
+
return withExclusiveLock(path.join(ctx.repositoryState, 'mutation.lock'), () =>
|
|
117
|
+
withExclusiveLock(path.join(ctx.directory, 'mutation.lock'), async () => {
|
|
118
|
+
const facts = Object.freeze(await nativeCurrent(ctx, binding));
|
|
119
|
+
const assertCurrent = async () => { await nativeCurrent(ctx, facts); };
|
|
120
|
+
return action(assertCurrent, facts);
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function withMergeArchguardFence(input, preflight, action) {
|
|
125
|
+
requireValue(typeof action === 'function', 'MERGE_ARCH_ACTION', 'Native architecture fence requires its original action');
|
|
126
|
+
const ctx = await context(input), binding = { repositoryDigest: ctx.repositoryDigest, flowId: ctx.flowId, stepId: input.stepId };
|
|
127
|
+
const assertReceipt = () => {
|
|
128
|
+
const checked = inspectMergeArchguardPreflight(preflight, binding, { preparation: input.preparation, mergeLease: input.mergeLease });
|
|
129
|
+
requireValue(checked.verified && checked.authentic, 'MERGE_ARCH_PREFLIGHT', checked.reason ?? 'Original independent passing native preflight is required');
|
|
130
|
+
};
|
|
131
|
+
assertReceipt();
|
|
132
|
+
return withMergeArchguardStateFence(input, preflight, async assertNativeCurrent => {
|
|
133
|
+
const pin = configuredMergePin(process.env.ENTROROVE_ARCHGUARD_RUNNER_PUBLIC_KEY?.trim()), config = await authority(ctx);
|
|
134
|
+
requireValue(config.runnerPublicKey === pin.publicKey, 'MERGE_ARCH_RUNNER_PIN', 'Original native runner authority differs from its independent pin');
|
|
135
|
+
const assertCurrent = async () => { assertReceipt(); await assertNativeCurrent(); };
|
|
136
|
+
await assertCurrent(); return action(assertCurrent);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { snapshotAuthority14 } from './local-mutation-snapshot.mjs';
|
|
5
|
+
import { canonical, sha256, fail, identifier, paths, noLinks, MISSING_SHA256, context, withMergeFence, directory, read, json, writeJson } from './local-merge-storage.mjs';
|
|
6
|
+
import { inspectMergeLease, signMergeReceipt } from './local-merge-authority.mjs';
|
|
7
|
+
import { assertDistinctMergeTargets } from './local-merge-targets.mjs';
|
|
8
|
+
|
|
9
|
+
export const mergeArtifactDigest = files => sha256(`validator.file-manifest/1.0\n${files.map(file => `${file.path}\0${file.sha256}`).join('\n')}`);
|
|
10
|
+
const entry = (name, value) => ({ path: name, exists: value.exists, sha256: value.sha256, mode: value.mode });
|
|
11
|
+
export async function readMergeSnapshot(ctx, receipt) {
|
|
12
|
+
const value = await snapshotAuthority14(ctx.input, ctx.root, receipt);
|
|
13
|
+
return { manifest: value.manifest, directory: value.snapshotRoot };
|
|
14
|
+
}
|
|
15
|
+
async function freeze(ctx, role, root, names, mappings = []) {
|
|
16
|
+
const artifactId = `${role}-${randomUUID()}`, directoryPath = path.join(ctx.state, 'artifacts', artifactId);
|
|
17
|
+
await directory(path.join(directoryPath, 'blobs')); const files = []; let total = 0;
|
|
18
|
+
for (const name of names) {
|
|
19
|
+
const sourcePath = role === 'source' ? mappings.find(item => item.targetPath === name).sourcePath : name;
|
|
20
|
+
const value = await read(path.join(root, sourcePath), ctx.input, sourcePath, role === 'target');
|
|
21
|
+
total += value.bytes?.length ?? 0; if (total > 64 * 1024 * 1024) fail('MERGE_ARTIFACT_LIMIT', 'Merge artifact exceeds its bounded byte quota');
|
|
22
|
+
if (value.exists) {
|
|
23
|
+
const blob = path.join(directoryPath, 'blobs', value.sha256);
|
|
24
|
+
try { await fs.writeFile(blob, value.bytes, { flag: 'wx', mode: 0o600 }); }
|
|
25
|
+
catch (error) { if (error.code !== 'EEXIST') throw error;
|
|
26
|
+
if (!(await read(blob, ctx.input, sourcePath)).bytes.equals(value.bytes)) fail('MERGE_BLOB_CHANGED', 'Duplicate artifact blob differs from its actual bytes'); }
|
|
27
|
+
}
|
|
28
|
+
files.push({ ...entry(name, value), ...(role === 'source' ? { sourcePath } : {}) });
|
|
29
|
+
}
|
|
30
|
+
const manifest = { schemaVersion: 'entrorove.merge-artifact/1.0', artifactId, role, repositoryDigest: ctx.repo, flowId: ctx.flowId, stepId: ctx.stepId, files };
|
|
31
|
+
await writeJson(path.join(directoryPath, 'manifest.json'), manifest);
|
|
32
|
+
return { ref: { kind: 'snapshot', artifactId, manifestDigest: sha256(canonical(manifest)) }, manifest };
|
|
33
|
+
}
|
|
34
|
+
export async function readFrozenMergeArtifact(input, ref) {
|
|
35
|
+
const ctx = await context(input);
|
|
36
|
+
if (!ref || ref.kind !== 'snapshot' || !/^[0-9a-f]{64}$/.test(ref.manifestDigest ?? '')) fail('MERGE_REF_INVALID', 'Merge refs must identify frozen private snapshot manifests');
|
|
37
|
+
const directoryPath = path.join(ctx.state, 'artifacts', identifier(ref.artifactId)), manifest = await json(path.join(directoryPath, 'manifest.json'));
|
|
38
|
+
if (Object.keys(ref).sort().join('|') !== 'artifactId|kind|manifestDigest'
|
|
39
|
+
|| Object.keys(manifest).sort().join('|') !== 'artifactId|files|flowId|repositoryDigest|role|schemaVersion|stepId'
|
|
40
|
+
|| !['source', 'target'].includes(manifest.role) || manifest.schemaVersion !== 'entrorove.merge-artifact/1.0' || manifest.artifactId !== ref.artifactId || manifest.repositoryDigest !== ctx.repo
|
|
41
|
+
|| manifest.flowId !== ctx.flowId || manifest.stepId !== ctx.stepId || sha256(canonical(manifest)) !== ref.manifestDigest
|
|
42
|
+
|| canonical(paths(manifest.files.map(file => file.path))) !== canonical(manifest.files.map(file => file.path))) fail('MERGE_REF_CHANGED', 'Frozen merge manifest or reference changed');
|
|
43
|
+
const files = [];
|
|
44
|
+
for (const file of manifest.files) {
|
|
45
|
+
if (manifest.role === 'source') paths([file.sourcePath]);
|
|
46
|
+
if (Object.keys(file).sort().join('|') !== (manifest.role === 'source' ? 'exists|mode|path|sha256|sourcePath' : 'exists|mode|path|sha256')
|
|
47
|
+
|| typeof file.exists !== 'boolean' || !/^[0-9a-f]{64}$/.test(file.sha256 ?? '')
|
|
48
|
+
|| file.exists && (!Number.isInteger(file.mode) || file.mode < 0 || file.mode > 0o777)
|
|
49
|
+
|| !file.exists && (file.sha256 !== MISSING_SHA256 || file.mode !== null)
|
|
50
|
+
|| manifest.role === 'source' && !file.exists) fail('MERGE_REF_CHANGED', 'Frozen merge file metadata is invalid');
|
|
51
|
+
const value = file.exists ? await read(path.join(directoryPath, 'blobs', file.sha256), ctx.input, file.sourcePath ?? file.path) : { bytes: null };
|
|
52
|
+
if (file.exists && value.sha256 !== file.sha256) fail('MERGE_BLOB_CHANGED', 'Frozen merge blob differs from its signed actual digest');
|
|
53
|
+
files.push({ ...file, bytes: value.bytes });
|
|
54
|
+
}
|
|
55
|
+
return { manifest, files };
|
|
56
|
+
}
|
|
57
|
+
export async function prepareMergeArtifacts(input) {
|
|
58
|
+
const ctx = await context(input);
|
|
59
|
+
return withMergeFence(ctx, async (state, registration) => {
|
|
60
|
+
const lease = inspectMergeLease(input.mergeLease, ctx, state, registration);
|
|
61
|
+
const snapshot = await readMergeSnapshot(ctx, input.snapshotReceipt), scopePaths = paths(snapshot.manifest.paths), names = paths(lease.mappings.map(item => item.targetPath));
|
|
62
|
+
if (canonical(scopePaths) !== canonical(lease.scopePaths)
|
|
63
|
+
|| snapshot.manifest.contractDigest !== lease.architectureContractDigest) fail('MERGE_SCOPE_CHANGED', 'Frozen merge scope and architecture must exactly match its original signed lease and snapshot');
|
|
64
|
+
const suppliedSourceRoot = input.sourceRoot ?? ctx.root; await noLinks(suppliedSourceRoot); const sourceRoot = await fs.realpath(suppliedSourceRoot);
|
|
65
|
+
if (sourceRoot !== ctx.root) fail('MERGE_SOURCE_ORIGIN', 'Merge candidate origins must retain the original registered repository');
|
|
66
|
+
await assertDistinctMergeTargets(ctx.input, scopePaths.map(path => ({ path })));
|
|
67
|
+
const target = await freeze(ctx, 'target', ctx.root, scopePaths), source = await freeze(ctx, 'source', sourceRoot, names, lease.mappings);
|
|
68
|
+
if (canonical(target.manifest.files) !== canonical(snapshot.manifest.files)) fail('MERGE_TARGET_CHANGED', 'Merge target changed from its frozen original snapshot');
|
|
69
|
+
const targetFiles = target.manifest.files.filter(file => names.includes(file.path));
|
|
70
|
+
const changed = names.filter((name, index) => { const { sourcePath, ...file } = source.manifest.files[index]; return canonical(file) !== canonical(targetFiles[index]); });
|
|
71
|
+
if (!changed.length) fail('MERGE_CHANGES_REQUIRED', 'Merge requires nonempty actual file changes');
|
|
72
|
+
const sourceFiles = source.manifest.files.map(({ exists, ...file }) => file);
|
|
73
|
+
if (sourceFiles.some(file => !lease.mappingProof.entries.some(entry => entry.sourcePath === file.sourcePath && entry.targetPath === file.path && entry.sha256 === file.sha256))) {
|
|
74
|
+
fail('MERGE_SOURCE_ANCESTRY', 'Actual candidate source differs from its frozen native producer mapping proof');
|
|
75
|
+
}
|
|
76
|
+
return signMergeReceipt(ctx.input, { schemaVersion: 'entrorove.merge-preparation-receipt/1.0', repositoryDigest: ctx.repo,
|
|
77
|
+
flowId: ctx.flowId, stepId: ctx.stepId, baselineRef: lease.baselineRef, sourceRef: source.ref, targetRef: target.ref, paths: changed,
|
|
78
|
+
...Object.fromEntries(['mergeId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'leaseId', 'generation', 'epoch'].map(field => [field, lease[field]])),
|
|
79
|
+
mergeLeaseDigest: sha256(canonical(lease)), architectureLedgerDigest: lease.architectureLedgerDigest,
|
|
80
|
+
scopePaths,
|
|
81
|
+
mappings: lease.mappings, mappingDigest: lease.mappingDigest, mappingProof: lease.mappingProof,
|
|
82
|
+
sourceFiles, targetFiles, mergedArtifactSha256: mergeArtifactDigest(sourceFiles), snapshotDigest: input.snapshotReceipt.snapshotDigest,
|
|
83
|
+
afterManifest: sourceFiles.map(({ path, sha256 }) => ({ path, sha256 })), beforeFullManifest: target.manifest.files,
|
|
84
|
+
snapshotReceiptDigest: input.snapshotReceipt.receiptDigest, architectureContractDigest: input.snapshotReceipt.contractDigest });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createPublicKey, sign, verify, randomUUID } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { canonical, sha256, fail, identifier, paths } from './local-merge-storage.mjs';
|
|
4
|
+
import { loadTaskHandoffSigner } from './local-task-handoff-observation.mjs';
|
|
5
|
+
|
|
6
|
+
export const MERGE_LEASE_FIELDS = ['schemaVersion', 'mergeId', 'taskId', 'flowId', 'stepId', 'ownerId', 'taskDigest', 'chainDigest',
|
|
7
|
+
'chainRevision', 'planId', 'planDigest', 'runnerId', 'runnerKeyId', 'repositoryId', 'repositoryDigest', 'scopeBindingDigest',
|
|
8
|
+
'scopeArtifactDigest', 'scopeContractDigest', 'scopeContractArtifactDigest', 'architectureContractDigest', 'architectureLedgerDigest', 'baselineRef', 'paths',
|
|
9
|
+
'scopePaths', 'mappings', 'mappingDigest', 'mappingProof',
|
|
10
|
+
'leaseId', 'generation', 'epoch', 'issuedAt', 'expiresAt', 'nonce', 'keyId', 'signature'];
|
|
11
|
+
|
|
12
|
+
export function pinnedKey(encoded) {
|
|
13
|
+
try {
|
|
14
|
+
const bytes = Buffer.from(encoded ?? '', 'base64'), key = createPublicKey({ key: bytes, type: 'spki', format: 'der' });
|
|
15
|
+
if (bytes.toString('base64') !== encoded || key.asymmetricKeyType !== 'ed25519') throw new Error();
|
|
16
|
+
return { key, keyId: sha256(bytes) };
|
|
17
|
+
} catch { fail('MERGE_AUTHORITY_KEY', 'Independent pinned Ed25519 authority is unavailable or invalid'); }
|
|
18
|
+
}
|
|
19
|
+
export function inspectSigned(value, schemaVersion, encoded, now = Date.now()) {
|
|
20
|
+
if (!value || value.schemaVersion !== schemaVersion || typeof value.signature !== 'string') fail('MERGE_AUTHORITY_SCHEMA', 'Signed merge authority schema is invalid');
|
|
21
|
+
const { signature, ...payload } = value, trusted = pinnedKey(encoded);
|
|
22
|
+
const bytes = Buffer.from(signature, 'base64url'), issued = Date.parse(value.issuedAt), expires = Date.parse(value.expiresAt);
|
|
23
|
+
identifier(value.nonce);
|
|
24
|
+
if (value.keyId !== trusted.keyId || bytes.length !== 64 || bytes.toString('base64url') !== signature
|
|
25
|
+
|| !Number.isFinite(issued) || !Number.isFinite(expires) || issued > now || expires <= now || expires <= issued || expires - issued > 300_000
|
|
26
|
+
|| !verify(null, Buffer.from(canonical(payload)), trusted.key, bytes)) fail('MERGE_AUTHORITY_INVALID', 'Merge authority signature, lifetime or independent identity is invalid');
|
|
27
|
+
return { payload, keyId: trusted.keyId, digest: sha256(canonical(value)) };
|
|
28
|
+
}
|
|
29
|
+
export async function signMergeReceipt(input, payload) {
|
|
30
|
+
const file = input.runnerSignerConfigPath;
|
|
31
|
+
if (typeof file !== 'string' || !path.isAbsolute(file)) fail('MERGE_RUNNER_SIGNER_REQUIRED', 'Merge requires an independently pinned configured runner signer');
|
|
32
|
+
const trusted = pinnedKey(process.env.ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY);
|
|
33
|
+
const signer = await loadTaskHandoffSigner({ root: input.repositoryRoot }, file, trusted.keyId);
|
|
34
|
+
const value = { ...payload, issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + signer.config.receiptTtlMs).toISOString(), nonce: randomUUID(), keyId: trusted.keyId };
|
|
35
|
+
return { ...value, signature: sign(null, Buffer.from(canonical(value)), signer.privateKey).toString('base64url') };
|
|
36
|
+
}
|
|
37
|
+
export function match(value, expected, fields, code = 'MERGE_AUTHORITY_BINDING') {
|
|
38
|
+
if (fields.some(field => canonical(value[field] ?? null) !== canonical(expected[field] ?? null))) fail(code, 'Merge authority changed its frozen task, refs, scope or artifact binding');
|
|
39
|
+
}
|
|
40
|
+
export function inspectMergeLease(lease, ctx, state, registration) {
|
|
41
|
+
inspectSigned(lease, 'entrorove.merge-lease/1.0', process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY);
|
|
42
|
+
if (Object.keys(lease).sort().join('|') !== [...MERGE_LEASE_FIELDS].sort().join('|')) fail('MERGE_LEASE_SCHEMA', 'Merge lease must preserve its exact signed protocol fields');
|
|
43
|
+
match(lease, registration, ['taskId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId']);
|
|
44
|
+
match(lease, { repositoryDigest: ctx.repo, flowId: ctx.flowId, stepId: ctx.stepId, epoch: state.epoch }, ['repositoryDigest', 'flowId', 'stepId', 'epoch']);
|
|
45
|
+
if (canonical(paths(lease.paths)) !== canonical(lease.paths) || !Number.isSafeInteger(lease.generation) || lease.generation < 1
|
|
46
|
+
|| !Number.isSafeInteger(lease.chainRevision) || lease.chainRevision < 1 || typeof lease.baselineRef !== 'string' || !lease.baselineRef.trim()) {
|
|
47
|
+
fail('MERGE_LEASE_BINDING', 'Merge lease paths, baseline, generation or chain revision is invalid');
|
|
48
|
+
}
|
|
49
|
+
for (const field of ['mergeId', 'leaseId', 'planId', 'runnerId', 'repositoryId']) identifier(lease[field]);
|
|
50
|
+
for (const field of ['planDigest', 'scopeBindingDigest', 'scopeArtifactDigest', 'scopeContractDigest', 'scopeContractArtifactDigest', 'architectureContractDigest', 'architectureLedgerDigest']) {
|
|
51
|
+
if (!/^[0-9a-f]{64}$/.test(lease[field] ?? '')) fail('MERGE_LEASE_BINDING', 'Merge lease requires original frozen authority digests');
|
|
52
|
+
}
|
|
53
|
+
const proof = lease.mappingProof;
|
|
54
|
+
if (!Array.isArray(lease.mappings) || !lease.mappings.length || !proof || proof.schemaVersion !== 'entrorove.merge-artifact-mapping/1.0'
|
|
55
|
+
|| lease.mappingDigest !== sha256(canonical(proof)) || !Array.isArray(proof.entries)
|
|
56
|
+
|| canonical(paths(lease.mappings.map(item => item.targetPath))) !== canonical(lease.mappings.map(item => item.targetPath))) fail('MERGE_MAPPING_BINDING', 'Merge mapping must retain its actual immutable plan-bound digest');
|
|
57
|
+
match(proof, lease, ['flowId', 'ownerId', 'planId', 'planDigest', 'taskDigest', 'chainDigest']);
|
|
58
|
+
if (Object.keys(proof).sort().join('|') !== 'blueprintBinding|chainDigest|entries|flowId|ownerId|planDigest|planId|schemaVersion|scopeArtifactDigest|taskDigest'
|
|
59
|
+
|| !proof.blueprintBinding || typeof proof.blueprintBinding !== 'object' || Array.isArray(proof.blueprintBinding)
|
|
60
|
+
|| !Object.keys(proof.blueprintBinding).length) fail('MERGE_MAPPING_BINDING', 'Mapping proof must retain its exact original native Blueprint shape');
|
|
61
|
+
const proofTargets = paths(proof.entries.map(entry => entry.targetPath)); paths(proof.entries.map(entry => entry.sourcePath));
|
|
62
|
+
if (canonical(proofTargets) !== canonical(proof.entries.map(entry => entry.targetPath))) fail('MERGE_MAPPING_BINDING', 'Original mapping proof must have unique sorted target entries');
|
|
63
|
+
for (const entry of proof.entries) {
|
|
64
|
+
if (Object.keys(entry).sort().join('|') !== 'producerNodeRef|producerStepId|sha256|sourcePath|stepId|targetPath'
|
|
65
|
+
|| !/^[0-9a-f]{64}$/.test(entry.sha256 ?? '')) fail('MERGE_MAPPING_BINDING', 'Mapping entry must retain its exact actual native producer identity');
|
|
66
|
+
for (const field of ['producerNodeRef', 'producerStepId', 'stepId']) identifier(entry[field]);
|
|
67
|
+
}
|
|
68
|
+
if (proof.scopeArtifactDigest !== lease.scopeContractArtifactDigest || canonical(paths(lease.scopePaths)) !== canonical(lease.scopePaths)) fail('MERGE_MAPPING_BINDING', 'Native artifact mapping lost its original frozen task, chain or scope');
|
|
69
|
+
paths(lease.mappings.map(item => item.sourcePath));
|
|
70
|
+
for (const mapping of lease.mappings) {
|
|
71
|
+
if (Object.keys(mapping).sort().join('|') !== 'sourcePath|targetPath' || !lease.scopePaths.includes(mapping.sourcePath) || !lease.scopePaths.includes(mapping.targetPath)
|
|
72
|
+
|| !proof.entries.some(entry => entry.sourcePath === mapping.sourcePath && entry.targetPath === mapping.targetPath && /^[0-9a-f]{64}$/.test(entry.sha256))) {
|
|
73
|
+
fail('MERGE_MAPPING_SCOPE', 'Every actual source origin and target path must remain in the original frozen scope');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (canonical(lease.paths) !== canonical(lease.mappings.map(item => item.targetPath))) fail('MERGE_MAPPING_SCOPE', 'Merge lease paths must be exactly its authorized mapping targets');
|
|
77
|
+
return lease;
|
|
78
|
+
}
|
|
79
|
+
export function inspectMergeContract(contract, ctx, preparation, preflight, evidence) {
|
|
80
|
+
const checked = inspectSigned(contract, 'entrorove.merge-contract/1.0', process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY);
|
|
81
|
+
match(contract, preparation, ['repositoryDigest', 'flowId', 'stepId', 'baselineRef', 'sourceRef', 'targetRef', 'paths', 'scopePaths', 'sourceFiles', 'targetFiles', 'afterManifest', 'beforeFullManifest', 'mappings', 'mappingDigest', 'mappingProof', 'mergedArtifactSha256', 'snapshotDigest', 'architectureContractDigest']);
|
|
82
|
+
match(contract, { preparationDigest: sha256(canonical(preparation)), archguardPreflightDigest: sha256(canonical(preflight)),
|
|
83
|
+
evidenceDigests: evidence.map(item => sha256(canonical(item))).sort() }, ['preparationDigest', 'archguardPreflightDigest', 'evidenceDigests']);
|
|
84
|
+
match(contract, { repositoryDigest: ctx.repo, flowId: ctx.flowId, stepId: ctx.stepId }, ['repositoryDigest', 'flowId', 'stepId']);
|
|
85
|
+
for (const field of ['contractId', 'ownerId', 'leaseId']) identifier(contract[field]);
|
|
86
|
+
if (!Number.isSafeInteger(contract.generation) || contract.generation < 1 || !/^[0-9a-f]{64}$/.test(contract.taskDigest ?? '')
|
|
87
|
+
|| !/^[0-9a-f]{64}$/.test(contract.chainDigest ?? '')) fail('MERGE_AUTHORITY_BINDING', 'Merge contract requires the original owner, task, chain and live lease');
|
|
88
|
+
return checked;
|
|
89
|
+
}
|