@entrorove/skill 0.1.51 → 0.1.53

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 (51) hide show
  1. package/CAPABILITY-MAP.md +14 -2
  2. package/CHANGELOG.md +12 -0
  3. package/DELIVERY-BOUNDARIES.md +4 -2
  4. package/IDE-EXECUTION.md +25 -3
  5. package/SKILL.md +8 -4
  6. package/cli.mjs +4 -6
  7. package/cloud-mutation-authority.mjs +96 -0
  8. package/connected-cli.mjs +20 -0
  9. package/ide-command.mjs +1 -1
  10. package/ide-hook.mjs +24 -10
  11. package/ide-results.mjs +8 -6
  12. package/ide-session.mjs +44 -1
  13. package/local-approved-plan.mjs +3 -21
  14. package/local-approved-process.mjs +54 -0
  15. package/local-approved-supervisor.mjs +99 -0
  16. package/local-cli.mjs +13 -2
  17. package/local-contracts.mjs +2 -0
  18. package/local-delegated-modify-analysis.mjs +232 -0
  19. package/local-discipline-actions.mjs +2 -0
  20. package/local-discipline-analysis.mjs +11 -5
  21. package/local-discipline-gate.mjs +3 -1
  22. package/local-discipline-repository.mjs +7 -4
  23. package/local-error.mjs +3 -0
  24. package/local-execution.mjs +1 -1
  25. package/local-mutation-authority.mjs +23 -5
  26. package/local-mutation-batch.mjs +1 -0
  27. package/local-mutation-binding.mjs +14 -5
  28. package/local-mutation-consumption.mjs +93 -0
  29. package/local-mutation-pass-store.mjs +6 -4
  30. package/local-mutation-wire.mjs +21 -4
  31. package/local-preserved-mutation-protection.mjs +51 -0
  32. package/local-preserved-mutation-signer.mjs +81 -0
  33. package/local-preserved-mutation-state.mjs +96 -0
  34. package/local-preserved-mutation-wire.mjs +49 -0
  35. package/local-scope-import-expressions.mjs +80 -0
  36. package/local-scope-repository.mjs +32 -21
  37. package/local-typescript-function-reference.mjs +98 -0
  38. package/manifest.json +1 -1
  39. package/mutation-client.mjs +290 -0
  40. package/package.json +22 -3
  41. package/release-state.json +49 -30
  42. package/release.mjs +25 -0
  43. package/releases.json +12 -0
  44. package/remote-client.mjs +2 -1
  45. package/reuse-discipline-release.mjs +3 -0
  46. package/workflow-observation-client.mjs +44 -0
  47. package/workflow-observation-connection.mjs +35 -0
  48. package/workflow-observation-remote.mjs +84 -0
  49. package/workflow-observation-runner.mjs +87 -0
  50. package/workflow-observation-state.mjs +36 -0
  51. package/workflow-observation-wire.mjs +69 -0
@@ -14,6 +14,8 @@ import { withSwarmMutationLease } from './local-swarm-mutation.mjs';
14
14
  import { disciplineWritePreflight } from './local-discipline-gate.mjs';
15
15
  import { readDisciplineSnapshot } from './local-discipline-snapshot.mjs';
16
16
  import { assertDisciplineRepository } from './local-discipline-repository.mjs';
17
+ import { withPreservedMutationFence } from './local-preserved-mutation-state.mjs';
18
+ import { assertPreservedMutationProtection } from './local-preserved-mutation-protection.mjs';
17
19
  export { beginMutationBatch14, finishMutationBatch14, failMutationBatch14 } from './local-mutation-batch.mjs';
18
20
 
19
21
  const SCHEMA = 'entrorove.local-execution/1.0';
@@ -25,7 +27,8 @@ export async function mutateGate14(input) {
25
27
  return withMutationAudit14(input, 'mutate-gate', async () => {
26
28
  const pass = suppliedPass(input), binding = await mutationOriginalBinding14(input, pass);
27
29
  const snapshot = await mutationSnapshotBinding14(input, pass, { allSources: true });
28
- assertTaskHandoffSnapshotBinding(binding.state, pass.snapshotReceipt);
30
+ if (!pass.writeDelegation) assertTaskHandoffSnapshotBinding(binding.state, pass.snapshotReceipt);
31
+ else await assertPreservedMutationProtection(input, binding.root, pass);
29
32
  return { schemaVersion: SCHEMA, status: 'verified', allowed: true, snapshotId: snapshot.manifest.snapshotId,
30
33
  receiptDigest: pass.snapshotReceiptDigest, paths: [...pass.paths], coordinationRequired: false };
31
34
  });
@@ -33,6 +36,18 @@ export async function mutateGate14(input) {
33
36
  export async function issueMutationPass14(input) {
34
37
  return withMutationAudit14(input, 'mutation-pass-issue', async () => {
35
38
  const pass = suppliedPass(input);
39
+ if (pass.writeDelegation) {
40
+ const binding = await mutationOriginalBinding14(input, pass);
41
+ await mutationSnapshotBinding14(input, pass, { allSources: true });
42
+ await assertPreservedMutationProtection(input, binding.root, pass);
43
+ mutationLifetime14(pass);
44
+ const locations = await installMutationPass14(input, binding, pass);
45
+ await withPreservedMutationFence(input, pass, async () => {
46
+ await assertPreservedMutationProtection(input, binding.root, pass);
47
+ await auditMutation14(input, 'mutation-pass-issued', { passId: pass.passId, leaseId: pass.leaseId, generation: pass.generation });
48
+ }, { issue: true });
49
+ return { schemaVersion: SCHEMA, gatePassPath: locations.gatePassPath, pass };
50
+ }
36
51
  return withTaskHandoffIssueFence(input, async state => {
37
52
  const binding = await mutationOriginalBinding14(input, pass, state);
38
53
  await mutationSnapshotBinding14(input, pass, { allSources: true });
@@ -50,7 +65,8 @@ export async function checkedPass(input, state, { allowTargetDrift = false } = {
50
65
  if (!pass.paths.includes(target)) mutationFail14('ENTROROVE_MUTATION_WRITE_SCOPE', 'Guarded writing is outside the cloud pass path set');
51
66
  const binding = await mutationOriginalBinding14(input, pass, state);
52
67
  const snapshot = await mutationSnapshotBinding14(input, pass, { targetPath: target, allowTargetDrift });
53
- assertTaskHandoffSnapshotBinding(binding.state, pass.snapshotReceipt);
68
+ if (!pass.writeDelegation) assertTaskHandoffSnapshotBinding(binding.state, pass.snapshotReceipt);
69
+ else await assertPreservedMutationProtection(input, binding.root, pass);
54
70
  const locations = await mutationPassLocations14(input, binding, pass);
55
71
  const ledger = await readMutationConsumption14(pass, locations);
56
72
  const consumedWrite = ledger.writes[target];
@@ -92,9 +108,10 @@ async function performWrite(input, state, value) {
92
108
  disciplineReviewDigest: discipline.reviewDigest, declaredKind: discipline.declaredKind });
93
109
  mutationLifetime14(pass);
94
110
  const written = await writeMutationProjectFile14(input, binding.root, target, content, baseline);
95
- const completed = { ...ledger, writes: { ...ledger.writes, [target]: { ...entry, status: 'written' } } };
111
+ const completed = { ...ledger, writes: { ...ledger.writes, [target]: { ...entry, status: 'written', writtenAt: new Date().toISOString() } } };
96
112
  await saveMutationConsumption14(locations, completed);
97
113
  await assertDisciplineRepository(input, binding.root, await readDisciplineSnapshot(snapshot), completed);
114
+ if (pass.writeDelegation) await assertPreservedMutationProtection(input, binding.root, pass);
98
115
  await auditMutation14(input, 'guarded-write', { passId: pass.passId, sha256: written.sha256 });
99
116
  const stateRoot = await mutationStateRoot14(input);
100
117
  await privateMutationDirectory14(path.join(stateRoot, 'ledgers', pass.repositoryDigest), { privateFrom: stateRoot });
@@ -106,7 +123,8 @@ async function performWrite(input, state, value) {
106
123
  export async function guardedWrite14(input) {
107
124
  return withMutationAudit14(input, 'guarded-write', async () => {
108
125
  const value = await checkedPass(input), pass = value.pass;
109
- return withStateLock(value.locations.consumptionPath, () => withTaskHandoffWriteFence({ ...input,
110
- epoch: pass.epoch, passId: pass.passId, leaseId: pass.leaseId }, state => performWrite(input, state, value)));
126
+ return withStateLock(value.locations.consumptionPath, () => pass.writeDelegation
127
+ ? withPreservedMutationFence(input, pass, state => performWrite(input, state, value))
128
+ : withTaskHandoffWriteFence({ ...input, epoch: pass.epoch, passId: pass.passId, leaseId: pass.leaseId }, state => performWrite(input, state, value)));
111
129
  });
112
130
  }
@@ -41,6 +41,7 @@ function tokenState(token, permitted) {
41
41
  }
42
42
  async function authority(input, supplied) {
43
43
  const pass = await readStoredMutationPass14(input);
44
+ if (pass.writeDelegation) mutationFail14('ENTROROVE_MUTATION_DELEGATION_CHANGE', 'Preserved mutation requires individual existing-file modification');
44
45
  for (const value of [supplied, input.mutationPass]) if (value !== undefined && canonical(mutationPass14(value)) !== canonical(pass)) {
45
46
  mutationFail14('ENTROROVE_MUTATION_PASS_CHANGED', 'Batch cannot replace its stored original signed pass');
46
47
  }
@@ -7,8 +7,12 @@ import { mutationFail14, mutationHome14, mutationRoot14, mutationStateRoot14 } f
7
7
  import { snapshotAuthority14, snapshotVerify14, verifySnapshotSources14 } from './local-mutation-snapshot.mjs';
8
8
  import { mutationChallenge14, mutationPass14 } from './local-mutation-wire.mjs';
9
9
  import { withMutationAudit14 } from './local-mutation-audit.mjs';
10
+ import { preservedMutationOriginalBinding, withPreservedMutationFence } from './local-preserved-mutation-state.mjs';
11
+ import { loadPreservedMutationSigner } from './local-preserved-mutation-signer.mjs';
12
+ import { capturePreservedMutationProtection } from './local-preserved-mutation-protection.mjs';
10
13
 
11
14
  export async function mutationOriginalBinding14(input, authority, state) {
15
+ if (authority.writeDelegation) return preservedMutationOriginalBinding(input, authority);
12
16
  const root = await mutationRoot14(input.repositoryRoot), home = await mutationHome14(input.home);
13
17
  await mutationStateRoot14(input);
14
18
  const ctx = await context({ ...input, repositoryRoot: root });
@@ -67,23 +71,28 @@ export async function attestMutationSnapshot14(input) {
67
71
  if (input.snapshotId !== challenge.snapshotId) mutationFail14('ENTROROVE_MUTATION_SNAPSHOT_BINDING', 'Snapshot attestation requires the cloud-assigned original snapshot identifier');
68
72
  const verified = await snapshotVerify14(input);
69
73
  const snapshot = await snapshotAuthority14(input, binding.root, verified.receipt);
70
- assertTaskHandoffSnapshotBinding(binding.state, verified.receipt);
74
+ if (!challenge.writeDelegation) assertTaskHandoffSnapshotBinding(binding.state, verified.receipt);
71
75
  if (snapshot.manifest.contractDigest !== challenge.scopeContractDigest
72
76
  || canonical(snapshot.manifest.paths) !== canonical(challenge.paths)
73
77
  || snapshot.manifest.files.some(file => !file.exists && !challenge.newFilePaths.includes(file.path))) {
74
78
  mutationFail14('ENTROROVE_MUTATION_SNAPSHOT_BINDING', 'Snapshot attestation differs from its original cloud scope challenge');
75
79
  }
76
- const signer = await loadTaskHandoffSigner(binding.context, input.signerConfigPath, challenge.runnerKeyId);
80
+ const signer = challenge.writeDelegation
81
+ ? await loadPreservedMutationSigner(input, input.signerConfigPath, challenge.writeDelegation.mutationKeyId)
82
+ : await loadTaskHandoffSigner(binding.context, input.signerConfigPath, challenge.runnerKeyId);
77
83
  const now = Date.now(), expires = Math.min(now + signer.config.receiptTtlMs, Date.parse(challenge.expiresAt));
78
84
  if (expires <= now) mutationFail14('ENTROROVE_MUTATION_AUTHORITY_EXPIRED', 'Snapshot challenge expired before runner attestation');
79
85
  const payload = { schemaVersion: 'entrorove.mutation-snapshot-attestation/1.0', challengeId: challenge.challengeId,
80
86
  challengeDigest: sha256(canonical(challenge)), nonce: challenge.nonce, runnerId: challenge.runnerId,
81
- keyId: challenge.runnerKeyId, flowId: challenge.flowId, taskId: challenge.taskId, ownerId: challenge.ownerId,
87
+ keyId: signer.config.keyId, flowId: challenge.flowId, taskId: challenge.taskId, ownerId: challenge.ownerId,
82
88
  stepId: challenge.stepId, leaseId: challenge.leaseId, generation: challenge.generation,
83
89
  repositoryId: challenge.repositoryId, repositoryDigest: challenge.repositoryDigest, snapshotId: challenge.snapshotId,
84
90
  paths: [...challenge.paths], manifest: snapshot.manifest, receipt: verified.receipt,
85
91
  issuedAt: new Date(now).toISOString(), expiresAt: new Date(expires).toISOString() };
86
- return { schemaVersion: 'entrorove.local-execution/1.0', verified: true,
87
- attestation: { ...payload, signature: sign(null, Buffer.from(canonical(payload)), signer.privateKey).toString('base64url') } };
92
+ const output = () => ({ schemaVersion: 'entrorove.local-execution/1.0', verified: true,
93
+ attestation: { ...payload, signature: sign(null, Buffer.from(canonical(payload)), signer.privateKey).toString('base64url') } });
94
+ if (!challenge.writeDelegation) return output();
95
+ const protectedFiles = await capturePreservedMutationProtection(input, binding.root, challenge, snapshot);
96
+ return withPreservedMutationFence(input, challenge, output, { attest: true, protectedFiles });
88
97
  });
89
98
  }
@@ -0,0 +1,93 @@
1
+ import path from 'node:path';
2
+ import { randomUUID, sign, verify } from 'node:crypto';
3
+ import { canonical, sha256 } from './local-integrity.mjs';
4
+ import { withStateLock } from './local-state-lock.mjs';
5
+ import { mutationFail14, mutationRead14, readMutationJson14, atomicMutationJson14, privateMutationDirectory14 } from './local-mutation-files.mjs';
6
+ import { readStoredMutationPass14, readMutationConsumption14, mutationPassLocations14 } from './local-mutation-pass-store.mjs';
7
+ import { assertPreservedMutationIssued } from './local-preserved-mutation-state.mjs';
8
+ import { loadPreservedMutationSigner } from './local-preserved-mutation-signer.mjs';
9
+ import { preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs';
10
+ import { assertPreservedMutationProtection } from './local-preserved-mutation-protection.mjs';
11
+ import { snapshotAuthority14, readSnapshotCopy14 } from './local-mutation-snapshot.mjs';
12
+ import { assertDelegatedSourceModification } from './local-delegated-modify-analysis.mjs';
13
+ import { assertDisciplineRepository } from './local-discipline-repository.mjs';
14
+ import { readDisciplineSnapshot } from './local-discipline-snapshot.mjs';
15
+
16
+ const fail = () => mutationFail14('ENTROROVE_MUTATION_CONSUMPTION_INVALID', 'Mutation consumption requires its actual completed original writes, unchanged protection and separate M signer');
17
+ const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
18
+ export async function preservedMutationConsumptionState(input) {
19
+ const pass = await readStoredMutationPass14(input, { live: false });
20
+ if (!pass.writeDelegation) fail();
21
+ const binding = await assertPreservedMutationIssued(input, pass), locations = await mutationPassLocations14(input, binding, pass);
22
+ const ledger = await readMutationConsumption14(pass, locations);
23
+ return { pass, binding, locations, ledger, complete: pass.paths.every(target => ledger.writes[target]?.status === 'written') };
24
+ }
25
+ async function actualWrites(input, state) {
26
+ const { pass, binding, ledger } = state;
27
+ if (!state.complete || Object.keys(ledger.writes).length !== pass.paths.length) fail();
28
+ const snapshot = await snapshotAuthority14(input, binding.root, pass.snapshotReceipt);
29
+ if (sha256(canonical(snapshot.manifest)) !== pass.snapshotManifestDigest || snapshot.manifest.snapshotDigest !== pass.snapshotDigest) fail();
30
+ const writes = [];
31
+ for (const target of pass.paths) {
32
+ const completed = ledger.writes[target], before = snapshot.manifest.files.find(file => file.path === target);
33
+ if (!iso(completed.writtenAt)) mutationFail14('ENTROROVE_MUTATION_CONSUMPTION_WRITE_TIME', 'Original write has no verifiable completion timestamp; its old receipt cannot authorize another mutation');
34
+ if (!before?.exists || completed.kind && completed.kind !== 'modify' || !iso(completed.at)
35
+ || Date.parse(completed.writtenAt) < Date.parse(pass.issuedAt) || Date.parse(completed.writtenAt) >= Date.parse(pass.expiresAt)
36
+ || Date.parse(completed.writtenAt) < Date.parse(completed.at) || Date.parse(completed.writtenAt) > Date.now()) fail();
37
+ const current = await mutationRead14(input, binding.root, target), copy = await readSnapshotCopy14(input, snapshot.snapshotRoot, snapshot.manifest, target);
38
+ if (current.sha256 !== completed.contentDigest || current.mode !== completed.mode || current.mode !== before.mode) fail();
39
+ assertDelegatedSourceModification({ path: target, before: copy.content.toString('utf8'), after: current.content.toString('utf8') });
40
+ writes.push({ path: target, beforeSha256: before.sha256, beforeMode: before.mode,
41
+ afterSha256: current.sha256, afterMode: current.mode, writtenAt: completed.writtenAt });
42
+ }
43
+ await assertDisciplineRepository(input, binding.root, await readDisciplineSnapshot(snapshot), ledger);
44
+ await assertPreservedMutationProtection(input, binding.root, pass);
45
+ return writes;
46
+ }
47
+ function receiptCore(pass, writes) {
48
+ return { schemaVersion: 'entrorove.preserved-mutation-consumption/1.0', purpose: 'preserved-existing-modify-consumption',
49
+ keyId: pass.writeDelegation.mutationKeyId, ownerId: pass.ownerId, taskId: pass.taskId, flowId: pass.flowId, stepId: pass.stepId,
50
+ leaseId: pass.leaseId, generation: pass.generation, epoch: pass.epoch, passId: pass.passId, passDigest: sha256(canonical(pass)),
51
+ challengeId: pass.challengeId, challengeNonce: pass.challengeNonce, passNonce: pass.nonce,
52
+ snapshotId: pass.snapshotId, snapshotDigest: pass.snapshotDigest,
53
+ protectedFilesDigest: sha256(canonical(pass.writeDelegation.protectedFiles)), writes };
54
+ }
55
+ function cachedReceipt(value, pass, core) {
56
+ const { signature, issuedAt, expiresAt, nonce, ...actual } = value;
57
+ const unsigned = { ...actual, issuedAt, expiresAt, nonce }, bytes = typeof signature === 'string' ? Buffer.from(signature, 'base64url') : Buffer.alloc(0);
58
+ if (canonical(actual) !== canonical(core) || !iso(issuedAt) || !iso(expiresAt) || Date.parse(issuedAt) > Date.now()
59
+ || Date.parse(expiresAt) <= Date.parse(issuedAt) || Date.parse(expiresAt) - Date.parse(issuedAt) > 300_000
60
+ || !/^[0-9a-f-]{36}$/.test(nonce) || bytes.length !== 64 || bytes.toString('base64url') !== signature
61
+ || !verify(null, Buffer.from(canonical(unsigned)), preservedMutationPublicKey(pass.writeDelegation.mutationPublicKey).key, bytes)) fail();
62
+ return value;
63
+ }
64
+ async function consumptionReceipt(input, state, { renewExpired = false, allowExpiredReplay = false } = {}) {
65
+ const writes = await actualWrites(input, state), core = receiptCore(state.pass, writes);
66
+ const directory = path.join(state.binding.directory, 'consumption-receipts'), file = path.join(directory, `${state.pass.passId}.json`);
67
+ await privateMutationDirectory14(directory, { privateFrom: state.binding.stateRoot });
68
+ let previous;
69
+ try { previous = cachedReceipt(await readMutationJson14(file), state.pass, core); }
70
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
71
+ if (previous && Date.parse(previous.expiresAt) > Date.now()) return previous;
72
+ if (previous && allowExpiredReplay && !renewExpired) return previous;
73
+ if (previous && !renewExpired) mutationFail14('ENTROROVE_MUTATION_CONSUMPTION_EXPIRED', 'Original M consumption receipt expired; recover any pending request before a proven zero-effect renewal');
74
+ const signer = await loadPreservedMutationSigner(input, state.binding.signerConfigPath, core.keyId), now = Date.now();
75
+ const unsigned = { ...core, issuedAt: new Date(now).toISOString(), expiresAt: new Date(now + 300_000).toISOString(), nonce: randomUUID() };
76
+ const result = { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), signer.privateKey).toString('base64url') };
77
+ if (previous) {
78
+ const archive = `${file}.${previous.nonce}.expired`;
79
+ try { if (canonical(await readMutationJson14(archive)) !== canonical(previous)) fail(); }
80
+ catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, previous, { privateFrom: state.binding.stateRoot }); }
81
+ }
82
+ await atomicMutationJson14(file, result, { privateFrom: state.binding.stateRoot, replace: Boolean(previous) });
83
+ return result;
84
+ }
85
+ export async function attestMutationConsumption14(input, options = {}) {
86
+ const original = await preservedMutationConsumptionState(input);
87
+ return withStateLock(original.locations.consumptionPath, async () => {
88
+ const state = await preservedMutationConsumptionState(input), receipt = await consumptionReceipt(input, state, options);
89
+ return { schemaVersion: 'entrorove.local-execution/1.0', status: 'reported', consumptionReceipt: receipt,
90
+ claimsVerifiedExecution: false, claimsCompletion: false,
91
+ nextAction: { operation: 'mutation-pass-settle', input: { passId: state.pass.passId, consumptionReceipt: receipt } } };
92
+ });
93
+ }
@@ -4,22 +4,24 @@ import { withStateLock } from './local-state-lock.mjs';
4
4
  import { atomicMutationJson14, mutationFail14, mutationRoot14, mutationStateRoot14, mutationTarget14,
5
5
  privateMutationDirectory14, readMutationJson14 } from './local-mutation-files.mjs';
6
6
  import { mutationPass14 } from './local-mutation-wire.mjs';
7
+ import { assertPreservedMutationIssued } from './local-preserved-mutation-state.mjs';
7
8
 
8
9
  const SCHEMA = 'entrorove.mutation-consumption/1.0';
9
- export async function readStoredMutationPass14(input) {
10
+ export async function readStoredMutationPass14(input, { live = true } = {}) {
10
11
  const root = await mutationRoot14(input.repositoryRoot), stateRoot = await mutationStateRoot14(input);
11
12
  if (typeof input.gatePassPath !== 'string' || !path.isAbsolute(input.gatePassPath)) {
12
13
  mutationFail14('ENTROROVE_MUTATION_AUTHORITY_REQUIRED', 'Guarded writing requires its stored original cloud pass');
13
14
  }
14
- const pass = mutationPass14(await readMutationJson14(input.gatePassPath));
15
+ const pass = mutationPass14(await readMutationJson14(input.gatePassPath), { live });
15
16
  const expected = path.join(stateRoot, 'gates', sha256(root).slice(0, 32), input.flowId, `${pass.passId}.json`);
16
17
  if (input.gatePassPath !== expected) mutationFail14('ENTROROVE_MUTATION_PASS_LOCATION', 'Mutation pass is outside its original repository and flow storage');
18
+ if (pass.writeDelegation) await assertPreservedMutationIssued(input, pass);
17
19
  return pass;
18
20
  }
19
21
  export async function mutationPassLocations14(input, binding, pass) {
20
22
  const stateRoot = await mutationStateRoot14(input);
21
23
  const gates = path.join(stateRoot, 'gates', pass.repositoryDigest, pass.flowId);
22
- const anchor = process.env.ENTROROVE_TASK_HANDOFF_STATE_DIRECTORY;
24
+ const anchor = pass.writeDelegation ? binding.directory : process.env.ENTROROVE_TASK_HANDOFF_STATE_DIRECTORY;
23
25
  const consumption = path.join(anchor, 'mutations', pass.repositoryDigest, pass.flowId);
24
26
  await privateMutationDirectory14(gates, { privateFrom: stateRoot });
25
27
  await privateMutationDirectory14(consumption, { privateFrom: anchor });
@@ -28,7 +30,7 @@ export async function mutationPassLocations14(input, binding, pass) {
28
30
  }
29
31
  function initial(pass, locations) {
30
32
  return { schemaVersion: SCHEMA, passId: pass.passId, passDigest: sha256(canonical(pass)), flowId: pass.flowId,
31
- stepId: pass.stepId, executionAttemptNonce: pass.executionAttemptNonce, executionHome: locations.executionHome,
33
+ stepId: pass.stepId, ...(pass.writeDelegation ? { delegationNonce: pass.challengeNonce } : { executionAttemptNonce: pass.executionAttemptNonce }), executionHome: locations.executionHome,
32
34
  paths: [...pass.paths], writes: {} };
33
35
  }
34
36
  export async function readMutationConsumption14(pass, locations) {
@@ -4,6 +4,7 @@ import { canonicalHandoffSignature } from './local-task-handoff-overlay-wire.mjs
4
4
  import { mutationFail14, mutationPaths14 } from './local-mutation-files.mjs';
5
5
  import { disciplineApprovedPolicy } from './local-discipline-contracts.mjs';
6
6
  import { validatorParallelDeclarationsValid } from './runtime/validator-runtime-v7.0.44.mjs';
7
+ import { preservedMutationDelegation } from './local-preserved-mutation-wire.mjs';
7
8
 
8
9
  export const MUTATION_IDENTITY_FIELDS14 = ['flowId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision',
9
10
  'acceptanceReceiptId', 'acceptanceDigest', 'routeDigest', 'receiver', 'receiverDigest', 'stepId', 'leaseId', 'leaseDigest',
@@ -38,13 +39,18 @@ function pinnedAuthority() {
38
39
  } catch { mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Pinned cloud mutation authority is unavailable or invalid'); }
39
40
  }
40
41
  function identity(payload) {
42
+ if (Object.hasOwn(payload, 'previousPassId') && (!ID.test(payload.previousPassId) || payload.previousPassId === payload.passId)) {
43
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation continuation must retain a distinct original settled predecessor');
44
+ }
41
45
  const ids = ['flowId', 'taskId', 'acceptanceReceiptId', 'stepId', 'leaseId', 'planId', 'executionAuthorityId',
42
46
  'executionAttemptNonce', 'runnerId', 'repositoryId', 'snapshotId', 'nonce', 'challengeId'];
43
47
  const digests = ['taskDigest', 'chainDigest', 'acceptanceDigest', 'routeDigest', 'receiverDigest', 'leaseDigest',
44
48
  'planDigest', 'executionPlanSha256', 'executionAuthorityDigest', 'runnerKeyId', 'scopeBindingDigest',
45
49
  'scopeArtifactDigest', 'scopeContractDigest'];
46
- if (ids.some(field => typeof payload[field] !== 'string' || !ID.test(payload[field]))
47
- || digests.some(field => typeof payload[field] !== 'string' || !SHA.test(payload[field]))
50
+ const delegated = Object.hasOwn(payload, 'writeDelegation');
51
+ const execution = new Set(['executionAuthorityId', 'executionAuthorityDigest', 'executionAttemptNonce']);
52
+ if (ids.filter(field => !delegated || !execution.has(field)).some(field => typeof payload[field] !== 'string' || !ID.test(payload[field]))
53
+ || digests.filter(field => !delegated || !execution.has(field)).some(field => typeof payload[field] !== 'string' || !SHA.test(payload[field]))
48
54
  || !/^[0-9a-f]{32}$/.test(payload.repositoryDigest) || !/^[0-9a-f-]{36}$/.test(payload.ownerId)
49
55
  || !Number.isSafeInteger(payload.epoch) || payload.epoch < 0
50
56
  || ['generation', 'chainRevision'].some(field => !Number.isSafeInteger(payload[field]) || payload[field] < 1)) {
@@ -63,6 +69,7 @@ function identity(payload) {
63
69
  || ['projectId', 'hostId', 'threadId'].some(field => !ID.test(String(payload.receiver[field])))) {
64
70
  mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority lost its original cloud receiver');
65
71
  }
72
+ if (delegated) preservedMutationDelegation(payload);
66
73
  }
67
74
  function authority(value, fields, schema, options) {
68
75
  exact(value, [...fields, 'authorityReceipt']);
@@ -79,12 +86,22 @@ function authority(value, fields, schema, options) {
79
86
  identity(payload); mutationLifetime14(payload, options); return value;
80
87
  }
81
88
  export function mutationChallenge14(value, options) {
82
- return authority(value, CHALLENGE_FIELDS, 'entrorove.mutation-snapshot-challenge/1.0', options);
89
+ return authority(value, authorityFields(value, CHALLENGE_FIELDS), 'entrorove.mutation-snapshot-challenge/1.0', options);
90
+ }
91
+ function authorityFields(value, fields) {
92
+ const original = Object.hasOwn(value ?? {}, 'writeDelegation') ? [...fields.filter(field =>
93
+ !['executionAuthorityId', 'executionAuthorityDigest', 'executionAttemptNonce'].includes(field)), 'writeDelegation'] : fields;
94
+ return [...original, ...['previousPassId'].filter(field => Object.hasOwn(value ?? {}, field))];
83
95
  }
84
96
  export function mutationPass14(value, options) {
85
- const fields = [...PASS_FIELDS, ...['disciplinePolicy', 'parallelPaths'].filter(key => Object.hasOwn(value ?? {}, key))];
97
+ const fields = [...authorityFields(value, PASS_FIELDS), ...['disciplinePolicy', 'parallelPaths'].filter(key => Object.hasOwn(value ?? {}, key))];
86
98
  const pass = authority(value, fields, 'entrorove.mutation-pass/1.0', options);
87
99
  if (pass.disciplinePolicy) disciplineApprovedPolicy(pass.disciplinePolicy);
100
+ if (pass.writeDelegation && (!pass.disciplinePolicy || pass.parallelPaths || pass.disciplinePolicy.dataOperations?.length
101
+ || pass.paths.some(target => { const write = pass.disciplinePolicy.writes.find(item => item.targetPath === target);
102
+ return !write || write.kind !== 'modify' || write.deletedSymbols?.length || write.exceptions?.length; }))) {
103
+ mutationFail14('ENTROROVE_MUTATION_DELEGATION_INVALID', 'Preserved mutation permits only the exact existing modification policy');
104
+ }
88
105
  if (pass.parallelPaths && !validatorParallelDeclarationsValid(pass.parallelPaths)) mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Parallel paths must match the frozen equivalence contract');
89
106
  if (!ID.test(pass.passId) || !ID.test(pass.challengeNonce) || pass.handoffEpoch !== pass.epoch || pass.coordinationRequired !== false
90
107
  || ['challengeDigest', 'snapshotDigest', 'snapshotReceiptDigest', 'snapshotManifestDigest', 'attestationDigest'].some(field => !SHA.test(pass[field]))) {
@@ -0,0 +1,51 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { canonical } from './local-integrity.mjs';
3
+ import { scopeRead, scopeTarget } from './local-scope-files.mjs';
4
+ import { readDisciplineSnapshot } from './local-discipline-snapshot.mjs';
5
+ import { preservedMutationDelegation, preservedMutationFail } from './local-preserved-mutation-wire.mjs';
6
+ import { preservedMutationProtectedFiles } from './local-preserved-mutation-state.mjs';
7
+ import { scopeCandidateImports } from './local-scope-repository.mjs';
8
+
9
+ function unknownTarget(path, site) {
10
+ const reason = typeof site?.reason === 'string' && /^[a-z-]+$/.test(site.reason) ? site.reason : 'unknown-import-source';
11
+ throw Object.assign(new Error(`Preserved mutation cannot modify unknown dependency source ${JSON.stringify(path)}: ${reason}`),
12
+ { code: 'ENTROROVE_MUTATION_DELEGATION_INVALID', path, reason });
13
+ }
14
+
15
+ async function observe(input, root, expected) {
16
+ const before = await scopeTarget(root, expected.path, false, true), resolvedPath = await fs.realpath(before.target);
17
+ const read = await scopeRead(root, expected.path, input), after = await scopeTarget(root, expected.path, false, true);
18
+ if (read.sha256 !== expected.sha256 || before.stat.dev !== after.stat.dev || before.stat.ino !== after.stat.ino
19
+ || before.stat.mode !== after.stat.mode || resolvedPath !== await fs.realpath(after.target)) {
20
+ preservedMutationFail(`Preserved mutation dependency ${JSON.stringify(expected.path)} changed bytes, permissions or physical identity`);
21
+ }
22
+ return { ...expected, mode: after.stat.mode & 0o777, resolvedPath };
23
+ }
24
+ export async function capturePreservedMutationProtection(input, root, authority, snapshot) {
25
+ const delegation = preservedMutationDelegation(authority), review = await readDisciplineSnapshot(snapshot);
26
+ for (const expected of delegation.targetFiles) {
27
+ const file = snapshot.manifest.files.find(item => item.path === expected.path);
28
+ if (!file?.exists || file.sha256 !== expected.sha256) preservedMutationFail('Preserved mutation target must retain its exact existing gate baseline');
29
+ const imports = await scopeCandidateImports(root, { path: expected.path,
30
+ content: (await scopeRead(root, expected.path, input)).content.toString('utf8') }, input);
31
+ const declared = delegation.targetImports.find(item => item.path === expected.path);
32
+ if (imports.dynamic) unknownTarget(expected.path, imports.unresolvedImports?.[0]);
33
+ if (canonical([...new Set(imports.edges.map(edge => edge.specifier))].sort()) !== canonical(declared.specifiers)) {
34
+ preservedMutationFail('Preserved mutation targets cannot contain unknown or changed imports');
35
+ }
36
+ }
37
+ const unknown = review.analysis.unresolved.find(item => authority.paths.includes(item.path) && /(?:^import-|dynamic-import|dynamic-reference)/.test(item.reason));
38
+ if (unknown) unknownTarget(unknown.path, unknown);
39
+ const result = [];
40
+ for (const expected of delegation.protectedFiles) result.push(await observe(input, root, expected));
41
+ return result;
42
+ }
43
+ export async function assertPreservedMutationProtection(input, root, authority) {
44
+ const delegation = preservedMutationDelegation(authority), original = await preservedMutationProtectedFiles(input, authority);
45
+ if (canonical(original.map(({ path, sha256 }) => ({ path, sha256 }))) !== canonical(delegation.protectedFiles)) {
46
+ preservedMutationFail('Preserved mutation protected file manifest differs from its signed delegation');
47
+ }
48
+ for (const expected of original) {
49
+ if (canonical(await observe(input, root, expected)) !== canonical(expected)) preservedMutationFail(`Preserved mutation dependency ${JSON.stringify(expected.path)} changed permissions or physical identity`);
50
+ }
51
+ }
@@ -0,0 +1,81 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } 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 { mutationHome14, mutationRoot14, mutationStateRoot14, mutationId14, privateMutationDirectory14,
6
+ readMutationJson14, atomicMutationJson14, mutationTarget14 } from './local-mutation-files.mjs';
7
+ import { preservedMutationFail, preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs';
8
+ import { withStateLock } from './local-state-lock.mjs';
9
+ import { regular } from './local-task-handoff-storage.mjs';
10
+
11
+ export async function preservedMutationLocation(input) {
12
+ const root = await mutationRoot14(input.repositoryRoot), home = await mutationHome14(input.home);
13
+ const relative = path.relative(root, home);
14
+ if (!relative || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
15
+ preservedMutationFail('Preserved mutation signer home must be outside the repository');
16
+ }
17
+ const stateRoot = await mutationStateRoot14({ ...input, home });
18
+ const repositoryDigest = sha256(root).slice(0, 32), flowId = mutationId14(input.flowId, 'flowId');
19
+ const directory = path.join(stateRoot, 'mutation-delegates', repositoryDigest, flowId);
20
+ await privateMutationDirectory14(directory, { privateFrom: stateRoot });
21
+ return { root, home, stateRoot, repositoryDigest, flowId, directory, bindingPath: path.join(directory, 'binding.json'),
22
+ signerConfigPath: path.join(directory, 'signer.json'), fencePath: path.join(directory, 'fence.json') };
23
+ }
24
+ export async function readPreservedMutationBinding(input) {
25
+ const location = await preservedMutationLocation(input), binding = await readMutationJson14(location.bindingPath);
26
+ const publicKey = preservedMutationPublicKey(binding.publicKey);
27
+ if (canonical(Object.keys(binding).sort()) !== canonical(['schemaVersion', 'flowId', 'repositoryDigest', 'home', 'keyId', 'publicKey'].sort())
28
+ || binding.schemaVersion !== 'entrorove.mutation-delegate-binding/1.0' || binding.flowId !== location.flowId
29
+ || binding.repositoryDigest !== location.repositoryDigest || binding.home !== location.home || binding.keyId !== publicKey.keyId) {
30
+ preservedMutationFail('Preserved mutation signer lost its original repository, home or flow binding');
31
+ }
32
+ return { ...location, binding };
33
+ }
34
+ export async function preparePreservedMutationSigner(input) {
35
+ const location = await preservedMutationLocation(input);
36
+ return withStateLock(path.join(location.directory, 'prepare'), () => prepareSigner(input, location));
37
+ }
38
+ async function prepareSigner(input, location) {
39
+ try { await mutationTarget14(location.directory, 'binding.json'); }
40
+ catch (error) {
41
+ if (error.code !== 'ENOENT') throw error;
42
+ const pair = generateKeyPairSync('ed25519'), publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).toString('base64');
43
+ const stage = path.join(location.directory, `.prepare-${randomUUID()}`);
44
+ await privateMutationDirectory14(stage, { exclusive: true, privateFrom: location.stateRoot });
45
+ const privateKeyPath = path.join(location.directory, 'private.pem');
46
+ try {
47
+ await fs.writeFile(path.join(stage, 'private.pem'), pair.privateKey.export({ format: 'pem', type: 'pkcs8' }), { flag: 'wx', mode: 0o600 });
48
+ await atomicMutationJson14(path.join(stage, 'signer.json'), { schemaVersion: 'entrorove.mutation-delegate-signer/1.0',
49
+ privateKeyPath, keyId: sha256(Buffer.from(publicKey, 'base64')), receiptTtlMs: 60_000 }, { privateFrom: location.stateRoot });
50
+ await atomicMutationJson14(path.join(stage, 'binding.json'), { schemaVersion: 'entrorove.mutation-delegate-binding/1.0',
51
+ flowId: location.flowId, repositoryDigest: location.repositoryDigest, home: location.home,
52
+ publicKey, keyId: sha256(Buffer.from(publicKey, 'base64')) }, { privateFrom: location.stateRoot });
53
+ const fence = { schemaVersion: 'entrorove.preserved-mutation-fence/1.0', epoch: 0, identity: null, grants: [] };
54
+ await atomicMutationJson14(path.join(stage, 'fence.json'), { ...fence,
55
+ signature: sign(null, Buffer.from(canonical(fence)), pair.privateKey).toString('base64url') }, { privateFrom: location.stateRoot });
56
+ for (const file of ['private.pem', 'signer.json', 'binding.json', 'fence.json']) await fs.link(path.join(stage, file), path.join(location.directory, file));
57
+ } finally { await fs.rm(stage, { recursive: true, force: true }); }
58
+ }
59
+ const actual = await readPreservedMutationBinding(input);
60
+ await mutationTarget14(actual.directory, 'fence.json');
61
+ await loadPreservedMutationSigner(input, actual.signerConfigPath, actual.binding.keyId);
62
+ return { schemaVersion: 'entrorove.local-execution/1.0', status: 'reported', purpose: 'preserved-existing-modify',
63
+ mutationPublicKey: actual.binding.publicKey, mutationKeyId: actual.binding.keyId, keyId: actual.binding.keyId, signerConfigPath: actual.signerConfigPath };
64
+ }
65
+ export async function loadPreservedMutationSigner(input, file, keyId) {
66
+ const location = await readPreservedMutationBinding(input);
67
+ if (file !== location.signerConfigPath || location.binding.keyId !== keyId) preservedMutationFail('Preserved mutation signer differs from the delegated physical key');
68
+ const config = await readMutationJson14(file);
69
+ if (canonical(Object.keys(config).sort()) !== canonical(['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs'].sort())
70
+ || config.schemaVersion !== 'entrorove.mutation-delegate-signer/1.0' || config.keyId !== keyId
71
+ || config.privateKeyPath !== path.join(location.directory, 'private.pem') || !Number.isSafeInteger(config.receiptTtlMs)
72
+ || config.receiptTtlMs < 1 || config.receiptTtlMs > 90_000) preservedMutationFail('Preserved mutation signer configuration is invalid');
73
+ const target = await mutationTarget14(location.directory, 'private.pem'), { bytes, stat } = await regular(target.target, 16 * 1024);
74
+ try {
75
+ if (stat.nlink !== 1 || process.platform !== 'win32' && (stat.mode & 0o077)
76
+ || process.getuid && stat.uid !== process.getuid()) preservedMutationFail('Preserved mutation signer must be an owned private file');
77
+ const privateKey = createPrivateKey(bytes), der = createPublicKey(privateKey).export({ format: 'der', type: 'spki' });
78
+ if (privateKey.asymmetricKeyType !== 'ed25519' || sha256(der) !== keyId) preservedMutationFail('Preserved mutation signer key fingerprint is invalid');
79
+ return { privateKey, config };
80
+ } finally { bytes.fill(0); }
81
+ }
@@ -0,0 +1,96 @@
1
+ import path from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
+ import { sign, verify } from 'node:crypto';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { withStateLock } from './local-state-lock.mjs';
6
+ import { readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
7
+ import { readPreservedMutationBinding, loadPreservedMutationSigner } from './local-preserved-mutation-signer.mjs';
8
+ import { preservedMutationDelegation, preservedMutationFail, preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs';
9
+
10
+ const identity = authority => Object.fromEntries(['flowId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest',
11
+ 'repositoryDigest', 'runnerKeyId', 'runnerId', 'repositoryId', 'receiverDigest'].map(field => [field, authority[field]]));
12
+ async function rejectGenericHistory(location) {
13
+ const anchor = process.env.ENTROROVE_TASK_HANDOFF_STATE_DIRECTORY;
14
+ if (!anchor) return;
15
+ const file = path.join(anchor, 'flows', location.repositoryDigest, location.flowId, 'fence.json');
16
+ try {
17
+ await fs.lstat(file);
18
+ preservedMutationFail('Preserved mutation cannot replace an existing generic task execution or handoff history');
19
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
20
+ }
21
+ export async function preservedMutationOriginalBinding(input, authority) {
22
+ const location = await readPreservedMutationBinding(input), delegation = preservedMutationDelegation(authority);
23
+ if (authority.flowId !== input.flowId || authority.stepId !== input.stepId || authority.repositoryDigest !== location.repositoryDigest
24
+ || input.taskId !== undefined && input.taskId !== authority.taskId || input.generation !== undefined && input.generation !== authority.generation
25
+ || input.leaseId !== undefined && input.leaseId !== authority.leaseId || input.executionAttemptNonce !== undefined
26
+ || delegation.mutationKeyId !== location.binding.keyId || delegation.mutationPublicKey !== location.binding.publicKey) {
27
+ preservedMutationFail('Preserved mutation lost its original physical signer, task, lease or repository binding');
28
+ }
29
+ await rejectGenericHistory(location);
30
+ let state;
31
+ try { state = await readMutationJson14(location.fencePath); }
32
+ catch (error) { if (error.code !== 'ENOENT') throw error; preservedMutationFail('Preserved mutation fence history is missing; automatic reinitialization is forbidden'); }
33
+ if (state) {
34
+ const { signature, ...core } = state, bytes = typeof signature === 'string' ? Buffer.from(signature, 'base64url') : Buffer.alloc(0);
35
+ if (bytes.length !== 64 || bytes.toString('base64url') !== signature
36
+ || !verify(null, Buffer.from(canonical(core)), preservedMutationPublicKey(location.binding.publicKey).key, bytes)) {
37
+ preservedMutationFail('Preserved mutation fence integrity is invalid');
38
+ }
39
+ }
40
+ if (state.schemaVersion !== 'entrorove.preserved-mutation-fence/1.0' || state.epoch !== 0
41
+ || state.identity !== null && canonical(state.identity) !== canonical(identity(authority))
42
+ || !Array.isArray(state.grants) || state.grants.length > 10000 || state.identity === null && state.grants.length) {
43
+ preservedMutationFail('Preserved mutation fence cannot replace original task identity or epoch history');
44
+ }
45
+ return { ...location, context: null, state: { epoch: 0, status: 'active' }, preservedState: state };
46
+ }
47
+ function grantFor(state, authority) {
48
+ const grant = state.grants.find(item => item.challengeId === authority.challengeId);
49
+ const nonce = authority.passId ? authority.challengeNonce : authority.nonce;
50
+ const digest = authority.passId ? authority.challengeDigest : sha256(canonical(authority));
51
+ if (state.grants.some(item => item.nonce === nonce && item !== grant)
52
+ || grant && (grant.nonce !== nonce || grant.challengeDigest !== digest || grant.leaseId !== authority.leaseId
53
+ || grant.generation !== authority.generation || grant.stepId !== authority.stepId)) {
54
+ preservedMutationFail('Preserved mutation nonce cannot replace its original challenge or lease');
55
+ }
56
+ return grant;
57
+ }
58
+ export async function assertPreservedMutationIssued(input, pass) {
59
+ const binding = await preservedMutationOriginalBinding(input, pass), grant = grantFor(binding.preservedState, pass);
60
+ if (!grant || grant.passDigest !== sha256(canonical(pass))) preservedMutationFail('Preserved mutation requires its original issued pass and nonce fence');
61
+ return binding;
62
+ }
63
+ export async function preservedMutationProtectedFiles(input, authority) {
64
+ const binding = await preservedMutationOriginalBinding(input, authority), grant = grantFor(binding.preservedState, authority);
65
+ if (!grant?.protectedFiles) preservedMutationFail('Preserved mutation requires its original protected file observations');
66
+ return grant.protectedFiles;
67
+ }
68
+ async function persist(input, binding, state) {
69
+ const { signature: previous, ...core } = state;
70
+ const signer = await loadPreservedMutationSigner(input, binding.signerConfigPath, binding.binding.keyId);
71
+ const signature = sign(null, Buffer.from(canonical(core)), signer.privateKey).toString('base64url');
72
+ await atomicMutationJson14(binding.fencePath, { ...core, signature }, { privateFrom: binding.stateRoot, replace: true });
73
+ }
74
+ export async function withPreservedMutationFence(input, authority, action, { attest = false, issue = false, protectedFiles } = {}) {
75
+ const original = await preservedMutationOriginalBinding(input, authority);
76
+ return withStateLock(original.fencePath, async () => {
77
+ const binding = await preservedMutationOriginalBinding(input, authority), state = binding.preservedState;
78
+ if (state.identity === null) state.identity = identity(authority);
79
+ let grant = grantFor(state, authority);
80
+ if (!grant) {
81
+ if (!attest || authority.passId) preservedMutationFail('Preserved mutation has no original attested challenge');
82
+ grant = { challengeId: authority.challengeId, nonce: authority.nonce, challengeDigest: sha256(canonical(authority)),
83
+ leaseId: authority.leaseId, generation: authority.generation, stepId: authority.stepId, passDigest: null, protectedFiles };
84
+ state.grants.push(grant);
85
+ }
86
+ if (attest && canonical(grant.protectedFiles) !== canonical(protectedFiles)) preservedMutationFail('Preserved mutation protected baseline changed during attestation');
87
+ if (authority.passId) {
88
+ const passDigest = sha256(canonical(authority));
89
+ if (grant.passDigest !== passDigest && (!issue || grant.passDigest !== null)) preservedMutationFail('Preserved mutation challenge was already bound to another pass');
90
+ if (issue) grant.passDigest = passDigest;
91
+ else if (!grant.passDigest) preservedMutationFail('Preserved mutation pass is not issued');
92
+ }
93
+ await persist(input, binding, state);
94
+ return action(binding.state);
95
+ });
96
+ }
@@ -0,0 +1,49 @@
1
+ import { createPublicKey } from 'node:crypto';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { mutationFail14, mutationPath14 } from './local-mutation-files.mjs';
4
+
5
+ export const preservedMutationFail = message => mutationFail14('ENTROROVE_MUTATION_DELEGATION_INVALID', message);
6
+ function exact(value, names) {
7
+ if (!value || typeof value !== 'object' || Array.isArray(value)
8
+ || canonical(Object.keys(value).sort()) !== canonical([...names].sort())) preservedMutationFail('Preserved mutation delegation fields are invalid');
9
+ }
10
+ export function preservedMutationPublicKey(encoded) {
11
+ try {
12
+ const bytes = Buffer.from(encoded, 'base64'), key = createPublicKey({ key: bytes, format: 'der', type: 'spki' });
13
+ if (typeof encoded !== 'string' || bytes.toString('base64') !== encoded || key.asymmetricKeyType !== 'ed25519'
14
+ || key.export({ format: 'der', type: 'spki' }).toString('base64') !== encoded) throw new Error();
15
+ return { key, keyId: sha256(bytes) };
16
+ } catch { preservedMutationFail('Preserved mutation requires a canonical Ed25519 public key'); }
17
+ }
18
+ function files(values) {
19
+ if (!Array.isArray(values) || values.length > 2000) preservedMutationFail('Preserved mutation file manifest is invalid');
20
+ for (const [index, file] of values.entries()) {
21
+ exact(file, ['path', 'sha256']); mutationPath14(file.path);
22
+ if (!/^[0-9a-f]{64}$/.test(file.sha256) || index && values[index - 1].path >= file.path) {
23
+ preservedMutationFail('Preserved mutation file manifest must be sorted and unique');
24
+ }
25
+ }
26
+ return values;
27
+ }
28
+ export function preservedMutationDelegation(authority) {
29
+ const value = authority.writeDelegation;
30
+ exact(value, ['schemaVersion', 'purpose', 'scopeKeyId', 'mutationKeyId', 'mutationPublicKey',
31
+ 'gateEvidenceDigest', 'importGraphDigest', 'protectedFiles', 'targetFiles', 'targetImports']);
32
+ const publicKey = preservedMutationPublicKey(value.mutationPublicKey);
33
+ if (value.schemaVersion !== 'entrorove.preserved-write-delegation/1.0' || value.purpose !== 'preserved-existing-modify'
34
+ || ['scopeKeyId', 'mutationKeyId', 'gateEvidenceDigest', 'importGraphDigest'].some(field => !/^[0-9a-f]{64}$/.test(value[field]))
35
+ || publicKey.keyId !== value.mutationKeyId || new Set([authority.runnerKeyId, value.scopeKeyId, value.mutationKeyId]).size !== 3
36
+ || authority.epoch !== 0 || authority.newFilePaths.length !== 0) preservedMutationFail('Preserved mutation role or original epoch is invalid');
37
+ files(value.protectedFiles); files(value.targetFiles);
38
+ if (canonical(value.targetFiles.map(file => file.path)) !== canonical(authority.paths)
39
+ || value.protectedFiles.some(file => authority.paths.includes(file.path))
40
+ || !Array.isArray(value.targetImports) || canonical(value.targetImports.map(file => file?.path)) !== canonical(authority.paths)) {
41
+ preservedMutationFail('Preserved mutation must retain its exact existing target and protected path sets');
42
+ }
43
+ for (const file of value.targetImports) {
44
+ exact(file, ['path', 'specifiers']);
45
+ if (!Array.isArray(file.specifiers) || file.specifiers.some(item => typeof item !== 'string' || !item || /[\u0000-\u001f\u007f]/.test(item))
46
+ || canonical([...new Set(file.specifiers)].sort()) !== canonical(file.specifiers)) preservedMutationFail('Preserved mutation import manifest is invalid');
47
+ }
48
+ return value;
49
+ }