@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,28 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { context as handoffContext, readRegistry } from './local-task-handoff-storage.mjs';
|
|
3
|
+
import { readTaskHandoffFence, assertTaskHandoffActive } from './local-task-handoff-fence.mjs';
|
|
4
|
+
import { withExclusiveLock, fail } from './local-archguard-state.mjs';
|
|
5
|
+
|
|
6
|
+
// This is the same physical directory used by 05's withStateLock(ctx.file).
|
|
7
|
+
// Publish pid/token atomically and recover dead owners immediately, including SIGKILL.
|
|
8
|
+
export async function withArchguardMutationFence(input, action, options = {}) {
|
|
9
|
+
const ctx = await handoffContext(input, true);
|
|
10
|
+
const ordinary = { repositoryRoot: ctx.root, home: ctx.home, flowId: ctx.flowId };
|
|
11
|
+
return withExclusiveLock(`${ctx.file}.lock`, async () => {
|
|
12
|
+
const registry = await readRegistry(ctx.registry);
|
|
13
|
+
if (!registry.bindings[ctx.registryKey]) {
|
|
14
|
+
try {
|
|
15
|
+
await fs.lstat(ctx.file);
|
|
16
|
+
fail('ENTROROVE_HANDOFF_FENCE', 'unregistered flow has existing handoff history; cloud recovery is required');
|
|
17
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
18
|
+
if (!options.bootstrap) fail('ENTROROVE_HANDOFF_FENCE', 'ordinary mutation requires an actually registered flow');
|
|
19
|
+
if (typeof options.bootstrap === 'function') await options.bootstrap();
|
|
20
|
+
return action(async () => {});
|
|
21
|
+
}
|
|
22
|
+
const state = await readTaskHandoffFence(ordinary);
|
|
23
|
+
const assertPaths = paths => assertTaskHandoffActive({ ...ordinary, epoch: state.epoch, ...(paths ? { paths } : {}) });
|
|
24
|
+
// Caller handoff/pass/lease fields never authorize an ordinary Archguard mutation.
|
|
25
|
+
await assertPaths(options.paths);
|
|
26
|
+
return action(assertPaths);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as archguard from './local-archguard.mjs';
|
|
2
|
+
import { runPureArchguardOperation } from './local-archguard-evaluate.mjs';
|
|
3
|
+
import { ARCHGUARD_LOCAL_OPERATION_SCHEMAS } from './local-archguard-contracts.mjs';
|
|
4
|
+
|
|
5
|
+
const capabilities = async () => ({ ...(await runPureArchguardOperation('capabilities', {})).output,
|
|
6
|
+
operations: Object.keys(ARCHGUARD_LOCAL_OPERATION_SCHEMAS), operationSchemas: ARCHGUARD_LOCAL_OPERATION_SCHEMAS,
|
|
7
|
+
stateDirectory: '~/.entrorove/archguard', executionBoundary: 'Only a real snapshot, Ed25519 pass and pinned cloud scope authorization permit a local checkpoint. Signed local receipts require separate cloud verification.' });
|
|
8
|
+
export const ARCHGUARD_LOCAL_OPERATIONS = Object.freeze({
|
|
9
|
+
'archguard-capabilities': capabilities, 'archguard-help': capabilities,
|
|
10
|
+
'archguard-intake': input => runPureArchguardOperation('intake', input),
|
|
11
|
+
'archguard-template-list': input => runPureArchguardOperation('template-list', input),
|
|
12
|
+
'archguard-contract-create': archguard.archguardContractCreate,
|
|
13
|
+
'archguard-contract-get': archguard.archguardContractGet,
|
|
14
|
+
'archguard-contract-update': archguard.archguardContractUpdate,
|
|
15
|
+
'archguard-snapshot': archguard.archguardSnapshot,
|
|
16
|
+
'archguard-checkpoint': archguard.archguardCheckpoint,
|
|
17
|
+
'archguard-scope-request': archguard.archguardScopeRequest,
|
|
18
|
+
'archguard-rules-scan': archguard.archguardRulesScan,
|
|
19
|
+
'archguard-complexity-report': archguard.archguardComplexityReport,
|
|
20
|
+
'archguard-drift-status': archguard.archguardDriftStatus,
|
|
21
|
+
'archguard-ledger-init': archguard.archguardLedgerInit,
|
|
22
|
+
'archguard-ledger-query': archguard.archguardLedgerQuery,
|
|
23
|
+
'archguard-human-resume': archguard.archguardHumanResume,
|
|
24
|
+
'archguard-human-direction-request': archguard.archguardHumanDirectionRequest,
|
|
25
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { withArchguardMutationFence } from './local-archguard-mutation-fence.mjs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { snapshotVerify, verifyMutationPass } from './local-execution.mjs';
|
|
5
|
+
import { context, withExclusiveLock, readLedger, readBytes, readJson, canonical, sha256, fail } from './local-archguard-state.mjs';
|
|
6
|
+
import { freezeContract } from './local-archguard-contract.mjs';
|
|
7
|
+
import { loadSnapshot, drift, MISSING_SHA256, history } from './local-archguard-snapshot.mjs';
|
|
8
|
+
import { signRunnerReceipt, verifyAcceptedWorkflow } from './local-archguard-signing.mjs';
|
|
9
|
+
|
|
10
|
+
export async function archguardScopeRequest(input) {
|
|
11
|
+
return withArchguardMutationFence(input, async () => {
|
|
12
|
+
const ctx = await context(input);
|
|
13
|
+
return withExclusiveLock(path.join(ctx.repositoryState, 'mutation.lock'), async () => {
|
|
14
|
+
const ledger = await readLedger(ctx); const snapshot = await loadSnapshot(ctx, input.snapshotId);
|
|
15
|
+
if (drift(ledger).interrupted) fail('ARCH_RED_INTERRUPT', 'scope request is interrupted by the red architecture checkpoint history');
|
|
16
|
+
if (snapshot.stepId !== input.stepId || ledger.entries.some(item => item.event === 'checkpoint' && item.data.snapshotId === input.snapshotId)) fail('ARCH_SCOPE_REQUEST_REPLAY', 'scope request snapshot belongs to another step or was already consumed');
|
|
17
|
+
const workflow = await verifyAcceptedWorkflow(ctx, input); const plan = workflow.step.executionPlan;
|
|
18
|
+
const locked = await freezeContract(ctx);
|
|
19
|
+
if (locked.contractDigest !== snapshot.contractDigest || plan.contracts.archguard?.contractSha256 !== snapshot.contractDigest
|
|
20
|
+
|| typeof plan.contracts.aimlock?.snapshotSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(input.scopeArtifactDigest)
|
|
21
|
+
|| snapshot.paths.some(relative => !plan.files.some(file => file.path === relative))) fail('ARCH_SCOPE_REQUEST_BINDING', 'scope request must preserve the exact accepted architecture contract, original snapshot, scope artifact, and frozen files');
|
|
22
|
+
for (const expected of snapshot.files) {
|
|
23
|
+
const actual = await readBytes(path.join(ctx.root, expected.path), true, ctx);
|
|
24
|
+
if ((actual.exists ? sha256(actual.content) : MISSING_SHA256) !== expected.sha256) fail('ARCH_SCOPE_REQUEST_CHANGED', 'scope request must be issued before writing the snapshotted block');
|
|
25
|
+
}
|
|
26
|
+
const savedPass = await readJson(input.gatePassPath);
|
|
27
|
+
const verified = await verifyMutationPass({ ...input, repositoryRoot: ctx.root, targetPath: snapshot.paths[0] });
|
|
28
|
+
const pass = verified.pass;
|
|
29
|
+
if (canonical(pass) !== canonical(savedPass) || pass.snapshotId !== snapshot.snapshotId
|
|
30
|
+
|| pass.snapshotReceipt.snapshotDigest !== snapshot.snapshotDigest || pass.stepId !== snapshot.stepId
|
|
31
|
+
|| canonical(pass.paths) !== canonical([...snapshot.paths, 'arch.contract.yaml'].sort())) fail('ARCH_SCOPE_REQUEST_PASS', 'scope request must bind the exact real mutation pass and snapshot path set');
|
|
32
|
+
await snapshotVerify({ ...input, repositoryRoot: ctx.root, snapshotId: snapshot.snapshotId });
|
|
33
|
+
const expires = Math.min(Date.now() + 90_000, Date.parse(pass.expiresAt), Date.parse(workflow.lease.expiresAt), Date.parse(workflow.plan.expiresAt));
|
|
34
|
+
const receipt = await signRunnerReceipt(ctx, { schemaVersion: 'entrorove.archguard-snapshot-receipt/1.0', repositoryDigest: ctx.repositoryDigest,
|
|
35
|
+
flowId: ctx.flowId, ownerId: workflow.plan.ownerId, taskDigest: workflow.plan.taskDigest, chainDigest: workflow.plan.chainDigest,
|
|
36
|
+
scopeArtifactDigest: input.scopeArtifactDigest, contractDigest: snapshot.contractDigest, stepId: input.stepId,
|
|
37
|
+
leaseId: workflow.lease.leaseId, generation: workflow.lease.generation, originalSnapshotDigest: plan.contracts.aimlock.snapshotSha256,
|
|
38
|
+
snapshotDigest: snapshot.snapshotDigest, passDigest: sha256(canonical(pass)), paths: snapshot.paths,
|
|
39
|
+
issuedAt: new Date().toISOString(), expiresAt: new Date(expires).toISOString(), nonce: randomUUID() }, true);
|
|
40
|
+
return { snapshotReceipt: receipt, authorizesWrite: false, nextOperation: 'archguard-scope-authorize' };
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export async function archguardHumanDirectionRequest(input) {
|
|
45
|
+
return withArchguardMutationFence(input, async () => {
|
|
46
|
+
const ctx = await context(input);
|
|
47
|
+
return withExclusiveLock(path.join(ctx.repositoryState, 'mutation.lock'), async () => {
|
|
48
|
+
const ledger = await readLedger(ctx);
|
|
49
|
+
if (!drift(ledger).interrupted) fail('ARCH_HUMAN_REQUEST_NOT_RED', 'human direction request requires the real interrupted local checkpoint ledger');
|
|
50
|
+
const workflow = await verifyAcceptedWorkflow(ctx, input); const locked = await freezeContract(ctx);
|
|
51
|
+
if (workflow.step.executionPlan.contracts.archguard?.contractSha256 !== locked.contractDigest
|
|
52
|
+
|| !/^[0-9a-f]{64}$/.test(input.scopeArtifactDigest)) fail('ARCH_HUMAN_REQUEST_BINDING', 'human direction request must preserve the original accepted architecture and scope authority');
|
|
53
|
+
const last = history(ledger).at(-1);
|
|
54
|
+
if (!last || last.status !== 'blocked') fail('ARCH_HUMAN_REQUEST_INCOMPLETE', 'human direction request lacks a real blocked checkpoint');
|
|
55
|
+
const expires = Math.min(Date.now() + 90_000, Date.parse(workflow.lease.expiresAt), Date.parse(workflow.plan.expiresAt));
|
|
56
|
+
const receipt = await signRunnerReceipt(ctx, { schemaVersion: 'entrorove.archguard-ledger-head-receipt/1.0', repositoryDigest: ctx.repositoryDigest,
|
|
57
|
+
flowId: ctx.flowId, ownerId: workflow.plan.ownerId, taskDigest: workflow.plan.taskDigest, chainDigest: workflow.plan.chainDigest,
|
|
58
|
+
scopeArtifactDigest: input.scopeArtifactDigest, contractDigest: locked.contractDigest, stepId: input.stepId,
|
|
59
|
+
leaseId: workflow.lease.leaseId, generation: workflow.lease.generation, ledgerHeadDigest: ledger.headDigest,
|
|
60
|
+
lastCheckpointId: last.checkpointId, issuedAt: new Date().toISOString(), expiresAt: new Date(expires).toISOString(), nonce: randomUUID() }, true);
|
|
61
|
+
return { ledgerHeadReceipt: receipt, authorizesWrite: false, nextOperation: 'archguard-human-direction-authorize' };
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { createPublicKey, sign, verify } from 'node:crypto';
|
|
4
|
+
import Ajv from 'ajv';
|
|
5
|
+
import { WORKFLOW_ACCEPTED_PLAN_SCHEMA, WORKFLOW_LOCAL_LEASE_SCHEMA } from './workflow-local-contracts.mjs';
|
|
6
|
+
import { authority, canonical, sha256, readBytes, fail } from './local-archguard-state.mjs';
|
|
7
|
+
|
|
8
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
9
|
+
const planValid = ajv.compile(WORKFLOW_ACCEPTED_PLAN_SCHEMA);
|
|
10
|
+
const leaseValid = ajv.compile(WORKFLOW_LOCAL_LEASE_SCHEMA);
|
|
11
|
+
|
|
12
|
+
export async function signRunnerReceipt(ctx, payload, required = false) {
|
|
13
|
+
const config = await authority(ctx);
|
|
14
|
+
if (!config.runnerSignerFile && !config.runnerPublicKey) {
|
|
15
|
+
if (required) fail('ARCH_RUNNER_SIGNER_REQUIRED', 'a pinned trusted runner signer is required for this receipt');
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (typeof config.runnerSignerFile !== 'string' || typeof config.runnerPublicKey !== 'string') fail('ARCH_RUNNER_CONFIG_INVALID', 'trusted runner signer configuration is incomplete');
|
|
19
|
+
const relative = path.relative(ctx.stateRoot, config.runnerSignerFile);
|
|
20
|
+
if (!path.isAbsolute(config.runnerSignerFile) || relative.startsWith('..') || path.isAbsolute(relative)) fail('ARCH_RUNNER_CONFIG_INVALID', 'runner signer must remain in Entrorove private state');
|
|
21
|
+
const bytes = await readBytes(config.runnerSignerFile); const stat = await fs.lstat(config.runnerSignerFile);
|
|
22
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) fail('ARCH_RUNNER_PERMISSIONS', 'runner signer must have private permissions');
|
|
23
|
+
const privateKey = bytes.content.toString('utf8'); const key = createPublicKey(privateKey); const der = key.export({ type: 'spki', format: 'der' });
|
|
24
|
+
if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== config.runnerPublicKey) fail('ARCH_RUNNER_IDENTITY', 'Ed25519 runner signer does not match its pinned public key');
|
|
25
|
+
const value = { ...payload, keyId: sha256(der) };
|
|
26
|
+
return { ...value, signature: sign(null, Buffer.from(canonical(value)), privateKey).toString('base64url') };
|
|
27
|
+
}
|
|
28
|
+
export async function verifyAcceptedWorkflow(ctx, input) {
|
|
29
|
+
if (!planValid(input.acceptedPlan) || !leaseValid(input.lease)) fail('ARCH_WORKFLOW_SCHEMA', 'the accepted plan and lease must satisfy their exact workflow contracts');
|
|
30
|
+
const config = await authority(ctx); const der = Buffer.from(config.publicKey, 'base64');
|
|
31
|
+
const key = createPublicKey({ key: der, type: 'spki', format: 'der' });
|
|
32
|
+
const unsigned = value => {
|
|
33
|
+
const { authorityReceipt: receipt, ...payload } = value; const bytes = Buffer.from(canonical(payload));
|
|
34
|
+
if (bytes.length > 1024 * 1024 || receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0'
|
|
35
|
+
|| receipt.keyId !== sha256(der) || receipt.payloadDigest !== sha256(bytes)
|
|
36
|
+
|| !verify(null, bytes, key, Buffer.from(receipt.signature, 'base64url'))) fail('ARCH_WORKFLOW_AUTHORITY', 'workflow plan or lease signature is invalid');
|
|
37
|
+
return payload;
|
|
38
|
+
};
|
|
39
|
+
const plan = unsigned(input.acceptedPlan); const lease = unsigned(input.lease); const now = Date.now();
|
|
40
|
+
const step = plan.steps.find(value => value.stepId === input.stepId);
|
|
41
|
+
if (!step || plan.flowId !== ctx.flowId || lease.stepId !== input.stepId || !config.actors.includes(plan.ownerId)
|
|
42
|
+
|| ['planId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision'].some(key => lease[key] !== plan[key])
|
|
43
|
+
|| lease.planDigest !== sha256(canonical(plan)) || lease.sequence !== step.sequence
|
|
44
|
+
|| step.executionPlanSha256 !== sha256(canonical(step.executionPlan) + '\n')
|
|
45
|
+
|| plan.runnerKeyId !== sha256(Buffer.from(config.runnerPublicKey ?? '', 'base64'))
|
|
46
|
+
|| Date.parse(plan.acceptedAt) > now || Date.parse(plan.expiresAt) <= now
|
|
47
|
+
|| Date.parse(lease.issuedAt) > now || Date.parse(lease.expiresAt) <= now
|
|
48
|
+
|| Date.parse(lease.expiresAt) <= Date.parse(lease.issuedAt)) fail('ARCH_WORKFLOW_BINDING', 'original accepted plan, runner, flow, owner, task, chain, step, or live lease does not match');
|
|
49
|
+
const dependencyIds = lease.verifiedDependencies.map(value => value.stepId);
|
|
50
|
+
if (new Set(dependencyIds).size !== dependencyIds.length || canonical([...dependencyIds].sort()) !== canonical([...step.dependsOn].sort())) fail('ARCH_WORKFLOW_DEPENDENCIES', 'the signed lease does not verify every frozen step dependency');
|
|
51
|
+
return { plan, lease, step };
|
|
52
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { withArchguardMutationFence } from './local-archguard-mutation-fence.mjs';
|
|
2
|
+
import { constants, promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
|
|
6
|
+
import { context, safeId, safePath, noLinks, canonical, sha256, readBytes, readJson,
|
|
7
|
+
atomicJson, seal, unseal, withExclusiveLock, appendLedger, readLedger, fail } from './local-archguard-state.mjs';
|
|
8
|
+
import { freezeContract, loadContractLock } from './local-archguard-contract.mjs';
|
|
9
|
+
import { readBudgetedProjectFile } from './local-budget-reads.mjs';
|
|
10
|
+
|
|
11
|
+
export const MISSING_SHA256 = sha256('entrorove.missing-file/1.0');
|
|
12
|
+
export function sourcePaths(paths) {
|
|
13
|
+
if (!Array.isArray(paths) || !paths.length) fail('ARCH_SCOPE_EMPTY', 'snapshot paths must not be empty');
|
|
14
|
+
const normalized = paths.map(safePath).sort();
|
|
15
|
+
if (new Set(normalized).size !== normalized.length || normalized.includes('arch.contract.yaml')) fail('ARCH_SCOPE_INVALID', 'source paths must be unique and cannot include the locked contract file');
|
|
16
|
+
return normalized;
|
|
17
|
+
}
|
|
18
|
+
export function history(ledger) {
|
|
19
|
+
const lastDirection = ledger.entries.findLastIndex(item => item.event === 'human-resume');
|
|
20
|
+
return ledger.entries.slice(lastDirection + 1).filter(item => item.event === 'checkpoint').map(item => item.data.ledgerEntry);
|
|
21
|
+
}
|
|
22
|
+
export function drift(ledger) {
|
|
23
|
+
const checkpoints = history(ledger); const last = checkpoints.slice(-3);
|
|
24
|
+
const shared = last.length === 3 && last.every(entry => entry.status === 'blocked')
|
|
25
|
+
? last[0].blockingRuleIds.filter(rule => last.every(entry => entry.blockingRuleIds.includes(rule))) : [];
|
|
26
|
+
return { light: shared.length ? 'red' : checkpoints.at(-1)?.status === 'blocked' ? 'yellow' : 'green',
|
|
27
|
+
interrupted: shared.length > 0, ruleIds: shared, consecutiveBlocks: shared.length ? 3 : 0 };
|
|
28
|
+
}
|
|
29
|
+
function snapshotFile(ctx, snapshotId) { return path.join(ctx.directory, 'snapshots', `${safeId(snapshotId, 'snapshotId')}.json`); }
|
|
30
|
+
async function cachedSource(ctx, snapshotId, file) {
|
|
31
|
+
const target = path.join(ctx.home, '.entrorove', 'snapshots', ctx.repositoryDigest, ctx.flowId, safeId(snapshotId, 'snapshotId'), safePath(file.path));
|
|
32
|
+
await noLinks(target, !file.exists);
|
|
33
|
+
if (!file.exists) {
|
|
34
|
+
try { await fs.lstat(target); fail('ARCH_SNAPSHOT_TAMPERED', 'a missing-file snapshot acquired source bytes'); }
|
|
35
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const bytes = await readBudgetedProjectFile({ home: ctx.home, repositoryRoot: ctx.root, flowId: ctx.flowId }, target, { sourcePath: file.path });
|
|
39
|
+
await noLinks(target);
|
|
40
|
+
if (sha256(bytes) !== file.sha256) fail('ARCH_SNAPSHOT_TAMPERED', 'native snapshot source digest changed');
|
|
41
|
+
return bytes;
|
|
42
|
+
}
|
|
43
|
+
export async function loadSnapshot(ctx, snapshotId) {
|
|
44
|
+
const snapshot = await unseal(ctx, await readJson(snapshotFile(ctx, snapshotId)));
|
|
45
|
+
if (snapshot.schemaVersion !== 'entrorove.archguard-snapshot/2.0' || snapshot.files.some(file => Object.hasOwn(file, 'content'))) fail('ARCH_SNAPSHOT_FORMAT', 'use a metadata-only snapshot with bound native source copies');
|
|
46
|
+
if (snapshot.repositoryDigest !== ctx.repositoryDigest || snapshot.flowId !== ctx.flowId || snapshot.snapshotId !== snapshotId
|
|
47
|
+
|| snapshot.contentDigest !== sha256(canonical(snapshot.files))) fail('ARCH_SNAPSHOT_TAMPERED', 'snapshot is not bound to the repository or content');
|
|
48
|
+
const files = [];
|
|
49
|
+
for (const file of snapshot.files) { const bytes = await cachedSource(ctx, snapshotId, file); files.push({ ...file, content: bytes?.toString('base64') ?? null }); }
|
|
50
|
+
return { ...snapshot, files };
|
|
51
|
+
}
|
|
52
|
+
export async function archguardSnapshot(input) {
|
|
53
|
+
const paths = sourcePaths(input.paths); const snapshotId = safeId(input.snapshotId ?? `snap-${randomUUID()}`, 'snapshotId');
|
|
54
|
+
return withArchguardMutationFence(input, async () => {
|
|
55
|
+
const ctx = await context(input);
|
|
56
|
+
return withExclusiveLock(path.join(ctx.repositoryState, 'mutation.lock'), async () => withExclusiveLock(path.join(ctx.directory, 'mutation.lock'), async () => {
|
|
57
|
+
const ledger = await readLedger(ctx);
|
|
58
|
+
if (drift(ledger).interrupted) fail('ARCH_RED_INTERRUPT', 'three consecutive blocks triggered a red interrupt; human direction is required');
|
|
59
|
+
const current = await freezeContract(ctx); const stepId = safeId(input.stepId, 'stepId'); const files = [];
|
|
60
|
+
for (const relative of [...paths, 'arch.contract.yaml'].sort()) {
|
|
61
|
+
const value = await readBytes(path.join(ctx.root, relative), true, ctx);
|
|
62
|
+
files.push({ path: relative, exists: value.exists, content: value.exists ? value.content.toString('base64') : null,
|
|
63
|
+
sha256: value.exists ? sha256(value.content) : MISSING_SHA256, mode: value.mode });
|
|
64
|
+
}
|
|
65
|
+
const native = await snapshotPlan({ ...input, repositoryRoot: ctx.root, paths: files.map(file => file.path), snapshotId,
|
|
66
|
+
contractDigest: current.contractDigest });
|
|
67
|
+
const verified = await snapshotVerify({ ...input, repositoryRoot: ctx.root, snapshotId });
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const actual = await readBytes(path.join(ctx.root, file.path), true, ctx);
|
|
70
|
+
if ((actual.exists ? sha256(actual.content) : MISSING_SHA256) !== file.sha256) fail('ARCH_SNAPSHOT_RACE', 'source changed while creating its snapshot');
|
|
71
|
+
}
|
|
72
|
+
const metadata = files.map(({ content, ...file }) => file);
|
|
73
|
+
const snapshot = { schemaVersion: 'entrorove.archguard-snapshot/2.0', snapshotId, stepId, flowId: ctx.flowId,
|
|
74
|
+
repositoryDigest: ctx.repositoryDigest, paths, files: metadata, contentDigest: sha256(canonical(metadata)),
|
|
75
|
+
snapshotDigest: native.snapshotDigest, contractDigest: current.contractDigest, contractRawDigest: current.rawDigest,
|
|
76
|
+
receipt: verified.receipt };
|
|
77
|
+
await atomicJson(snapshotFile(ctx, snapshotId), await seal(ctx, snapshot), true);
|
|
78
|
+
await appendLedger(ctx, 'snapshot', { snapshotId, stepId, snapshotDigest: snapshot.snapshotDigest, contractDigest: current.contractDigest, paths });
|
|
79
|
+
return { schemaVersion: snapshot.schemaVersion, snapshotId, paths, snapshotDigest: snapshot.snapshotDigest,
|
|
80
|
+
contractDigest: current.contractDigest, receipt: snapshot.receipt };
|
|
81
|
+
}));
|
|
82
|
+
}, { paths, bootstrap: async () => {
|
|
83
|
+
const lock = await loadContractLock(await context(input), false);
|
|
84
|
+
if (!lock?.usedConfirmations?.length) fail('ENTROROVE_HANDOFF_FENCE', 'bootstrap snapshot requires an explicitly confirmed architecture contract');
|
|
85
|
+
} });
|
|
86
|
+
}
|
|
87
|
+
export async function restoreSnapshot(ctx, snapshot) {
|
|
88
|
+
const prepared = []; const completed = []; const lock = await loadContractLock(ctx);
|
|
89
|
+
const contractIsCurrent = lock.contractDigest === snapshot.contractDigest && lock.rawDigest === snapshot.contractRawDigest;
|
|
90
|
+
const files = contractIsCurrent ? snapshot.files : snapshot.files.filter(file => file.path !== 'arch.contract.yaml');
|
|
91
|
+
try {
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
const target = path.join(ctx.root, safePath(file.path)); await noLinks(path.dirname(target), true);
|
|
94
|
+
await fs.mkdir(path.dirname(target), { recursive: true }); await noLinks(path.dirname(target));
|
|
95
|
+
let old; try { old = await readBytes(target, true, ctx); } catch (error) {
|
|
96
|
+
if (error.code !== 'ARCH_SYMLINK_REJECTED') throw error;
|
|
97
|
+
const stat = await fs.lstat(target); if (!stat.isSymbolicLink()) throw error;
|
|
98
|
+
old = { symlink: await fs.readlink(target), exists: true };
|
|
99
|
+
}
|
|
100
|
+
const temporary = `${target}.${randomUUID()}.rollback`;
|
|
101
|
+
if (file.exists) {
|
|
102
|
+
const bytes = await cachedSource(ctx, snapshot.snapshotId, file);
|
|
103
|
+
if (file.content !== bytes.toString('base64')) fail('ARCH_SNAPSHOT_TAMPERED', 'snapshot content does not match its budgeted native copy');
|
|
104
|
+
await fs.writeFile(temporary, bytes, { flag: 'wx', mode: file.mode });
|
|
105
|
+
}
|
|
106
|
+
prepared.push({ file, target, temporary, old });
|
|
107
|
+
}
|
|
108
|
+
for (const item of prepared) {
|
|
109
|
+
await noLinks(path.dirname(item.target));
|
|
110
|
+
if (item.file.exists) await fs.rename(item.temporary, item.target);
|
|
111
|
+
else await fs.rm(item.target, { force: true });
|
|
112
|
+
completed.push(item);
|
|
113
|
+
}
|
|
114
|
+
return { restored: true, paths: files.map(file => file.path), contractRestored: contractIsCurrent,
|
|
115
|
+
reason: contractIsCurrent ? 'snapshot restored' : 'new explicitly authorized contract revision preserved' };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
for (const item of completed.reverse()) {
|
|
118
|
+
try {
|
|
119
|
+
if (!item.old.exists) await fs.rm(item.target, { force: true });
|
|
120
|
+
else if (item.old.symlink) { await fs.rm(item.target, { force: true }); await fs.symlink(item.old.symlink, item.target); }
|
|
121
|
+
else await fs.writeFile(item.target, item.old.content, { mode: item.old.mode, flag: 'w' });
|
|
122
|
+
} catch { fail('ARCH_ROLLBACK_FAILED', 'rollback recovery failed; repository requires explicit repair'); }
|
|
123
|
+
}
|
|
124
|
+
fail('ARCH_ROLLBACK_FAILED', `snapshot restore failed: ${error.code ?? 'filesystem error'}`);
|
|
125
|
+
} finally { for (const item of prepared) await fs.rm(item.temporary, { force: true }); }
|
|
126
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { generateKeyPairSync, createPublicKey, sign, verify, randomUUID } from 'node:crypto';
|
|
5
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
6
|
+
import { readBudgetedProjectFile } from './local-budget-reads.mjs';
|
|
7
|
+
|
|
8
|
+
export { canonical, sha256 };
|
|
9
|
+
export function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
|
|
10
|
+
export function safeId(value, field) {
|
|
11
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) fail('ARCH_ID_INVALID', `${field} is invalid`);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export function safePath(value) {
|
|
15
|
+
if (typeof value !== 'string' || value !== value.normalize('NFC') || path.isAbsolute(value) || /^[A-Za-z]:/.test(value)
|
|
16
|
+
|| /[\\\u0000-\u001f\u007f]/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail('ARCH_PATH_UNSAFE', 'path is unsafe');
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
export async function noLinks(target, missing = false) {
|
|
20
|
+
const absolute = path.resolve(target); const parts = absolute.split(path.sep).filter(Boolean); let current = path.parse(absolute).root;
|
|
21
|
+
for (const [index, part] of parts.entries()) {
|
|
22
|
+
current = path.join(current, part);
|
|
23
|
+
try {
|
|
24
|
+
const stat = await fs.lstat(current);
|
|
25
|
+
if (stat.isSymbolicLink()) fail('ARCH_SYMLINK_REJECTED', 'symbolic links are forbidden in an Archguard path');
|
|
26
|
+
if (index < parts.length - 1 && !stat.isDirectory()) fail('ARCH_PATH_INVALID', 'path ancestor must be a directory');
|
|
27
|
+
} catch (error) { if (error.code === 'ENOENT' && missing) return absolute; throw error; }
|
|
28
|
+
}
|
|
29
|
+
return absolute;
|
|
30
|
+
}
|
|
31
|
+
export async function readBytes(file, missing = false, sourceContext = null) {
|
|
32
|
+
await noLinks(file, missing); let handle;
|
|
33
|
+
try {
|
|
34
|
+
handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
35
|
+
const stat = await handle.stat();
|
|
36
|
+
if (!stat.isFile()) fail('ARCH_FILE_INVALID', 'Archguard input must be a regular file');
|
|
37
|
+
const content = sourceContext
|
|
38
|
+
? await readBudgetedProjectFile({ home: sourceContext.home, repositoryRoot: sourceContext.root, flowId: sourceContext.flowId }, file,
|
|
39
|
+
{ sourcePath: path.relative(sourceContext.root, file).split(path.sep).join('/') })
|
|
40
|
+
: await handle.readFile();
|
|
41
|
+
const after = await handle.stat();
|
|
42
|
+
const nameStat = await fs.lstat(file);
|
|
43
|
+
if (stat.ino !== after.ino || stat.size !== after.size || stat.mtimeMs !== after.mtimeMs
|
|
44
|
+
|| nameStat.ino !== stat.ino || nameStat.dev !== stat.dev || nameStat.isSymbolicLink()) fail('ARCH_READ_RACE', 'file changed during safe read');
|
|
45
|
+
return { content, mode: stat.mode & 0o777, exists: true };
|
|
46
|
+
} catch (error) { if (missing && error.code === 'ENOENT') return { content: null, mode: null, exists: false }; throw error; }
|
|
47
|
+
finally { if (handle) await handle.close(); }
|
|
48
|
+
}
|
|
49
|
+
export async function privateDirectory(directory) {
|
|
50
|
+
await noLinks(directory, true); await fs.mkdir(directory, { recursive: true, mode: 0o700 }); await noLinks(directory);
|
|
51
|
+
if (!(await fs.lstat(directory)).isDirectory()) fail('ARCH_STATE_INVALID', 'state directory must be a real directory');
|
|
52
|
+
if (process.platform !== 'win32') await fs.chmod(directory, 0o700);
|
|
53
|
+
return directory;
|
|
54
|
+
}
|
|
55
|
+
export async function atomicJson(file, value, exclusive = false) {
|
|
56
|
+
await privateDirectory(path.dirname(file)); await noLinks(file, true);
|
|
57
|
+
if (exclusive) { await fs.writeFile(file, canonical(value) + '\n', { mode: 0o600, flag: 'wx' }); return; }
|
|
58
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
59
|
+
try { await fs.writeFile(temporary, canonical(value) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temporary, file); }
|
|
60
|
+
finally { await fs.rm(temporary, { force: true }); }
|
|
61
|
+
}
|
|
62
|
+
export async function atomicBytes(file, bytes, mode = 0o600) {
|
|
63
|
+
await privateDirectory(path.dirname(file)); await noLinks(file, true);
|
|
64
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
65
|
+
try { await fs.writeFile(temporary, bytes, { mode, flag: 'wx' }); await fs.rename(temporary, file); }
|
|
66
|
+
finally { await fs.rm(temporary, { force: true }); }
|
|
67
|
+
}
|
|
68
|
+
export async function readJson(file) { return JSON.parse((await readBytes(file)).content.toString('utf8')); }
|
|
69
|
+
export async function context(input) {
|
|
70
|
+
if (typeof input.repositoryRoot !== 'string') fail('ARCH_ROOT_REQUIRED', 'repositoryRoot is required');
|
|
71
|
+
const root = await noLinks(path.resolve(input.repositoryRoot));
|
|
72
|
+
if (!(await fs.lstat(root)).isDirectory()) fail('ARCH_ROOT_INVALID', 'repositoryRoot must be a directory');
|
|
73
|
+
const home = input.home ?? os.homedir();
|
|
74
|
+
if (typeof home !== 'string' || !path.isAbsolute(home)) fail('ARCH_HOME_INVALID', 'home must be an absolute path');
|
|
75
|
+
const stateRoot = path.join(home, '.entrorove', 'archguard'); await privateDirectory(stateRoot);
|
|
76
|
+
const repositoryDigest = sha256(root).slice(0, 32); const flowId = safeId(input.flowId, 'flowId');
|
|
77
|
+
return { root, home, flowId, repositoryDigest, stateRoot,
|
|
78
|
+
repositoryState: path.join(stateRoot, 'repositories', repositoryDigest),
|
|
79
|
+
directory: path.join(stateRoot, 'flows', repositoryDigest, flowId) };
|
|
80
|
+
}
|
|
81
|
+
export async function withExclusiveLock(file, action) {
|
|
82
|
+
await privateDirectory(path.dirname(file)); await noLinks(file, true);
|
|
83
|
+
const token = randomUUID();
|
|
84
|
+
try { await fs.mkdir(file, { mode: 0o700 }); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code !== 'EEXIST') throw error;
|
|
87
|
+
if (!await recoverDeadLock(file)) fail('ARCH_CONCURRENT_REJECTED', 'concurrent Archguard mutation rejected');
|
|
88
|
+
try { await fs.mkdir(file, { mode: 0o700 }); }
|
|
89
|
+
catch (cause) { if (cause.code === 'EEXIST') fail('ARCH_CONCURRENT_REJECTED', 'concurrent Archguard mutation rejected'); throw cause; }
|
|
90
|
+
}
|
|
91
|
+
const ownerFile = path.join(file, 'owner.json');
|
|
92
|
+
await publishLockOwner(ownerFile, { pid: process.pid, token });
|
|
93
|
+
try { return await action(); }
|
|
94
|
+
finally {
|
|
95
|
+
const owner = await readJson(ownerFile);
|
|
96
|
+
if (owner.token !== token || owner.pid !== process.pid) fail('ARCH_LOCK_OWNERSHIP', 'Archguard mutation lock ownership changed');
|
|
97
|
+
await fs.unlink(ownerFile); await fs.rmdir(file);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function publishLockOwner(file, owner) {
|
|
101
|
+
const staged = `${file}.${owner.token}.tmp`;
|
|
102
|
+
try {
|
|
103
|
+
await fs.writeFile(staged, canonical(owner) + '\n', { flag: 'wx', mode: 0o600 });
|
|
104
|
+
await fs.link(staged, file);
|
|
105
|
+
} finally { await fs.rm(staged, { force: true }); }
|
|
106
|
+
}
|
|
107
|
+
async function recoverDeadLock(file) {
|
|
108
|
+
let stat; let owner;
|
|
109
|
+
try { stat = await fs.lstat(file); }
|
|
110
|
+
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
111
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ARCH_LOCK_INVALID', 'Archguard mutation lock must be a real directory');
|
|
112
|
+
try { owner = await readJson(path.join(file, 'owner.json')); }
|
|
113
|
+
catch (error) { if (['ENOENT', 'ARCH_READ_RACE'].includes(error.code) || error instanceof SyntaxError) return false; throw error; }
|
|
114
|
+
if (!owner || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || typeof owner.token !== 'string') fail('ARCH_LOCK_INVALID', 'Archguard mutation lock has no valid process owner');
|
|
115
|
+
try { process.kill(owner.pid, 0); return false; } catch (error) { if (error.code !== 'ESRCH') return false; }
|
|
116
|
+
const moved = `${file}.dead-${randomUUID()}`;
|
|
117
|
+
try { await fs.rename(file, moved); } catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
118
|
+
const after = await fs.lstat(moved);
|
|
119
|
+
if (stat.ino !== after.ino || stat.dev !== after.dev) {
|
|
120
|
+
await fs.rename(moved, file); fail('ARCH_LOCK_RACE', 'Archguard mutation lock changed during dead process recovery');
|
|
121
|
+
}
|
|
122
|
+
await fs.unlink(path.join(moved, 'owner.json')); await fs.rmdir(moved); return true;
|
|
123
|
+
}
|
|
124
|
+
async function signingKey(ctx) {
|
|
125
|
+
const file = path.join(ctx.repositoryState, 'state-signer.json'); await privateDirectory(ctx.repositoryState);
|
|
126
|
+
try {
|
|
127
|
+
const stat = await fs.lstat(file);
|
|
128
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) fail('ARCH_KEY_PERMISSIONS', 'state signer must have private permissions');
|
|
129
|
+
const key = await readJson(file); const derived = createPublicKey(key.privateKey).export({ type: 'spki', format: 'der' }).toString('base64');
|
|
130
|
+
if (key.publicKey !== derived) fail('ARCH_STATE_KEY_INVALID', 'state signer identity changed');
|
|
131
|
+
return key;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error.code !== 'ENOENT') throw error;
|
|
134
|
+
const pair = generateKeyPairSync('ed25519');
|
|
135
|
+
const key = { privateKey: pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
136
|
+
publicKey: pair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64') };
|
|
137
|
+
try { await atomicJson(file, key, true); return key; } catch (cause) { if (cause.code === 'EEXIST') return signingKey(ctx); throw cause; }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export async function seal(ctx, value) {
|
|
141
|
+
const key = await signingKey(ctx);
|
|
142
|
+
return { ...value, stateSignature: sign(null, Buffer.from(canonical(value)), key.privateKey).toString('base64url') };
|
|
143
|
+
}
|
|
144
|
+
export async function unseal(ctx, signed) {
|
|
145
|
+
const key = await signingKey(ctx); const { stateSignature, ...value } = signed;
|
|
146
|
+
const publicKey = createPublicKey({ key: Buffer.from(key.publicKey, 'base64'), type: 'spki', format: 'der' });
|
|
147
|
+
if (typeof stateSignature !== 'string' || !verify(null, Buffer.from(canonical(value)), publicKey, Buffer.from(stateSignature, 'base64url'))) fail('ARCH_STATE_TAMPERED', 'signed Archguard state was tampered');
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
export async function readLedger(ctx) {
|
|
151
|
+
const ledgerPath = path.join(ctx.directory, 'ledger.jsonl'); const headPath = path.join(ctx.directory, 'head.json');
|
|
152
|
+
const bytes = await readBytes(ledgerPath, true); const headBytes = await readBytes(headPath, true);
|
|
153
|
+
if (!bytes.exists && !headBytes.exists) return { entries: [], headDigest: null, ledgerPath };
|
|
154
|
+
if (!bytes.exists || !headBytes.exists) fail('ARCH_LEDGER_UNMANAGED', 'ledger has no managed signed head');
|
|
155
|
+
const entries = bytes.content.toString('utf8').trim().split('\n').map(line => JSON.parse(line)); let previousDigest = null;
|
|
156
|
+
for (const [index, signed] of entries.entries()) {
|
|
157
|
+
const value = await unseal(ctx, signed); const { digest, ...core } = value;
|
|
158
|
+
if (value.sequence !== index + 1 || value.previousDigest !== previousDigest || sha256(canonical(core)) !== digest
|
|
159
|
+
|| value.flowId !== ctx.flowId || value.repositoryDigest !== ctx.repositoryDigest) fail('ARCH_LEDGER_TAMPERED', 'ledger hash chain is invalid');
|
|
160
|
+
previousDigest = digest;
|
|
161
|
+
}
|
|
162
|
+
const head = await unseal(ctx, JSON.parse(headBytes.content.toString('utf8')));
|
|
163
|
+
if (head.sequence !== entries.length || head.digest !== previousDigest || head.flowId !== ctx.flowId
|
|
164
|
+
|| head.repositoryDigest !== ctx.repositoryDigest) fail('ARCH_LEDGER_TAMPERED', 'ledger signed head rejects truncation or reordering');
|
|
165
|
+
return { entries, headDigest: previousDigest, ledgerPath };
|
|
166
|
+
}
|
|
167
|
+
export async function appendLedger(ctx, event, data) {
|
|
168
|
+
const previous = await readLedger(ctx); const core = { schemaVersion: 'entrorove.archguard-ledger/1.0',
|
|
169
|
+
sequence: previous.entries.length + 1, previousDigest: previous.headDigest, repositoryDigest: ctx.repositoryDigest,
|
|
170
|
+
flowId: ctx.flowId, event, data, at: new Date().toISOString() };
|
|
171
|
+
const record = await seal(ctx, { ...core, digest: sha256(canonical(core)) });
|
|
172
|
+
await privateDirectory(ctx.directory);
|
|
173
|
+
const handle = await fs.open(previous.ledgerPath, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
174
|
+
try {
|
|
175
|
+
const stat = await handle.stat();
|
|
176
|
+
if (!stat.isFile() || stat.nlink !== 1) fail('ARCH_LEDGER_UNSAFE', 'append-only ledger must be an unshared regular managed file');
|
|
177
|
+
await handle.writeFile(canonical(record) + '\n'); await handle.sync();
|
|
178
|
+
} finally { await handle.close(); }
|
|
179
|
+
await atomicJson(path.join(ctx.directory, 'head.json'), await seal(ctx, {
|
|
180
|
+
sequence: record.sequence, digest: record.digest, flowId: ctx.flowId, repositoryDigest: ctx.repositoryDigest }));
|
|
181
|
+
return record;
|
|
182
|
+
}
|
|
183
|
+
export async function authority(ctx) {
|
|
184
|
+
const file = path.join(ctx.stateRoot, 'authorities', `${ctx.repositoryDigest}.json`); const stat = await fs.lstat(file);
|
|
185
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) fail('ARCH_AUTHORITY_PERMISSIONS', 'authority configuration must have private permissions');
|
|
186
|
+
const value = await readJson(file);
|
|
187
|
+
if (value.schemaVersion !== 'entrorove.archguard-authority/1.0' || !Array.isArray(value.actors) || !value.actors.length || typeof value.publicKey !== 'string') fail('ARCH_AUTHORITY_INVALID', 'pinned architecture authority is not configured');
|
|
188
|
+
const der = Buffer.from(value.publicKey, 'base64'); const key = createPublicKey({ key: der, type: 'spki', format: 'der' });
|
|
189
|
+
if (key.asymmetricKeyType !== 'ed25519' || key.export({ type: 'spki', format: 'der' }).toString('base64') !== value.publicKey) fail('ARCH_AUTHORITY_INVALID', 'pinned architecture authority must be canonical Ed25519 SPKI');
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
export async function verifyAuthority(ctx, signed, schemaVersion) {
|
|
193
|
+
if (!signed || typeof signed.signature !== 'string') fail('ARCH_CONFIRMATION_REQUIRED', 'signed authority confirmation is required');
|
|
194
|
+
const key = await authority(ctx); const { signature, ...value } = signed; const der = Buffer.from(key.publicKey, 'base64');
|
|
195
|
+
const now = Date.now(); const issued = Date.parse(value.issuedAt); const expires = Date.parse(value.expiresAt);
|
|
196
|
+
if (value.schemaVersion !== schemaVersion || value.repositoryDigest !== ctx.repositoryDigest || value.flowId !== ctx.flowId
|
|
197
|
+
|| !Number.isFinite(issued) || !Number.isFinite(expires) || issued > now || expires <= now || expires <= issued || expires - issued > 300_000
|
|
198
|
+
|| value.keyId !== sha256(der) || !verify(null, Buffer.from(canonical(value)), createPublicKey({ key: der, type: 'spki', format: 'der' }), Buffer.from(signature, 'base64url'))) fail('ARCH_AUTHORIZATION_INVALID', 'authority signature, repository, flow, or lifetime is invalid');
|
|
199
|
+
safeId(value.nonce, 'authority nonce'); return { value, authority: key };
|
|
200
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { withArchguardMutationFence } from './local-archguard-mutation-fence.mjs';
|
|
2
|
+
export { archguardContractGet, archguardContractCreate, archguardContractUpdate } from './local-archguard-contract.mjs';
|
|
3
|
+
export { archguardSnapshot } from './local-archguard-snapshot.mjs';
|
|
4
|
+
export { archguardCheckpoint } from './local-archguard-checkpoint.mjs';
|
|
5
|
+
export { archguardScopeRequest, archguardHumanDirectionRequest } from './local-archguard-scope.mjs';
|
|
6
|
+
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { context, safePath, readBytes, readLedger, withExclusiveLock, fail, verifyAuthority, appendLedger, sha256, canonical } from './local-archguard-state.mjs';
|
|
9
|
+
import { freezeContract, archguardContractGet } from './local-archguard-contract.mjs';
|
|
10
|
+
import { sourcePaths, history, drift } from './local-archguard-snapshot.mjs';
|
|
11
|
+
import { evaluateArchguardFiles, evaluateArchguardDrift } from './local-archguard-evaluate.mjs';
|
|
12
|
+
|
|
13
|
+
async function scan(input) {
|
|
14
|
+
const ctx = await context(input);
|
|
15
|
+
return withExclusiveLock(path.join(ctx.repositoryState, 'mutation.lock'), async () => {
|
|
16
|
+
await readLedger(ctx); const locked = await archguardContractGet(input); const files = [];
|
|
17
|
+
for (const relative of sourcePaths(input.paths)) {
|
|
18
|
+
const bytes = await readBytes(path.join(ctx.root, safePath(relative)), false, ctx);
|
|
19
|
+
files.push({ path: relative, content: bytes.content.toString('utf8') });
|
|
20
|
+
}
|
|
21
|
+
return { ...evaluateArchguardFiles(locked.contract, files), contractDigest: locked.contractDigest };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export async function archguardRulesScan(input) {
|
|
25
|
+
const result = await scan(input);
|
|
26
|
+
return { findings: result.findings, summary: { total: result.findings.length,
|
|
27
|
+
blocking: result.findings.filter(item => item.blocking).length }, contractDigest: result.contractDigest };
|
|
28
|
+
}
|
|
29
|
+
export async function archguardComplexityReport(input) {
|
|
30
|
+
const result = await scan(input);
|
|
31
|
+
return { metrics: result.metrics, findings: result.findings.filter(item => item.category === 'complexity'), contractDigest: result.contractDigest };
|
|
32
|
+
}
|
|
33
|
+
export async function archguardDriftStatus(input) {
|
|
34
|
+
const ctx = await context(input); const ledger = await readLedger(ctx);
|
|
35
|
+
return { ...await evaluateArchguardDrift(history(ledger)), ...drift(ledger), processLedgerDigest: ledger.headDigest };
|
|
36
|
+
}
|
|
37
|
+
export async function archguardLedgerQuery(input) {
|
|
38
|
+
const ctx = await context(input); const ledger = await readLedger(ctx);
|
|
39
|
+
const entries = ledger.entries.filter(entry => (!input.event || entry.event === input.event)
|
|
40
|
+
&& (!input.status || entry.data.status === input.status)
|
|
41
|
+
&& (!input.ruleId || entry.data.ledgerEntry?.ruleIds.includes(input.ruleId)));
|
|
42
|
+
return { schemaVersion: 'entrorove.archguard-ledger/1.0', entries, total: entries.length,
|
|
43
|
+
headDigest: ledger.headDigest, ledgerPath: ledger.ledgerPath, verifiedIntegrity: true };
|
|
44
|
+
}
|
|
45
|
+
export async function archguardLedgerInit(input) {
|
|
46
|
+
return withArchguardMutationFence(input, async () => {
|
|
47
|
+
const ctx = await context(input);
|
|
48
|
+
return withExclusiveLock(path.join(ctx.repositoryState, 'mutation.lock'), async () => {
|
|
49
|
+
const ledger = await readLedger(ctx);
|
|
50
|
+
if (ledger.entries.length) return { initialized: false, alreadyManaged: true, total: ledger.entries.length, headDigest: ledger.headDigest };
|
|
51
|
+
const record = await appendLedger(ctx, 'ledger-init', { repositoryDigest: ctx.repositoryDigest, flowId: ctx.flowId });
|
|
52
|
+
return { initialized: true, alreadyManaged: false, total: 1, headDigest: record.digest };
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export async function archguardHumanResume(input) {
|
|
57
|
+
return withArchguardMutationFence(input, async () => {
|
|
58
|
+
const ctx = await context(input);
|
|
59
|
+
return withExclusiveLock(path.join(ctx.repositoryState, 'mutation.lock'), async () => {
|
|
60
|
+
const ledger = await readLedger(ctx);
|
|
61
|
+
const permit = await verifyAuthority(ctx, input.confirmation, 'entrorove.archguard-human-direction/1.0');
|
|
62
|
+
if (!drift(ledger).interrupted) fail('ARCH_RESUME_NOT_RED', 'human direction applies only to a red interrupted flow');
|
|
63
|
+
const value = permit.value;
|
|
64
|
+
if (value.action !== 'resume-correction' || value.ledgerHeadDigest !== ledger.headDigest
|
|
65
|
+
|| !permit.authority.actors.includes(value.actor) || typeof value.reason !== 'string' || !value.reason.trim()) fail('ARCH_HUMAN_DIRECTION_INVALID', 'human direction must bind the exact red ledger head, actor, and reason');
|
|
66
|
+
if (ledger.entries.some(item => item.event === 'human-resume' && item.data.nonce === value.nonce)) fail('ARCH_HUMAN_DIRECTION_REPLAY', 'human direction was already consumed');
|
|
67
|
+
const locked = await freezeContract(ctx);
|
|
68
|
+
if (value.contractDigest !== locked.contractDigest) fail('ARCH_HUMAN_DIRECTION_BINDING', 'human direction must preserve the exact architecture contract digest');
|
|
69
|
+
const record = await appendLedger(ctx, 'human-resume', { nonce: value.nonce, actor: value.actor, reason: value.reason,
|
|
70
|
+
previousHeadDigest: ledger.headDigest, confirmationDigest: sha256(canonical(input.confirmation)) });
|
|
71
|
+
return { resumed: true, action: 'resume-correction', contractUnchanged: true, headDigest: record.digest };
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { canonical, relativePath, fail } from './local-budget-grants.mjs';
|
|
4
|
+
import { readBudgetedProjectFile } from './local-budget-reads.mjs';
|
|
5
|
+
import { gitReadBinding, readBudgetedGitBlob } from './local-budget-git.mjs';
|
|
6
|
+
import { deriveBudgetGitAttributeSource } from './local-scope-git-attributes.mjs';
|
|
7
|
+
const bindings = new WeakMap(), hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
8
|
+
const control = side => `${side}/** -text -working-tree-encoding -filter -ident\n`;
|
|
9
|
+
const invalid = () => fail('ENTROROVE_GIT_ATTRIBUTES_UNSUPPORTED', 'derived Git attributes require their fixed original source binding');
|
|
10
|
+
function identity(input) { return canonical({ home: input.home, repositoryRoot: input.repositoryRoot, flowId: input.flowId }); }
|
|
11
|
+
export async function sealBudgetedGitAttributes(input, sources) {
|
|
12
|
+
const binding = await gitReadBinding(input), segments = [], drivers = new Set(); let text = '';
|
|
13
|
+
if (!binding.input.flowId || !Array.isArray(sources)) invalid();
|
|
14
|
+
for (const source of sources) {
|
|
15
|
+
if (!source || !Buffer.isBuffer(source.content) || !source.evidence || !/^[0-9a-f]{64}$/.test(source.evidence.sha256)) invalid();
|
|
16
|
+
const relative = relativePath(source.evidence.path);
|
|
17
|
+
const directory = path.basename(relative) === '.gitattributes' ? path.dirname(relative).replace(/^\.$/, '') : '';
|
|
18
|
+
if (source.directory !== directory) invalid();
|
|
19
|
+
let actual;
|
|
20
|
+
if (source.evidence.sourceKind === 'index') {
|
|
21
|
+
actual = await readBudgetedGitBlob(binding.input, { path: relative, mode: source.evidence.mode, objectId: source.evidence.objectId,
|
|
22
|
+
sourceKind: 'index', commitId: null, role: 'git-blob', bytes: source.content.length });
|
|
23
|
+
} else actual = await readBudgetedProjectFile(binding.input, path.join(binding.root, relative), { sourcePath: relative });
|
|
24
|
+
if (hash(actual) !== source.evidence.sha256 || !actual.equals(source.content)) invalid();
|
|
25
|
+
const lines = deriveBudgetGitAttributeSource(actual, source.directory, drivers, source.evidence.sourceKind);
|
|
26
|
+
const derived = lines.length ? `${lines.join('\n')}\n` : '', start = Buffer.byteLength(text);
|
|
27
|
+
text += derived;
|
|
28
|
+
if (derived) segments.push(Object.freeze({ ...source.evidence, path: relative, start, end: Buffer.byteLength(text), role: 'git-process-derived' }));
|
|
29
|
+
}
|
|
30
|
+
const token = Object.freeze({ kind: 'fixed-git-attribute-binding', digest: hash(canonical({ binding: identity(binding.input), segments, text })) });
|
|
31
|
+
bindings.set(token, Object.freeze({ binding: identity(binding.input), text, segments: Object.freeze(segments) })); return token;
|
|
32
|
+
}
|
|
33
|
+
export async function boundGitAttributeSegments(input, options) {
|
|
34
|
+
const text = options.attributes;
|
|
35
|
+
if (text === '' && !options.attributeBinding) return [];
|
|
36
|
+
const trusted = bindings.get(options.attributeBinding);
|
|
37
|
+
if (!trusted) {
|
|
38
|
+
if ([control('before'), control('before') + control('after')].includes(text)) return [];
|
|
39
|
+
invalid();
|
|
40
|
+
}
|
|
41
|
+
const binding = await gitReadBinding(input);
|
|
42
|
+
if (trusted.binding !== identity(binding.input)
|
|
43
|
+
|| ![trusted.text, trusted.text + control('before'), trusted.text + control('before') + control('after')].includes(text)) invalid();
|
|
44
|
+
return trusted.segments;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function hasSealedBudgetGitAttributes(options) {
|
|
48
|
+
return bindings.has(options?.attributeBinding);
|
|
49
|
+
}
|