@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.5
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/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
- package/bin/__tests__/codex-hooks.test.js +531 -29
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +653 -185
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
- package/bin/__tests__/installer-safety.test.js +39 -0
- package/bin/__tests__/opencode-spec-gate.test.js +24 -16
- package/bin/__tests__/package-inventory.test.js +705 -13
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
- package/bin/__tests__/specs-v2-execution-closeout.test.js +468 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +665 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +1132 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2013 -221
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +5 -2
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +143 -99
- package/src/claude/hooks/__tests__/completion-authority.test.js +321 -58
- package/src/claude/hooks/__tests__/privacy-block.test.js +254 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
- package/src/claude/hooks/completion-authority-check.cjs +37 -288
- package/src/claude/hooks/completion-authority-state.cjs +10 -8
- package/src/claude/hooks/completion-authority.cjs +13 -7
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +99 -28
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/spec-gate.cjs +47 -117
- package/src/claude/hooks/spec-state.cjs +8 -2
- package/src/claude/migration-manifest.json +9 -1
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/change-firewall.cjs +1748 -0
- package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
- package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
- package/src/claude/scripts/spec-final-state.cjs +290 -0
- package/src/claude/scripts/spec-ground.cjs +571 -173
- package/src/claude/scripts/spec-readiness.cjs +425 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +67 -16
- package/src/claude/scripts/spec-scaffold.cjs +1594 -139
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3110 -218
- package/src/claude/scripts/workflow-policy.cjs +501 -183
- package/src/claude/settings/settings.json +4 -0
- package/src/claude/skills/code-review/SKILL.md +12 -3
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +47 -60
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +19 -12
- package/src/claude/skills/specs/SKILL.md +183 -177
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +214 -262
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +35 -85
- package/src/claude/skills/specs/templates/task.md +30 -102
- package/src/claude/skills/sync/SKILL.md +33 -10
- package/src/claude/skills/sync/references/sync-protocols.md +31 -13
- package/src/claude/skills/test/SKILL.md +17 -7
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +43 -240
- package/src/codex/hooks/completion-authority-state.cjs +10 -8
- package/src/codex/hooks/completion-authority.cjs +13 -7
- package/src/codex/hooks/lib/hook-context.cjs +16 -2
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +61 -38
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
- package/src/codex/hooks/lib/spec-utils.cjs +56 -5
- package/src/codex/hooks/privacy-approval.cjs +6 -2
- package/src/codex/hooks/privacy-block.cjs +94 -15
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/spec-gate.cjs +49 -34
- package/src/codex/hooks/spec-state.cjs +10 -3
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +6 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
|
@@ -0,0 +1,1748 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// D1 change-firewall gate (owner R0-01). Exports the exact named surface D1
|
|
4
|
+
// requires: loadBaselineAuthority, assertBaselineUsable, bootstrapBaseline,
|
|
5
|
+
// advanceBaseline, deriveProtectedChangedPaths, computeProtectedTreeDigest,
|
|
6
|
+
// computeProposalDigest, computeCandidateDigest, assertHotspotChangeAllowed,
|
|
7
|
+
// createFreezeManifest, verifyFreezeManifest, invalidateFreeze,
|
|
8
|
+
// NO_CHANGE_ATTESTATION_DIGEST, plus the C15 BenchmarkFailureLedger surface
|
|
9
|
+
// (loadFailureLedger, assertFailureLedgerUsable, openFailure, classifyFailure,
|
|
10
|
+
// reclassifyFailure, resolveFailure, computeFailureLedgerDigest,
|
|
11
|
+
// deriveFailureState, NO_FAILURES_ATTESTATION_DIGEST) this same module owns.
|
|
12
|
+
//
|
|
13
|
+
// Production callers never get a per-call path override (I13/I19): the plain
|
|
14
|
+
// top-level exports are bound to this checkout's own real project root
|
|
15
|
+
// (resolved via git from this file's own location, never process.cwd()).
|
|
16
|
+
// `createChangeFirewall({ root })` exists only so a test/harness can construct
|
|
17
|
+
// an isolated instance bound to a different, once-verified trusted root.
|
|
18
|
+
|
|
19
|
+
const crypto = require('node:crypto');
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { spawnSync } = require('node:child_process');
|
|
23
|
+
|
|
24
|
+
const DIGEST = require('./spec-authoring-digest.cjs');
|
|
25
|
+
const { asciiOrdinalCompare, stableJson } = DIGEST;
|
|
26
|
+
|
|
27
|
+
let VALIDATOR = null;
|
|
28
|
+
function validatorModule() {
|
|
29
|
+
if (!VALIDATOR) VALIDATOR = require('./validate-spec-output.cjs');
|
|
30
|
+
return VALIDATOR;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SCHEMA_VERSION = '1';
|
|
34
|
+
|
|
35
|
+
const REL = {
|
|
36
|
+
releaseBaseline: 'packages/spec/benchmarks/release-baseline.json',
|
|
37
|
+
ledger: 'packages/spec/benchmarks/benchmark-failure-ledger.json',
|
|
38
|
+
freeze: 'packages/spec/.cafekit-release/change-firewall-freeze.json',
|
|
39
|
+
receipt: 'packages/spec/.cafekit-release/release-receipt.json',
|
|
40
|
+
legacyBridge: 'specs/cafekit-semantic-eval-firewall/reports/bootstrap-legacy-bridge-review.json',
|
|
41
|
+
cafekitReleaseDir: 'packages/spec/.cafekit-release/',
|
|
42
|
+
packageJson: 'packages/spec/package.json',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const PROTECTED_FILE_PATHS = new Set([
|
|
46
|
+
'packages/spec/bin/phases/copy-payload.js',
|
|
47
|
+
'packages/spec/package.json',
|
|
48
|
+
'packages/spec/.gitignore',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const PROTECTED_DIR_PREFIXES = [
|
|
52
|
+
'packages/spec/src/claude/scripts/',
|
|
53
|
+
'packages/spec/src/claude/skills/specs/',
|
|
54
|
+
'packages/spec/scripts/',
|
|
55
|
+
'packages/spec/benchmarks/',
|
|
56
|
+
'packages/spec/bin/__tests__/',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const RESERVED_PATHS = new Set([REL.releaseBaseline, REL.ledger]);
|
|
60
|
+
|
|
61
|
+
const FAILURE_CLASSES = [
|
|
62
|
+
'framework_regression', 'model_runtime_error', 'prompt_ambiguity',
|
|
63
|
+
'domain_research_failure', 'infrastructure', 'oracle_defect', 'mixed', 'unknown',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const OWNER_LAYERS = [
|
|
67
|
+
'authoring_kernel', 'structural_compiler', 'semantic_reviewer',
|
|
68
|
+
'readiness_finalizer', 'benchmark_controller', 'independent_adjudicator', 'release_process',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const CANDIDATE_DIGEST_FIELDS = [
|
|
72
|
+
'baselineAuthorityDigest', 'baselineGeneration', 'baselineCommit',
|
|
73
|
+
'failureLedgerDigest', 'failureLedgerGeneration', 'remediatedFailureIds',
|
|
74
|
+
'changedPaths', 'treeDigest', 'proposalDigest', 'packageVersion',
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const RELEASE_RECEIPT_FIELDS = [
|
|
78
|
+
'schema_version', 'receipt_id', 'status', 'baselineAuthorityDigest', 'baselineGeneration',
|
|
79
|
+
'freezeDigest', 'candidateDigest', 'treeDigest', 'packageVersion', 'releaseCommit',
|
|
80
|
+
'artifactPath', 'artifactDigest', 'resolvedFailureIds',
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const NO_CHANGE_ATTESTATION_DIGEST = `sha256:${crypto.createHash('sha256').update('cafekit:no-protected-change:v1').digest('hex')}`;
|
|
84
|
+
const NO_FAILURES_ATTESTATION_DIGEST = `sha256:${crypto.createHash('sha256').update('cafekit:no-open-failures:v1').digest('hex')}`;
|
|
85
|
+
|
|
86
|
+
class ChangeFirewallError extends Error {
|
|
87
|
+
constructor(code, message) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = 'ChangeFirewallError';
|
|
90
|
+
this.code = code;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function fail(code, message) { throw new ChangeFirewallError(code, message); }
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// generic helpers
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function sha256Hex(bytes) { return crypto.createHash('sha256').update(bytes).digest('hex'); }
|
|
100
|
+
function sha256Tag(bytes) { return `sha256:${sha256Hex(bytes)}`; }
|
|
101
|
+
function isSha256Tag(value) { return typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value); }
|
|
102
|
+
function isCommitId(value) { return typeof value === 'string' && /^[0-9a-f]{40}$/.test(value); }
|
|
103
|
+
function plain(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
|
|
104
|
+
|
|
105
|
+
function exactKeys(value, keys) {
|
|
106
|
+
return plain(value) && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function hasOnlyKeys(value, required, optional = []) {
|
|
110
|
+
if (!plain(value)) return false;
|
|
111
|
+
const keys = Object.keys(value);
|
|
112
|
+
if (!required.every((key) => keys.includes(key))) return false;
|
|
113
|
+
const allowed = new Set([...required, ...optional]);
|
|
114
|
+
return keys.every((key) => allowed.has(key));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sortedUnique(list) { return [...new Set(list)].sort(asciiOrdinalCompare); }
|
|
118
|
+
|
|
119
|
+
function isSortedUniqueArray(value, { allowEmpty = false } = {}) {
|
|
120
|
+
if (!Array.isArray(value)) return false;
|
|
121
|
+
if (value.length === 0) return allowEmpty;
|
|
122
|
+
if (value.some((entry) => typeof entry !== 'string' || entry === '')) return false;
|
|
123
|
+
for (let index = 1; index < value.length; index += 1) {
|
|
124
|
+
if (!(value[index - 1] < value[index])) return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isNonEmptyStringArray(value) {
|
|
130
|
+
return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === 'string' && entry !== '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// git helpers
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function runGit(root, args) {
|
|
138
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
139
|
+
if (result.error || result.status !== 0) {
|
|
140
|
+
const detail = result.error?.message || String(result.stderr || '').trim() || `exit ${result.status}`;
|
|
141
|
+
fail('git_command_failed', `git ${args.join(' ')} failed: ${detail}`);
|
|
142
|
+
}
|
|
143
|
+
return result.stdout;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function runGitBytes(root, args) {
|
|
147
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
|
|
148
|
+
if (result.error || result.status !== 0) {
|
|
149
|
+
const detail = result.error?.message || (Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf8') : '').trim() || `exit ${result.status}`;
|
|
150
|
+
fail('git_command_failed', `git ${args.join(' ')} failed: ${detail}`);
|
|
151
|
+
}
|
|
152
|
+
return result.stdout;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function tryGitBytes(root, args) {
|
|
156
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
|
|
157
|
+
if (result.error || result.status !== 0) return null;
|
|
158
|
+
return result.stdout;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function splitNul(buffer) {
|
|
162
|
+
if (buffer.length === 0) return [];
|
|
163
|
+
const trimmed = buffer[buffer.length - 1] === 0 ? buffer.subarray(0, -1) : buffer;
|
|
164
|
+
return trimmed.length === 0 ? [] : trimmed.toString('utf8').split('\0');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function gitHeadCommit(root) {
|
|
168
|
+
const out = runGit(root, ['rev-parse', 'HEAD']).trim();
|
|
169
|
+
if (!isCommitId(out)) fail('malformed_git_output', 'git rev-parse HEAD did not return a commit id');
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isRealCommit(root, sha) {
|
|
174
|
+
if (!isCommitId(sha)) return false;
|
|
175
|
+
const result = spawnSync('git', ['-C', root, 'cat-file', '-e', `${sha}^{commit}`]);
|
|
176
|
+
return result.status === 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isAncestorOrEqual(root, ancestor, descendant) {
|
|
180
|
+
if (!isRealCommit(root, descendant)) return false;
|
|
181
|
+
if (ancestor === descendant) return isRealCommit(root, ancestor);
|
|
182
|
+
if (!isRealCommit(root, ancestor)) return false;
|
|
183
|
+
const result = spawnSync('git', ['-C', root, 'merge-base', '--is-ancestor', ancestor, descendant]);
|
|
184
|
+
return result.status === 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function gitHeadFileBytes(root, relativePath) {
|
|
188
|
+
return tryGitBytes(root, ['show', `HEAD:${relativePath}`]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function tryParseJson(bytes) {
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// true/false when git can answer definitively; null when the check itself is
|
|
200
|
+
// ambiguous (spawn error, non-zero exit, or an unrecognized answer). Callers
|
|
201
|
+
// must treat null exactly like "shallow" -- fail closed, never silently
|
|
202
|
+
// assume a full clone just because the shallow probe itself misbehaved.
|
|
203
|
+
function isShallowRepository(root) {
|
|
204
|
+
const result = spawnSync('git', ['-C', root, 'rev-parse', '--is-shallow-repository'], { encoding: 'utf8' });
|
|
205
|
+
if (result.error || result.status !== 0) return null;
|
|
206
|
+
const value = result.stdout.trim();
|
|
207
|
+
if (value === 'true') return true;
|
|
208
|
+
if (value === 'false') return false;
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Fail-closed reader for the ordered (oldest -> newest) committed snapshots
|
|
213
|
+
// of relativePath -- the shared foundation both C10 authority and C15 ledger
|
|
214
|
+
// provenance build on. The shallow/ambiguity probe runs *first*, unconditionally
|
|
215
|
+
// -- before any "no history" trust can ever be granted, not merely once a
|
|
216
|
+
// current blob happens to exist -- because a truncated or unanswerable clone
|
|
217
|
+
// can hide real prior history for a path that currently looks absent just as
|
|
218
|
+
// easily as it can hide the true genesis of a path that currently looks
|
|
219
|
+
// present. { ok: true, snapshots: [] } (trusted, matching C10's own case (2),
|
|
220
|
+
// "uncommitted... trusted... without requiring a git commit first") is
|
|
221
|
+
// returned only when a full, non-shallow `git log` query itself *succeeds*
|
|
222
|
+
// and independently proves zero commits ever touched this path, *and* HEAD
|
|
223
|
+
// currently has no blob for it -- both facts must agree. If committed
|
|
224
|
+
// history exists for this path but HEAD's tree currently has no blob for it,
|
|
225
|
+
// that is a committed deletion: no named transition ever deletes or renames
|
|
226
|
+
// this file, so a git-rm'd-then-recommitted (or renamed-away) reserved
|
|
227
|
+
// control-plane path is itself an out-of-band edit, not a fresh "never
|
|
228
|
+
// committed" genesis for whatever gets written in its place -- fail closed.
|
|
229
|
+
// Any other ambiguity -- a failed or non-zero `git log`/`git show`, or a
|
|
230
|
+
// commit whose blob cannot be read -- is reported as { ok: false } rather
|
|
231
|
+
// than silently treated as "nothing committed" or "this is genesis". That
|
|
232
|
+
// silent fallback is exactly how a shallow clone, a transient git failure, or
|
|
233
|
+
// a delete-then-recreate could smuggle a tampered/laundered snapshot past
|
|
234
|
+
// provenance as a false, unprovable trust root.
|
|
235
|
+
function readCommittedSnapshots(root, relativePath) {
|
|
236
|
+
if (isShallowRepository(root) !== false) return { ok: false }; // true or null (ambiguous): fail closed
|
|
237
|
+
const logResult = spawnSync('git', ['-C', root, 'log', '--format=%H', '--', relativePath], { encoding: 'utf8' });
|
|
238
|
+
if (logResult.error || logResult.status !== 0) return { ok: false };
|
|
239
|
+
const commits = logResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean).reverse(); // oldest first
|
|
240
|
+
const headBytes = gitHeadFileBytes(root, relativePath);
|
|
241
|
+
if (commits.length === 0) {
|
|
242
|
+
if (headBytes !== null) return { ok: false }; // HEAD has a blob `git log` never saw: ambiguous, fail closed
|
|
243
|
+
return { ok: true, snapshots: [] }; // full history independently proves this path never appeared: trusted
|
|
244
|
+
}
|
|
245
|
+
if (headBytes === null) return { ok: false }; // committed history exists but HEAD has no blob: committed deletion
|
|
246
|
+
const snapshots = [];
|
|
247
|
+
for (const hash of commits) {
|
|
248
|
+
const bytes = tryGitBytes(root, ['show', `${hash}:${relativePath}`]);
|
|
249
|
+
if (bytes === null) return { ok: false };
|
|
250
|
+
snapshots.push({ hash, bytes });
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, snapshots };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Honest-agent-only provenance (C10/C15, not a defense against a privileged
|
|
256
|
+
// history-rewriting insider). Validates the *entire* committed-then-current
|
|
257
|
+
// chain for a control-plane record against one shared generation rule --
|
|
258
|
+
// every adjacent pair, including every committed-to-committed pair, not
|
|
259
|
+
// merely a last-commit-vs-current hop: a same-generation in-place rewrite
|
|
260
|
+
// that itself got committed (and later buried under unrelated commits) is
|
|
261
|
+
// caught here even though it is no longer the most recent commit, which a
|
|
262
|
+
// check comparing only "current" against "the last commit" could never see
|
|
263
|
+
// once that rewrite stopped being the most recent one.
|
|
264
|
+
//
|
|
265
|
+
// The exact rule (design.md C10, "an out-of-band edit": generation must equal
|
|
266
|
+
// priorGeneration + 1, or the load rejects): once a committed predecessor
|
|
267
|
+
// exists for this path, every content-changing pair -- committed-to-committed
|
|
268
|
+
// or committed-to-current alike -- must advance generation by *exactly* one,
|
|
269
|
+
// and the later record's own previousDigest must hash the immediately prior
|
|
270
|
+
// bytes exactly. There is no generation-jump exemption: a named transition
|
|
271
|
+
// always writes priorGeneration+1 relative to whatever it itself loaded as
|
|
272
|
+
// current, so a real generation gap against the last *committed* snapshot can
|
|
273
|
+
// only mean an intervening uncommitted transition was never independently
|
|
274
|
+
// verified against that committed predecessor before a later one overwrote
|
|
275
|
+
// it -- exactly the gap assertCandidateProvenance (called by every named
|
|
276
|
+
// writer before it mutates anything) exists to close. The only trusted case
|
|
277
|
+
// with no such constraint is case (2) of C10's own load rules: no committed
|
|
278
|
+
// predecessor exists at all yet, so whatever is currently on disk -- however
|
|
279
|
+
// many uncommitted transitions produced it -- is trusted as first-ever state.
|
|
280
|
+
// parseSnapshot turns raw committed bytes into a validated record (or null
|
|
281
|
+
// on a malformed historic snapshot, itself fail-closed); verifyPair, when
|
|
282
|
+
// supplied, checks each caller-owned structural invariant across every
|
|
283
|
+
// adjacent pair (C10 legal state order; C15 append-only event prefix).
|
|
284
|
+
function verifyProvenanceChain(root, relativePath, {
|
|
285
|
+
currentBytes, currentRecord, parseSnapshot, getGeneration, getPreviousDigest, verifyPair, label,
|
|
286
|
+
}) {
|
|
287
|
+
const read = readCommittedSnapshots(root, relativePath);
|
|
288
|
+
if (!read.ok) {
|
|
289
|
+
fail('out_of_band_edit', `${label} committed history could not be independently established (a shallow clone or an unreadable git history)`);
|
|
290
|
+
}
|
|
291
|
+
const chain = read.snapshots.map((snapshot) => {
|
|
292
|
+
const record = parseSnapshot(snapshot.bytes);
|
|
293
|
+
if (!record) fail('out_of_band_edit', `${label} has a committed snapshot that fails its exact schema`);
|
|
294
|
+
return { bytes: snapshot.bytes, record };
|
|
295
|
+
});
|
|
296
|
+
chain.push({ bytes: currentBytes, record: currentRecord });
|
|
297
|
+
|
|
298
|
+
for (let index = 1; index < chain.length; index += 1) {
|
|
299
|
+
const prev = chain[index - 1];
|
|
300
|
+
const curr = chain[index];
|
|
301
|
+
if (prev.bytes.equals(curr.bytes)) continue; // unchanged at this hop: nothing to verify
|
|
302
|
+
const prevGeneration = getGeneration(prev.record);
|
|
303
|
+
const currGeneration = getGeneration(curr.record);
|
|
304
|
+
if (currGeneration !== prevGeneration + 1) {
|
|
305
|
+
fail('out_of_band_edit', `${label} generation does not advance by exactly one from its committed predecessor`);
|
|
306
|
+
}
|
|
307
|
+
if (getPreviousDigest(curr.record) !== sha256Tag(prev.bytes)) {
|
|
308
|
+
fail('out_of_band_edit', `${label} previousDigest does not hash the immediately prior committed bytes`);
|
|
309
|
+
}
|
|
310
|
+
if (verifyPair) verifyPair(prev.record, curr.record);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// root resolution (I13/I19: production calls never take a per-call override)
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
function resolveTrustedRoot(requestedRoot) {
|
|
319
|
+
const rawRoot = requestedRoot ? path.resolve(requestedRoot) : resolveGitRootFromHere();
|
|
320
|
+
const canonical = fs.realpathSync(rawRoot);
|
|
321
|
+
const stat = fs.lstatSync(canonical);
|
|
322
|
+
if (!stat.isDirectory()) fail('malformed_input', 'trusted root must be a directory');
|
|
323
|
+
return canonical;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function resolveGitRootFromHere() {
|
|
327
|
+
const result = spawnSync('git', ['-C', __dirname, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
328
|
+
if (result.error || result.status !== 0) {
|
|
329
|
+
fail('root_resolution_failed', 'cannot resolve the canonical project root via git from this source checkout');
|
|
330
|
+
}
|
|
331
|
+
return result.stdout.trim();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function absPath(root, relativePath) {
|
|
335
|
+
return path.join(root, ...relativePath.split('/'));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// D1 protected-path allowlist
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
function isExcludedPath(relativePath) {
|
|
343
|
+
return relativePath === REL.cafekitReleaseDir.replace(/\/$/, '') || relativePath.startsWith(REL.cafekitReleaseDir);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isReservedPath(relativePath) {
|
|
347
|
+
return RESERVED_PATHS.has(relativePath);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function isProtectedPath(relativePath) {
|
|
351
|
+
if (isExcludedPath(relativePath)) return false;
|
|
352
|
+
if (isReservedPath(relativePath)) return false; // reserved-path exclusion precedes the prefix match (D1)
|
|
353
|
+
if (PROTECTED_FILE_PATHS.has(relativePath)) return true;
|
|
354
|
+
return PROTECTED_DIR_PREFIXES.some((prefix) => relativePath.startsWith(prefix));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// atomic writes
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
function atomicWriteBytes(absoluteTarget, bytes) {
|
|
362
|
+
fs.mkdirSync(path.dirname(absoluteTarget), { recursive: true });
|
|
363
|
+
const tmp = path.join(
|
|
364
|
+
path.dirname(absoluteTarget),
|
|
365
|
+
`.${path.basename(absoluteTarget)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
|
366
|
+
);
|
|
367
|
+
const fd = fs.openSync(tmp, 'wx', 0o644);
|
|
368
|
+
try {
|
|
369
|
+
fs.writeFileSync(fd, bytes);
|
|
370
|
+
fs.fsyncSync(fd);
|
|
371
|
+
} finally {
|
|
372
|
+
fs.closeSync(fd);
|
|
373
|
+
}
|
|
374
|
+
fs.renameSync(tmp, absoluteTarget);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Sole canonical JSON-record byte serialization (D13-adjacent: one shared
|
|
378
|
+
// primitive, never a per-writer reimplementation). Every C10/C15 named
|
|
379
|
+
// writer computes its candidate's bytes through this exact function once,
|
|
380
|
+
// then reuses those same bytes both to validate the candidate's provenance
|
|
381
|
+
// and to perform the atomic write -- there is never a second, independently
|
|
382
|
+
// re-serialized copy that could drift from what actually gets persisted.
|
|
383
|
+
function jsonRecordBytes(value) {
|
|
384
|
+
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function atomicWriteJson(absoluteTarget, value) {
|
|
388
|
+
atomicWriteBytes(absoluteTarget, jsonRecordBytes(value));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function readJsonFileIfExists(root, relativePath) {
|
|
392
|
+
const absolute = absPath(root, relativePath);
|
|
393
|
+
let stat;
|
|
394
|
+
try {
|
|
395
|
+
stat = fs.lstatSync(absolute);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (error.code === 'ENOENT') return null;
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail('malformed_input', `${relativePath} must be a regular non-symlink file`);
|
|
401
|
+
const bytes = fs.readFileSync(absolute);
|
|
402
|
+
let parsed;
|
|
403
|
+
try {
|
|
404
|
+
parsed = JSON.parse(bytes.toString('utf8'));
|
|
405
|
+
} catch (error) {
|
|
406
|
+
fail('malformed_json', `${relativePath} is not valid JSON (${error.message})`);
|
|
407
|
+
}
|
|
408
|
+
return { bytes, parsed };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Tolerant variant for the one place a corrupt-or-missing file is itself an
|
|
412
|
+
// expected, recoverable state rather than a fatal error: advanceBaseline's
|
|
413
|
+
// idempotency check (I11 1a/1b) and resolveFailure's independent receipt read
|
|
414
|
+
// (I13) must be able to distinguish "no usable canonical evidence" (missing,
|
|
415
|
+
// unreadable, or malformed bytes) from a hard error, so they can apply their
|
|
416
|
+
// own specific repair/refuse semantics instead of an uncaught crash.
|
|
417
|
+
function tryReadJsonFile(root, relativePath) {
|
|
418
|
+
try {
|
|
419
|
+
return readJsonFileIfExists(root, relativePath);
|
|
420
|
+
} catch {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// deriveProtectedChangedPaths / computeProtectedTreeDigest (D1, R1.3, R1.5)
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
// No --diff-filter: an allowlist of change statuses (the prior
|
|
430
|
+
// A(dded)/C(opied)/D(eleted)/M(odified)/R(enamed) set) is exactly the kind of
|
|
431
|
+
// brittle enumeration that silently stops covering a real git status it never
|
|
432
|
+
// anticipated -- it omitted T (type-changed, e.g. a protected regular file
|
|
433
|
+
// swapped for a symlink to the same path) and U (unmerged), either of which
|
|
434
|
+
// would let a protected-path change escape D1 detection entirely rather than
|
|
435
|
+
// merely being classified oddly. Every change status git diff can report
|
|
436
|
+
// must surface here; isProtectedPath below still gates which *paths* count.
|
|
437
|
+
function gitChangedPathsRaw(root, baselineCommit, headCommit) {
|
|
438
|
+
const diffArgs = headCommit
|
|
439
|
+
? ['diff', '--name-only', '-z', baselineCommit, headCommit]
|
|
440
|
+
: ['diff', '--name-only', '-z', baselineCommit];
|
|
441
|
+
const tracked = splitNul(runGitBytes(root, diffArgs));
|
|
442
|
+
const untracked = splitNul(runGitBytes(root, ['ls-files', '-z', '--others', '--exclude-standard']));
|
|
443
|
+
return [...tracked, ...untracked];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Self-derives protected changed paths from a git diff (never a caller-supplied
|
|
447
|
+
// changedPaths substitute): baselineCommit vs the working tree for a routine
|
|
448
|
+
// freeze, or baselineCommit..headCommit for the one-time C14 bootstrap diff.
|
|
449
|
+
function deriveProtectedChangedPaths(root, baselineCommit, options = {}) {
|
|
450
|
+
if (!isCommitId(baselineCommit)) fail('malformed_input', 'baselineCommit must be a full git commit object id');
|
|
451
|
+
const headCommit = options.headCommit;
|
|
452
|
+
if (headCommit !== undefined && !isCommitId(headCommit)) fail('malformed_input', 'headCommit must be a full git commit object id');
|
|
453
|
+
const raw = gitChangedPathsRaw(root, baselineCommit, headCommit);
|
|
454
|
+
const protectedPaths = raw.filter((entry) => entry && isProtectedPath(entry));
|
|
455
|
+
return sortedUnique(protectedPaths);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function readWorktreeEntry(root, relativePath) {
|
|
459
|
+
const absolute = absPath(root, relativePath);
|
|
460
|
+
let stat;
|
|
461
|
+
try {
|
|
462
|
+
stat = fs.lstatSync(absolute);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (error.code === 'ENOENT') return { type: 'deleted', mode: '0000', content: Buffer.alloc(0) };
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
if (stat.isSymbolicLink()) return { type: 'symlink', mode: '0000', content: Buffer.from(fs.readlinkSync(absolute), 'utf8') };
|
|
468
|
+
if (stat.isFile()) return { type: 'file', mode: (stat.mode & 0o7777).toString(8).padStart(4, '0'), content: fs.readFileSync(absolute) };
|
|
469
|
+
if (stat.isDirectory()) return { type: 'directory', mode: '0000', content: Buffer.alloc(0) };
|
|
470
|
+
return { type: 'other', mode: '0000', content: Buffer.alloc(0) };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function readGitTreeEntry(root, ref, relativePath) {
|
|
474
|
+
const output = runGit(root, ['ls-tree', ref, '--', relativePath]);
|
|
475
|
+
const line = output.split('\n').find(Boolean);
|
|
476
|
+
if (!line) return { type: 'deleted', mode: '0000', content: Buffer.alloc(0) };
|
|
477
|
+
const match = line.match(/^([0-7]+) (blob|tree|commit) ([0-9a-f]+)\t/);
|
|
478
|
+
if (!match) fail('malformed_git_output', 'git ls-tree emitted an unrecognized entry');
|
|
479
|
+
const [, gitMode, objectType, sha] = match;
|
|
480
|
+
if (objectType !== 'blob') return { type: 'other', mode: '0000', content: Buffer.alloc(0) };
|
|
481
|
+
const isSymlink = gitMode === '120000';
|
|
482
|
+
const content = runGitBytes(root, ['cat-file', 'blob', sha]);
|
|
483
|
+
return { type: isSymlink ? 'symlink' : 'file', mode: isSymlink ? '0000' : gitMode.slice(-4), content };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Hashes sorted protected paths with type, mode, and content or a delete
|
|
487
|
+
// sentinel (R1.5). With options.ref, reads each path from that git ref
|
|
488
|
+
// (bootstrap-time, over a committed baseCommit); otherwise reads the current
|
|
489
|
+
// working tree (routine freeze-time, over the proposed candidate state).
|
|
490
|
+
function computeProtectedTreeDigest(root, protectedPaths, options = {}) {
|
|
491
|
+
const sorted = sortedUnique(protectedPaths);
|
|
492
|
+
const hash = crypto.createHash('sha256');
|
|
493
|
+
hash.update('cafekit-protected-tree-v1\n');
|
|
494
|
+
for (const relativePath of sorted) {
|
|
495
|
+
const entry = options.ref ? readGitTreeEntry(root, options.ref, relativePath) : readWorktreeEntry(root, relativePath);
|
|
496
|
+
hash.update(`${relativePath}\n${entry.type}\n${entry.mode}\n`);
|
|
497
|
+
hash.update(entry.type === 'deleted' ? Buffer.from('DELETED') : entry.content);
|
|
498
|
+
hash.update('\n');
|
|
499
|
+
}
|
|
500
|
+
return `sha256:${hash.digest('hex')}`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function computeProposalDigest(proposal) {
|
|
504
|
+
if (proposal === null || proposal === undefined) return NO_CHANGE_ATTESTATION_DIGEST;
|
|
505
|
+
return sha256Tag(Buffer.from(stableJson(proposal), 'utf8'));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function computeCandidateDigest(fields) {
|
|
509
|
+
if (!plain(fields)) fail('malformed_input', 'candidate digest fields must be an object');
|
|
510
|
+
const missing = CANDIDATE_DIGEST_FIELDS.filter((key) => !Object.prototype.hasOwnProperty.call(fields, key));
|
|
511
|
+
if (missing.length) fail('malformed_input', `candidate digest missing fields: ${missing.join(', ')}`);
|
|
512
|
+
const extra = Object.keys(fields).filter((key) => !CANDIDATE_DIGEST_FIELDS.includes(key));
|
|
513
|
+
if (extra.length) fail('malformed_input', `candidate digest received unexpected fields: ${extra.join(', ')}`);
|
|
514
|
+
const bound = {};
|
|
515
|
+
for (const key of CANDIDATE_DIGEST_FIELDS) bound[key] = fields[key];
|
|
516
|
+
return sha256Tag(Buffer.from(stableJson(bound), 'utf8'));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
// C10 ReleaseBaselineAuthority
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
function validateBootstrapRecordShape(record) {
|
|
524
|
+
if (!exactKeys(record, ['state', 'generation', 'baselineCommit', 'baselinePackageVersion', 'bootstrapReviewDigest', 'previousBaselineDigest'])) return false;
|
|
525
|
+
if (record.state !== 'bootstrap') return false;
|
|
526
|
+
if (record.generation !== 0) return false;
|
|
527
|
+
if (!isCommitId(record.baselineCommit)) return false;
|
|
528
|
+
if (typeof record.baselinePackageVersion !== 'string' || !record.baselinePackageVersion) return false;
|
|
529
|
+
if (!isSha256Tag(record.bootstrapReviewDigest)) return false;
|
|
530
|
+
if (record.previousBaselineDigest !== null) return false;
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function validateReleasedRecordShape(record) {
|
|
535
|
+
if (!plain(record) || record.state !== 'released') return false;
|
|
536
|
+
const hasCandidate = Object.prototype.hasOwnProperty.call(record, 'releasedCandidateDigest');
|
|
537
|
+
const hasReceipt = Object.prototype.hasOwnProperty.call(record, 'releaseReceiptDigest');
|
|
538
|
+
if (hasCandidate === hasReceipt) return false; // exactly one required
|
|
539
|
+
const expected = [
|
|
540
|
+
'state', 'generation', 'baselineCommit', 'baselinePackageVersion', 'previousBaselineDigest',
|
|
541
|
+
hasCandidate ? 'releasedCandidateDigest' : 'releaseReceiptDigest',
|
|
542
|
+
];
|
|
543
|
+
if (!exactKeys(record, expected)) return false;
|
|
544
|
+
if (!Number.isInteger(record.generation) || record.generation < 1) return false;
|
|
545
|
+
if (!isCommitId(record.baselineCommit)) return false;
|
|
546
|
+
if (typeof record.baselinePackageVersion !== 'string' || !record.baselinePackageVersion) return false;
|
|
547
|
+
if (!isSha256Tag(record.previousBaselineDigest)) return false;
|
|
548
|
+
if (hasCandidate && !isSha256Tag(record.releasedCandidateDigest)) return false;
|
|
549
|
+
if (hasReceipt && !isSha256Tag(record.releaseReceiptDigest)) return false;
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function loadBaselineAuthority(root) {
|
|
554
|
+
const loaded = readJsonFileIfExists(root, REL.releaseBaseline);
|
|
555
|
+
if (!loaded) return { state: 'absent' };
|
|
556
|
+
const { bytes, parsed } = loaded;
|
|
557
|
+
if (!plain(parsed) || typeof parsed.state !== 'string') fail('malformed_authority', 'release-baseline.json is not a recognized C10 record');
|
|
558
|
+
if (parsed.state === 'bootstrap') {
|
|
559
|
+
if (!validateBootstrapRecordShape(parsed)) fail('malformed_authority', 'release-baseline.json bootstrap record fails the exact C10 schema');
|
|
560
|
+
return { state: 'bootstrap', record: parsed, bytes, digest: sha256Tag(bytes) };
|
|
561
|
+
}
|
|
562
|
+
if (parsed.state === 'released') {
|
|
563
|
+
if (!validateReleasedRecordShape(parsed)) fail('malformed_authority', 'release-baseline.json released record fails the exact C10 schema');
|
|
564
|
+
return { state: 'released', record: parsed, bytes, digest: sha256Tag(bytes) };
|
|
565
|
+
}
|
|
566
|
+
fail('malformed_authority', 'release-baseline.json state must be bootstrap or released');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Turns raw committed bytes into a validated C10 record (or null on a
|
|
570
|
+
// malformed/unrecognized historic snapshot) -- reuses the exact same shape
|
|
571
|
+
// validators loadBaselineAuthority applies to the live file, so a historic
|
|
572
|
+
// commit is held to the identical schema, never a looser "unknown" reading.
|
|
573
|
+
function parseBaselineSnapshot(bytes) {
|
|
574
|
+
const parsed = tryParseJson(bytes);
|
|
575
|
+
if (!plain(parsed) || typeof parsed.state !== 'string') return null;
|
|
576
|
+
if (parsed.state === 'bootstrap') return validateBootstrapRecordShape(parsed) ? parsed : null;
|
|
577
|
+
if (parsed.state === 'released') return validateReleasedRecordShape(parsed) ? parsed : null;
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// C10's own legal transition: bootstrap may become released (advanceBaseline,
|
|
582
|
+
// once); released may only ever advance to another released generation.
|
|
583
|
+
// released regressing back to bootstrap is never legitimate -- explicit here
|
|
584
|
+
// even though the schema already makes it unreachable in practice (bootstrap
|
|
585
|
+
// is always generation 0, so a released->bootstrap pair is already caught by
|
|
586
|
+
// verifyProvenanceChain's own generation-must-advance rule first) -- keeping
|
|
587
|
+
// the rule named and checked directly, not merely an emergent side effect.
|
|
588
|
+
function verifyBaselineStateOrder(prevRecord, currRecord) {
|
|
589
|
+
if (prevRecord.state === 'released' && currRecord.state === 'bootstrap') {
|
|
590
|
+
fail('out_of_band_edit', 'release-baseline.json regresses from released back to bootstrap');
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Shared per-call options for every release-baseline.json provenance check
|
|
595
|
+
// (live-state validation and pre-write candidate validation alike) -- one
|
|
596
|
+
// definition, so C10's parse/generation/digest/state-order rules can never
|
|
597
|
+
// drift between the two call sites.
|
|
598
|
+
const BASELINE_PROVENANCE_OPTIONS = {
|
|
599
|
+
parseSnapshot: parseBaselineSnapshot,
|
|
600
|
+
getGeneration: (record) => record.generation,
|
|
601
|
+
getPreviousDigest: (record) => record.previousBaselineDigest,
|
|
602
|
+
verifyPair: verifyBaselineStateOrder,
|
|
603
|
+
label: 'release-baseline.json',
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// Honest-agent-only provenance (C10, not a defense against a privileged
|
|
607
|
+
// history-rewriting insider): the shared verifyProvenanceChain walks every
|
|
608
|
+
// committed snapshot of release-baseline.json plus the live (possibly
|
|
609
|
+
// uncommitted) current record, requiring each path-changing hop to move
|
|
610
|
+
// generation by *exactly* one and, on that hop, to hash the immediately
|
|
611
|
+
// prior committed bytes exactly -- catching a same-generation in-place
|
|
612
|
+
// rewrite (bootstrap or released alike) even once it is no longer the most
|
|
613
|
+
// recent commit. Case (2) is the only exemption: no committed predecessor
|
|
614
|
+
// exists at all yet, so whatever is currently on disk is trusted as
|
|
615
|
+
// first-ever state without requiring a git commit first.
|
|
616
|
+
function verifyAuthorityProvenance(root, loaded) {
|
|
617
|
+
verifyProvenanceChain(root, REL.releaseBaseline, {
|
|
618
|
+
...BASELINE_PROVENANCE_OPTIONS,
|
|
619
|
+
currentBytes: loaded.bytes,
|
|
620
|
+
currentRecord: loaded.record,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Validates a prospective (not-yet-written) C10 candidate against the exact
|
|
625
|
+
// same rule, before any mutation happens: a second uncommitted named
|
|
626
|
+
// transition relative to the same committed predecessor jumps generation by
|
|
627
|
+
// more than one and is rejected *here*, leaving the file's bytes exactly as
|
|
628
|
+
// the last valid transition left them, rather than writing a state that only
|
|
629
|
+
// the next load would reject. Returns the candidate's serialized bytes (via
|
|
630
|
+
// the sole canonical jsonRecordBytes) so the caller reuses them verbatim for
|
|
631
|
+
// the atomic write -- no second, independent serialization.
|
|
632
|
+
function verifyBaselineCandidate(root, candidateRecord) {
|
|
633
|
+
const candidateBytes = jsonRecordBytes(candidateRecord);
|
|
634
|
+
verifyProvenanceChain(root, REL.releaseBaseline, {
|
|
635
|
+
...BASELINE_PROVENANCE_OPTIONS,
|
|
636
|
+
currentBytes: candidateBytes,
|
|
637
|
+
currentRecord: candidateRecord,
|
|
638
|
+
});
|
|
639
|
+
return candidateBytes;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// R1.3 (design.md Errors and Recovery: "Caller base override | exit 2 | no
|
|
643
|
+
// accept"): no function in this module ever accepts a baselineCommit/root
|
|
644
|
+
// override from a caller (I13/I19), so the only remaining candidate surface
|
|
645
|
+
// is environment configuration. This is a closed, exactly-enumerated
|
|
646
|
+
// reserved-key boundary -- never an open-ended name-shape scan over
|
|
647
|
+
// process.env. An environment variable this runtime never reads cannot
|
|
648
|
+
// influence baseline authority and is therefore not an override attempt by
|
|
649
|
+
// definition: guessing at arbitrary names via a BASE/BASELINE-shaped token
|
|
650
|
+
// pattern is exactly the false-positive/false-negative tradeoff this
|
|
651
|
+
// enumeration removes (a prior token-matching version false-positived on
|
|
652
|
+
// ambient vars such as GITHUB_BASE_REF/GITHUB_BASE_SHA that GitHub Actions
|
|
653
|
+
// sets on every pull_request run, and was never provably exhaustive against
|
|
654
|
+
// a genuine override name it does not anticipate either). The six keys below
|
|
655
|
+
// are the entire observable channel; presence is checked by exact key name
|
|
656
|
+
// only (Object.prototype.hasOwnProperty on process.env), never by value --
|
|
657
|
+
// a value is never read or logged. No other environment variable name --
|
|
658
|
+
// however BASE/BASELINE-shaped, and regardless of any third-party or ambient
|
|
659
|
+
// prefix (GITHUB_BASE_REF, OVERRIDE_BASE_COMMIT, FORCE_BASELINE_REF,
|
|
660
|
+
// BASECOMMIT, RELEASE_BASE_URL, AUTHORITY_BASE_URL, DATABASE_URL,
|
|
661
|
+
// RELEASE_NOTES_PATH, etc.) -- is ever a supported override channel: none of
|
|
662
|
+
// them are read, and none influence authority, so a reviewer must not read
|
|
663
|
+
// any of these names as a channel this boundary recognizes.
|
|
664
|
+
const BASE_OVERRIDE_ENV_KEYS = Object.freeze(new Set([
|
|
665
|
+
'CAFEKIT_BASE_COMMIT',
|
|
666
|
+
'CAFEKIT_BASELINE_COMMIT',
|
|
667
|
+
'CAFEKIT_BASE_REF',
|
|
668
|
+
'CAFEKIT_BASELINE_REF',
|
|
669
|
+
'CAFEKIT_BASE_PATH',
|
|
670
|
+
'CAFEKIT_BASELINE_PATH',
|
|
671
|
+
]));
|
|
672
|
+
|
|
673
|
+
function assertNoBaseOverrideEnv() {
|
|
674
|
+
for (const name of BASE_OVERRIDE_ENV_KEYS) {
|
|
675
|
+
if (Object.prototype.hasOwnProperty.call(process.env, name)) {
|
|
676
|
+
fail('caller_base_override', `environment variable ${name} is a reserved baseline override channel; only tracked release-baseline.json is ever loaded (R1.3)`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function assertBaselineUsable(root, options = {}) {
|
|
682
|
+
assertNoBaseOverrideEnv();
|
|
683
|
+
const loaded = loadBaselineAuthority(root);
|
|
684
|
+
if (loaded.state === 'absent') {
|
|
685
|
+
if (options.allowAbsent) return loaded;
|
|
686
|
+
fail('authority_absent', 'release-baseline.json does not exist; only bootstrapBaseline may create it');
|
|
687
|
+
}
|
|
688
|
+
verifyAuthorityProvenance(root, loaded);
|
|
689
|
+
return loaded;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
// D11 legacy-bridge artifact precondition (read-only; R0-01 never writes it)
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
const LEGACY_BRIDGE_KEYS = ['schema_version', 'verdict', 'blocking_count', 'reviewed_criteria', 'semantic_digest', 'bootWindowBaseCommit', 'written_at'];
|
|
697
|
+
|
|
698
|
+
function readLegacyBridgeArtifact(root) {
|
|
699
|
+
const loaded = readJsonFileIfExists(root, REL.legacyBridge);
|
|
700
|
+
if (!loaded) fail('legacy_bridge_missing', 'bootstrap-legacy-bridge-review.json does not exist');
|
|
701
|
+
const { parsed } = loaded;
|
|
702
|
+
if (!exactKeys(parsed, LEGACY_BRIDGE_KEYS)) fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json has an unexpected key set');
|
|
703
|
+
if (parsed.schema_version !== '1') fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json schema_version must be "1"');
|
|
704
|
+
if (parsed.verdict !== 'PASS') fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json verdict must be PASS');
|
|
705
|
+
if (parsed.blocking_count !== 0) fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json blocking_count must be 0');
|
|
706
|
+
if (!Array.isArray(parsed.reviewed_criteria) || parsed.reviewed_criteria.length === 0) {
|
|
707
|
+
fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json reviewed_criteria must be a non-empty array');
|
|
708
|
+
}
|
|
709
|
+
if (!isSha256Tag(parsed.semantic_digest)) fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json semantic_digest must be sha256');
|
|
710
|
+
if (!isCommitId(parsed.bootWindowBaseCommit)) fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json bootWindowBaseCommit must be a full commit id');
|
|
711
|
+
if (typeof parsed.written_at !== 'string' || !parsed.written_at) fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json written_at must be a non-empty string');
|
|
712
|
+
return parsed;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
// C14 BootstrapAttestation / bootstrapBaseline
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
const REL_FEATURE_SPEC = 'specs/cafekit-semantic-eval-firewall/spec.json';
|
|
720
|
+
|
|
721
|
+
function readFeatureSpecJson(root) {
|
|
722
|
+
const loaded = readJsonFileIfExists(root, REL_FEATURE_SPEC);
|
|
723
|
+
if (!loaded) fail('spec_missing', 'this feature spec.json does not exist');
|
|
724
|
+
return loaded.parsed;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function validateAttestationShape(attestation) {
|
|
728
|
+
return exactKeys(attestation, ['baseCommit', 'headCommit', 'bootstrapReviewDigest', 'packageVersion'])
|
|
729
|
+
&& isCommitId(attestation.baseCommit)
|
|
730
|
+
&& isCommitId(attestation.headCommit)
|
|
731
|
+
&& isSha256Tag(attestation.bootstrapReviewDigest)
|
|
732
|
+
&& typeof attestation.packageVersion === 'string' && attestation.packageVersion !== '';
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function readSeedPassEntry(spec) {
|
|
736
|
+
const history = spec && spec.validation && spec.validation.semantic_review_history;
|
|
737
|
+
if (!plain(history) || !Array.isArray(history.entries)) return null;
|
|
738
|
+
return history.entries.find((entry) => entry && entry.sequence === 0 && entry.review_epoch === 0 && entry.verdict === 'PASS') || null;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// C14: one-time, caller-supplied input; never persisted verbatim. No
|
|
742
|
+
// treeDigest field (baseCommit already pins the tree). Refuses to run before
|
|
743
|
+
// the seed semantic_review_history entry (sequence 0/review_epoch 0) exists,
|
|
744
|
+
// and verifies baseCommit against the legacy-bridge artifact's own durably
|
|
745
|
+
// captured bootWindowBaseCommit field rather than a merge-base guess.
|
|
746
|
+
function bootstrapBaseline(root, attestation) {
|
|
747
|
+
if (!validateAttestationShape(attestation)) fail('malformed_input', 'C14 BootstrapAttestation fails its exact schema');
|
|
748
|
+
const existing = loadBaselineAuthority(root);
|
|
749
|
+
if (existing.state !== 'absent') fail('authority_exists', 'release-baseline.json already exists; bootstrapBaseline only creates the first record');
|
|
750
|
+
|
|
751
|
+
const bridge = readLegacyBridgeArtifact(root);
|
|
752
|
+
if (attestation.baseCommit !== bridge.bootWindowBaseCommit) {
|
|
753
|
+
fail('base_commit_mismatch', 'C14 baseCommit does not equal the legacy-bridge artifact bootWindowBaseCommit');
|
|
754
|
+
}
|
|
755
|
+
if (!isRealCommit(root, attestation.baseCommit)) fail('malformed_input', 'C14 baseCommit is not a real git commit object');
|
|
756
|
+
const headCommit = gitHeadCommit(root);
|
|
757
|
+
if (attestation.headCommit !== headCommit) fail('stale_attestation', 'C14 headCommit does not equal the actual current HEAD');
|
|
758
|
+
if (!isAncestorOrEqual(root, attestation.baseCommit, attestation.headCommit)) {
|
|
759
|
+
fail('malformed_input', 'C14 baseCommit is not an ancestor of (or equal to) headCommit');
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const spec = readFeatureSpecJson(root);
|
|
763
|
+
const seedEntry = readSeedPassEntry(spec);
|
|
764
|
+
if (!seedEntry) fail('seed_pass_missing', 'bootstrapBaseline cannot run before the seed semantic_review_history entry exists');
|
|
765
|
+
if (attestation.bootstrapReviewDigest !== seedEntry.review_receipt_digest) {
|
|
766
|
+
fail('stale_attestation', 'C14 bootstrapReviewDigest does not match the seed PASS entry review_receipt_digest');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const protectedPaths = deriveProtectedChangedPaths(root, attestation.baseCommit, { headCommit: attestation.headCommit });
|
|
770
|
+
if (protectedPaths.length === 0) fail('empty_bootstrap_diff', 'bootstrap requires a real independent-PASS digest over a non-empty protected change set');
|
|
771
|
+
|
|
772
|
+
const record = {
|
|
773
|
+
state: 'bootstrap',
|
|
774
|
+
generation: 0,
|
|
775
|
+
baselineCommit: attestation.baseCommit,
|
|
776
|
+
baselinePackageVersion: attestation.packageVersion,
|
|
777
|
+
bootstrapReviewDigest: attestation.bootstrapReviewDigest,
|
|
778
|
+
previousBaselineDigest: null,
|
|
779
|
+
};
|
|
780
|
+
const bytes = verifyBaselineCandidate(root, record);
|
|
781
|
+
atomicWriteBytes(absPath(root, REL.releaseBaseline), bytes);
|
|
782
|
+
return { record, protectedPaths };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
// C15 BenchmarkFailureLedger
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
function validateOpenedEvent(event) {
|
|
790
|
+
return exactKeys(event, ['type', 'failure_id', 'paths', 'candidate_digest', 'evidence_digest', 'occurred_at'])
|
|
791
|
+
&& event.type === 'opened'
|
|
792
|
+
&& typeof event.failure_id === 'string' && event.failure_id !== ''
|
|
793
|
+
&& isSortedUniqueArray(event.paths)
|
|
794
|
+
&& isSha256Tag(event.candidate_digest)
|
|
795
|
+
&& isSha256Tag(event.evidence_digest)
|
|
796
|
+
&& typeof event.occurred_at === 'string' && event.occurred_at !== '';
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function validateClassifiedEvent(event) {
|
|
800
|
+
return exactKeys(event, ['type', 'failure_id', 'primary_failure_class', 'adjudication_digest', 'occurred_at'])
|
|
801
|
+
&& event.type === 'classified'
|
|
802
|
+
&& typeof event.failure_id === 'string' && event.failure_id !== ''
|
|
803
|
+
&& FAILURE_CLASSES.includes(event.primary_failure_class)
|
|
804
|
+
&& isSha256Tag(event.adjudication_digest)
|
|
805
|
+
&& typeof event.occurred_at === 'string' && event.occurred_at !== '';
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function validateReclassifiedEvent(event) {
|
|
809
|
+
return exactKeys(event, ['type', 'failure_id', 'previous_primary_failure_class', 'primary_failure_class', 'adjudication_digest', 'occurred_at'])
|
|
810
|
+
&& event.type === 'reclassified'
|
|
811
|
+
&& typeof event.failure_id === 'string' && event.failure_id !== ''
|
|
812
|
+
&& FAILURE_CLASSES.includes(event.previous_primary_failure_class)
|
|
813
|
+
&& FAILURE_CLASSES.includes(event.primary_failure_class)
|
|
814
|
+
&& isSha256Tag(event.adjudication_digest)
|
|
815
|
+
&& typeof event.occurred_at === 'string' && event.occurred_at !== '';
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function validateResolvedEvent(event) {
|
|
819
|
+
return exactKeys(event, ['type', 'failure_id', 'decision_digest', 'release_receipt_digest', 'occurred_at'])
|
|
820
|
+
&& event.type === 'resolved'
|
|
821
|
+
&& typeof event.failure_id === 'string' && event.failure_id !== ''
|
|
822
|
+
&& isSha256Tag(event.decision_digest)
|
|
823
|
+
&& isSha256Tag(event.release_receipt_digest)
|
|
824
|
+
&& typeof event.occurred_at === 'string' && event.occurred_at !== '';
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function validateLedgerEvent(event) {
|
|
828
|
+
if (!plain(event) || typeof event.type !== 'string') return false;
|
|
829
|
+
if (event.type === 'opened') return validateOpenedEvent(event);
|
|
830
|
+
if (event.type === 'classified') return validateClassifiedEvent(event);
|
|
831
|
+
if (event.type === 'reclassified') return validateReclassifiedEvent(event);
|
|
832
|
+
if (event.type === 'resolved') return validateResolvedEvent(event);
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Pure shape validator (no throwing) so it can be reused both by the live
|
|
837
|
+
// loader below and by parseLedgerSnapshot for a raw historic commit. Named
|
|
838
|
+
// transitions (openFailure/classifyFailure/reclassifyFailure/resolveFailure)
|
|
839
|
+
// each append exactly one event per generation (nextLedgerRecord), so
|
|
840
|
+
// events.length === generation + 1 is a structural fact of every valid
|
|
841
|
+
// record, current or historic -- not merely something to check pairwise.
|
|
842
|
+
function validateLedgerRecordShape(parsed) {
|
|
843
|
+
if (!exactKeys(parsed, ['schema_version', 'generation', 'previousLedgerDigest', 'events'])) return false;
|
|
844
|
+
if (parsed.schema_version !== '1') return false;
|
|
845
|
+
if (!Number.isInteger(parsed.generation) || parsed.generation < 0) return false;
|
|
846
|
+
if (parsed.generation === 0 && parsed.previousLedgerDigest !== null) return false;
|
|
847
|
+
if (parsed.generation > 0 && !isSha256Tag(parsed.previousLedgerDigest)) return false;
|
|
848
|
+
if (!Array.isArray(parsed.events) || parsed.events.length !== parsed.generation + 1) return false;
|
|
849
|
+
const seenOpened = new Set();
|
|
850
|
+
for (const event of parsed.events) {
|
|
851
|
+
if (!validateLedgerEvent(event)) return false;
|
|
852
|
+
if (event.type === 'opened') {
|
|
853
|
+
if (seenOpened.has(event.failure_id)) return false;
|
|
854
|
+
seenOpened.add(event.failure_id);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function loadFailureLedger(root) {
|
|
861
|
+
const loaded = readJsonFileIfExists(root, REL.ledger);
|
|
862
|
+
if (!loaded) return { state: 'absent' };
|
|
863
|
+
const { bytes, parsed } = loaded;
|
|
864
|
+
if (!validateLedgerRecordShape(parsed)) fail('malformed_ledger', 'benchmark-failure-ledger.json fails the exact C15 schema');
|
|
865
|
+
return { state: 'present', record: parsed, bytes, digest: sha256Tag(bytes) };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Turns raw committed bytes into a validated C15 record (or null on a
|
|
869
|
+
// malformed/unrecognized historic snapshot), reusing the exact same shape
|
|
870
|
+
// validator loadFailureLedger applies to the live file.
|
|
871
|
+
function parseLedgerSnapshot(bytes) {
|
|
872
|
+
const parsed = tryParseJson(bytes);
|
|
873
|
+
return validateLedgerRecordShape(parsed) ? parsed : null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Append-only, generation-per-event invariant (C15): a committed transition
|
|
877
|
+
// must strictly extend the events array, and every event the prior committed
|
|
878
|
+
// snapshot already recorded must reappear byte-for-byte, in the same order,
|
|
879
|
+
// in the later one -- never rewritten, reordered, or dropped. Since named
|
|
880
|
+
// transitions each add exactly one event per generation and generation must
|
|
881
|
+
// now advance by exactly one (no jump exemption), curr.events.length is
|
|
882
|
+
// always exactly prev.events.length + 1 by construction; the explicit
|
|
883
|
+
// length/prefix checks below stay as direct, named defense-in-depth rather
|
|
884
|
+
// than an implicit side effect of the generation rule alone.
|
|
885
|
+
function verifyLedgerEventPrefix(prevRecord, currRecord) {
|
|
886
|
+
const prevEvents = prevRecord.events;
|
|
887
|
+
const currEvents = currRecord.events;
|
|
888
|
+
if (currEvents.length <= prevEvents.length) {
|
|
889
|
+
fail('out_of_band_edit', 'benchmark-failure-ledger.json committed transition does not append new events');
|
|
890
|
+
}
|
|
891
|
+
for (let index = 0; index < prevEvents.length; index += 1) {
|
|
892
|
+
if (JSON.stringify(prevEvents[index]) !== JSON.stringify(currEvents[index])) {
|
|
893
|
+
fail('out_of_band_edit', 'benchmark-failure-ledger.json rewrites a previously committed event instead of only appending');
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Shared per-call options for every benchmark-failure-ledger.json provenance
|
|
899
|
+
// check (live-state validation and pre-write candidate validation alike).
|
|
900
|
+
const LEDGER_PROVENANCE_OPTIONS = {
|
|
901
|
+
parseSnapshot: parseLedgerSnapshot,
|
|
902
|
+
getGeneration: (record) => record.generation,
|
|
903
|
+
getPreviousDigest: (record) => record.previousLedgerDigest,
|
|
904
|
+
verifyPair: verifyLedgerEventPrefix,
|
|
905
|
+
label: 'benchmark-failure-ledger.json',
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
// Same honest-agent-only chain-of-custody rule as C10 (verifyAuthorityProvenance),
|
|
909
|
+
// via the shared verifyProvenanceChain: every committed snapshot plus the
|
|
910
|
+
// live (possibly uncommitted) current record must move generation by exactly
|
|
911
|
+
// one with the matching digest check, and each committed transition must
|
|
912
|
+
// append-only extend the prior committed events array.
|
|
913
|
+
function verifyLedgerProvenance(root, loaded) {
|
|
914
|
+
verifyProvenanceChain(root, REL.ledger, {
|
|
915
|
+
...LEDGER_PROVENANCE_OPTIONS,
|
|
916
|
+
currentBytes: loaded.bytes,
|
|
917
|
+
currentRecord: loaded.record,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Validates a prospective (not-yet-written) C15 candidate against the exact
|
|
922
|
+
// same rule before any mutation: a second uncommitted named transition
|
|
923
|
+
// relative to the same committed predecessor is rejected here, before the
|
|
924
|
+
// ledger's on-disk bytes are ever overwritten. Returns the candidate's
|
|
925
|
+
// serialized bytes so the caller reuses them verbatim for the atomic write.
|
|
926
|
+
function verifyLedgerCandidate(root, candidateRecord) {
|
|
927
|
+
const candidateBytes = jsonRecordBytes(candidateRecord);
|
|
928
|
+
verifyProvenanceChain(root, REL.ledger, {
|
|
929
|
+
...LEDGER_PROVENANCE_OPTIONS,
|
|
930
|
+
currentBytes: candidateBytes,
|
|
931
|
+
currentRecord: candidateRecord,
|
|
932
|
+
});
|
|
933
|
+
return candidateBytes;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function assertFailureLedgerUsable(root) {
|
|
937
|
+
const loaded = loadFailureLedger(root);
|
|
938
|
+
if (loaded.state === 'present') verifyLedgerProvenance(root, loaded);
|
|
939
|
+
return loaded;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function computeFailureLedgerDigest(root) {
|
|
943
|
+
const loaded = loadFailureLedger(root);
|
|
944
|
+
return loaded.state === 'present' ? loaded.digest : NO_FAILURES_ATTESTATION_DIGEST;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Folds a failure_id's events: the most recent classification-type event
|
|
948
|
+
// (classified or reclassified) determines the current class (I18); blocking
|
|
949
|
+
// while opened-only or classified framework_regression and unresolved.
|
|
950
|
+
function deriveFailureState(ledgerLoaded, failureId) {
|
|
951
|
+
const events = ledgerLoaded && ledgerLoaded.state === 'present' ? ledgerLoaded.record.events : [];
|
|
952
|
+
const relevant = events.filter((event) => event.failure_id === failureId);
|
|
953
|
+
const opened = relevant.find((event) => event.type === 'opened');
|
|
954
|
+
if (!opened) return { exists: false };
|
|
955
|
+
let currentClass = null;
|
|
956
|
+
let resolved = false;
|
|
957
|
+
let resolvedEvent = null;
|
|
958
|
+
for (const event of relevant) {
|
|
959
|
+
if (event.type === 'classified' || event.type === 'reclassified') currentClass = event.primary_failure_class;
|
|
960
|
+
if (event.type === 'resolved') { resolved = true; resolvedEvent = event; }
|
|
961
|
+
}
|
|
962
|
+
const blocking = !resolved && (currentClass === null || currentClass === 'framework_regression');
|
|
963
|
+
return { exists: true, openedPaths: opened.paths, currentClass, resolved, resolvedEvent, blocking };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function allOpenFailureIds(ledgerLoaded) {
|
|
967
|
+
if (!ledgerLoaded || ledgerLoaded.state !== 'present') return [];
|
|
968
|
+
const ids = new Set();
|
|
969
|
+
for (const event of ledgerLoaded.record.events) if (event.type === 'opened') ids.add(event.failure_id);
|
|
970
|
+
return [...ids];
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Builds the candidate for a named C15 transition and validates it against
|
|
974
|
+
// the committed predecessor *before* returning -- callers write the returned
|
|
975
|
+
// bytes verbatim; nothing here ever mutates release-baseline.json/the
|
|
976
|
+
// ledger's own on-disk bytes itself before that validation passes.
|
|
977
|
+
function nextLedgerRecord(root, buildEvent) {
|
|
978
|
+
const loaded = assertFailureLedgerUsable(root);
|
|
979
|
+
const events = loaded.state === 'present' ? [...loaded.record.events] : [];
|
|
980
|
+
const generation = loaded.state === 'present' ? loaded.record.generation + 1 : 0;
|
|
981
|
+
const previousLedgerDigest = loaded.state === 'present' ? loaded.digest : null;
|
|
982
|
+
const newEvent = buildEvent(events, loaded);
|
|
983
|
+
events.push(newEvent);
|
|
984
|
+
const record = { schema_version: '1', generation, previousLedgerDigest, events };
|
|
985
|
+
const bytes = verifyLedgerCandidate(root, record);
|
|
986
|
+
return { record, bytes };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function openFailure(root, input) {
|
|
990
|
+
if (!plain(input)) fail('malformed_input', 'openFailure input must be an object');
|
|
991
|
+
const { failure_id: failureId, paths, candidate_digest: candidateDigest, evidence_digest: evidenceDigest, occurred_at: occurredAt } = input;
|
|
992
|
+
if (typeof failureId !== 'string' || !failureId) fail('malformed_input', 'openFailure requires a non-empty failure_id');
|
|
993
|
+
if (!isSortedUniqueArray(paths)) fail('malformed_input', 'openFailure requires sorted-unique non-empty paths');
|
|
994
|
+
if (!isSha256Tag(candidateDigest)) fail('malformed_input', 'openFailure requires a valid candidate_digest');
|
|
995
|
+
if (!isSha256Tag(evidenceDigest)) fail('malformed_input', 'openFailure requires a valid evidence_digest');
|
|
996
|
+
if (typeof occurredAt !== 'string' || !occurredAt) fail('malformed_input', 'openFailure requires occurred_at');
|
|
997
|
+
const { record, bytes } = nextLedgerRecord(root, (events) => {
|
|
998
|
+
if (events.some((event) => event.type === 'opened' && event.failure_id === failureId)) {
|
|
999
|
+
fail('duplicate_failure_id', `failure_id ${failureId} already opened`);
|
|
1000
|
+
}
|
|
1001
|
+
return { type: 'opened', failure_id: failureId, paths: [...paths], candidate_digest: candidateDigest, evidence_digest: evidenceDigest, occurred_at: occurredAt };
|
|
1002
|
+
});
|
|
1003
|
+
atomicWriteBytes(absPath(root, REL.ledger), bytes);
|
|
1004
|
+
return record;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function classifyFailure(root, input) {
|
|
1008
|
+
if (!plain(input)) fail('malformed_input', 'classifyFailure input must be an object');
|
|
1009
|
+
const { failure_id: failureId, primary_failure_class: primaryClass, adjudication_digest: adjudicationDigest, occurred_at: occurredAt } = input;
|
|
1010
|
+
if (typeof failureId !== 'string' || !failureId) fail('malformed_input', 'classifyFailure requires failure_id');
|
|
1011
|
+
if (!FAILURE_CLASSES.includes(primaryClass)) fail('malformed_input', 'classifyFailure requires a valid primary_failure_class');
|
|
1012
|
+
if (!isSha256Tag(adjudicationDigest)) fail('malformed_input', 'classifyFailure requires a valid adjudication_digest');
|
|
1013
|
+
if (typeof occurredAt !== 'string' || !occurredAt) fail('malformed_input', 'classifyFailure requires occurred_at');
|
|
1014
|
+
const { record, bytes } = nextLedgerRecord(root, (events) => {
|
|
1015
|
+
if (!events.some((event) => event.type === 'opened' && event.failure_id === failureId)) {
|
|
1016
|
+
fail('unknown_failure_id', `classifyFailure requires an opened event for ${failureId}`);
|
|
1017
|
+
}
|
|
1018
|
+
if (events.some((event) => event.type === 'classified' && event.failure_id === failureId)) {
|
|
1019
|
+
fail('already_classified', `failure_id ${failureId} already has a classified event`);
|
|
1020
|
+
}
|
|
1021
|
+
if (events.some((event) => (event.type === 'classified' || event.type === 'reclassified') && event.adjudication_digest === adjudicationDigest)) {
|
|
1022
|
+
fail('reused_adjudication_digest', 'adjudication_digest has already been used for a classification event');
|
|
1023
|
+
}
|
|
1024
|
+
return { type: 'classified', failure_id: failureId, primary_failure_class: primaryClass, adjudication_digest: adjudicationDigest, occurred_at: occurredAt };
|
|
1025
|
+
});
|
|
1026
|
+
atomicWriteBytes(absPath(root, REL.ledger), bytes);
|
|
1027
|
+
return record;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function reclassifyFailure(root, input) {
|
|
1031
|
+
if (!plain(input)) fail('malformed_input', 'reclassifyFailure input must be an object');
|
|
1032
|
+
const {
|
|
1033
|
+
failure_id: failureId, previous_primary_failure_class: previousClass,
|
|
1034
|
+
primary_failure_class: primaryClass, adjudication_digest: adjudicationDigest, occurred_at: occurredAt,
|
|
1035
|
+
} = input;
|
|
1036
|
+
if (typeof failureId !== 'string' || !failureId) fail('malformed_input', 'reclassifyFailure requires failure_id');
|
|
1037
|
+
if (!FAILURE_CLASSES.includes(previousClass)) fail('malformed_input', 'reclassifyFailure requires a valid previous_primary_failure_class');
|
|
1038
|
+
if (!FAILURE_CLASSES.includes(primaryClass)) fail('malformed_input', 'reclassifyFailure requires a valid primary_failure_class');
|
|
1039
|
+
if (!isSha256Tag(adjudicationDigest)) fail('malformed_input', 'reclassifyFailure requires a valid adjudication_digest');
|
|
1040
|
+
if (typeof occurredAt !== 'string' || !occurredAt) fail('malformed_input', 'reclassifyFailure requires occurred_at');
|
|
1041
|
+
const { record, bytes } = nextLedgerRecord(root, (events) => {
|
|
1042
|
+
const state = deriveFailureState({ state: 'present', record: { events } }, failureId);
|
|
1043
|
+
if (!state.exists || state.currentClass === null) fail('unknown_failure_id', `reclassifyFailure requires a prior classified event for ${failureId}`);
|
|
1044
|
+
if (state.resolved) fail('already_resolved', `failure_id ${failureId} already has a resolved event`);
|
|
1045
|
+
if (state.currentClass !== previousClass) fail('stale_previous_class', 'previous_primary_failure_class does not match the currently-derived class');
|
|
1046
|
+
if (events.some((event) => (event.type === 'classified' || event.type === 'reclassified') && event.adjudication_digest === adjudicationDigest)) {
|
|
1047
|
+
fail('reused_adjudication_digest', 'adjudication_digest has already been used for a classification event');
|
|
1048
|
+
}
|
|
1049
|
+
return {
|
|
1050
|
+
type: 'reclassified', failure_id: failureId, previous_primary_failure_class: previousClass,
|
|
1051
|
+
primary_failure_class: primaryClass, adjudication_digest: adjudicationDigest, occurred_at: occurredAt,
|
|
1052
|
+
};
|
|
1053
|
+
});
|
|
1054
|
+
atomicWriteBytes(absPath(root, REL.ledger), bytes);
|
|
1055
|
+
return record;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// I13: never trusts a caller-supplied release_receipt_digest or a caller's
|
|
1059
|
+
// claim that failure_id belongs to the release being resolved. Independently
|
|
1060
|
+
// re-derives and validates the published receipt before appending.
|
|
1061
|
+
function resolveFailure(root, input) {
|
|
1062
|
+
if (!plain(input)) fail('malformed_input', 'resolveFailure input must be an object');
|
|
1063
|
+
const { failure_id: failureId, decision_digest: decisionDigest } = input;
|
|
1064
|
+
if (typeof failureId !== 'string' || !failureId) fail('malformed_input', 'resolveFailure requires failure_id');
|
|
1065
|
+
if (!isSha256Tag(decisionDigest)) fail('malformed_input', 'resolveFailure requires a valid decision_digest');
|
|
1066
|
+
|
|
1067
|
+
const authority = assertBaselineUsable(root);
|
|
1068
|
+
if (authority.state !== 'released') fail('not_released', 'resolveFailure requires a released C10 authority');
|
|
1069
|
+
const canonical = tryReadJsonFile(root, REL.receipt);
|
|
1070
|
+
if (!canonical) fail('receipt_missing', 'canonical release receipt does not exist or is unreadable/malformed');
|
|
1071
|
+
const recomputedDigest = sha256Tag(canonical.bytes);
|
|
1072
|
+
if (recomputedDigest !== authority.record.releaseReceiptDigest) {
|
|
1073
|
+
fail('receipt_digest_mismatch', 'canonical receipt bytes do not match the currently published releaseReceiptDigest');
|
|
1074
|
+
}
|
|
1075
|
+
if (!validateReceiptShape(canonical.parsed)) fail('malformed_receipt', 'canonical release receipt fails the exact C12 schema');
|
|
1076
|
+
if (Object.prototype.hasOwnProperty.call(input, 'release_receipt_digest')
|
|
1077
|
+
&& input.release_receipt_digest !== undefined && input.release_receipt_digest !== recomputedDigest) {
|
|
1078
|
+
fail('untrusted_claim_mismatch', 'caller-supplied release_receipt_digest does not match the independently recomputed digest');
|
|
1079
|
+
}
|
|
1080
|
+
const resolvedIds = sortedUnique(canonical.parsed.resolvedFailureIds);
|
|
1081
|
+
if (!resolvedIds.includes(failureId)) fail('not_in_receipt', 'failure_id is not a member of the persisted receipt resolvedFailureIds');
|
|
1082
|
+
|
|
1083
|
+
const ledgerLoaded = loadFailureLedger(root);
|
|
1084
|
+
const state = deriveFailureState(ledgerLoaded, failureId);
|
|
1085
|
+
if (!state.exists) fail('unknown_failure_id', `resolveFailure requires a known failure_id ${failureId}`);
|
|
1086
|
+
if (state.resolved) {
|
|
1087
|
+
if (state.resolvedEvent.release_receipt_digest === recomputedDigest) return { status: 'noop' };
|
|
1088
|
+
fail('resolve_conflict', 'failure_id already resolved with a different release_receipt_digest');
|
|
1089
|
+
}
|
|
1090
|
+
if (state.currentClass !== 'framework_regression') fail('not_framework_regression', 'resolveFailure only resolves ids currently classified framework_regression');
|
|
1091
|
+
|
|
1092
|
+
const { record, bytes } = nextLedgerRecord(root, () => ({
|
|
1093
|
+
type: 'resolved', failure_id: failureId, decision_digest: decisionDigest,
|
|
1094
|
+
release_receipt_digest: recomputedDigest, occurred_at: new Date().toISOString(),
|
|
1095
|
+
}));
|
|
1096
|
+
atomicWriteBytes(absPath(root, REL.ledger), bytes);
|
|
1097
|
+
return { status: 'resolved', record };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ---------------------------------------------------------------------------
|
|
1101
|
+
// C1 ChangeProposal shape validators
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
|
|
1104
|
+
function validateBenchmarkRemediationShape(proposal) {
|
|
1105
|
+
const required = [
|
|
1106
|
+
'change_intent', 'proposal_id', 'invariant_id', 'hypothesis', 'owner_layer',
|
|
1107
|
+
'primary_failure_class', 'paths', 'failure_ids', 'evidence',
|
|
1108
|
+
'true_positive_family', 'negative_control_family', 'rollback_condition',
|
|
1109
|
+
];
|
|
1110
|
+
if (!hasOnlyKeys(proposal, required, ['held_out_result', 'ceremony_delta'])) return false;
|
|
1111
|
+
if (proposal.change_intent !== 'benchmark_remediation') return false;
|
|
1112
|
+
if (typeof proposal.proposal_id !== 'string' || !proposal.proposal_id) return false;
|
|
1113
|
+
if (typeof proposal.invariant_id !== 'string' || !proposal.invariant_id) return false;
|
|
1114
|
+
if (typeof proposal.hypothesis !== 'string' || !proposal.hypothesis) return false;
|
|
1115
|
+
if (!OWNER_LAYERS.includes(proposal.owner_layer)) return false;
|
|
1116
|
+
if (!FAILURE_CLASSES.includes(proposal.primary_failure_class)) return false;
|
|
1117
|
+
if (!isSortedUniqueArray(proposal.paths)) return false;
|
|
1118
|
+
if (!Array.isArray(proposal.failure_ids) || proposal.failure_ids.length === 0) return false;
|
|
1119
|
+
if (!proposal.failure_ids.every((entry) => typeof entry === 'string' && entry !== '')) return false;
|
|
1120
|
+
if (new Set(proposal.failure_ids).size !== proposal.failure_ids.length) return false;
|
|
1121
|
+
if (!isNonEmptyStringArray(proposal.evidence)) return false;
|
|
1122
|
+
if (!isNonEmptyStringArray(proposal.true_positive_family)) return false;
|
|
1123
|
+
if (!isNonEmptyStringArray(proposal.negative_control_family)) return false;
|
|
1124
|
+
if (typeof proposal.rollback_condition !== 'string' || !proposal.rollback_condition) return false;
|
|
1125
|
+
if ('held_out_result' in proposal && typeof proposal.held_out_result !== 'string') return false;
|
|
1126
|
+
if ('ceremony_delta' in proposal && typeof proposal.ceremony_delta !== 'string') return false;
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function validateAuthorizedEvolutionShape(proposal) {
|
|
1131
|
+
const required = [
|
|
1132
|
+
'change_intent', 'proposal_id', 'spec_ref', 'spec_semantic_digest',
|
|
1133
|
+
'independent_pass_receipt_digest', 'planned_write_set', 'rollback_condition', 'negative_control_family',
|
|
1134
|
+
];
|
|
1135
|
+
if (!hasOnlyKeys(proposal, required)) return false;
|
|
1136
|
+
if (proposal.change_intent !== 'authorized_evolution') return false;
|
|
1137
|
+
if (typeof proposal.proposal_id !== 'string' || !proposal.proposal_id) return false;
|
|
1138
|
+
if (typeof proposal.spec_ref !== 'string' || !proposal.spec_ref) return false;
|
|
1139
|
+
if (!isSha256Tag(proposal.spec_semantic_digest)) return false;
|
|
1140
|
+
if (!isSha256Tag(proposal.independent_pass_receipt_digest)) return false;
|
|
1141
|
+
if (!isSortedUniqueArray(proposal.planned_write_set)) return false;
|
|
1142
|
+
if (typeof proposal.rollback_condition !== 'string' || !proposal.rollback_condition) return false;
|
|
1143
|
+
if (!isNonEmptyStringArray(proposal.negative_control_family)) return false;
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Containment is checked twice: once lexically (before touching the
|
|
1148
|
+
// filesystem) and once again against the fully realpath-resolved location,
|
|
1149
|
+
// since an intermediate ancestor directory component can be a symlink that
|
|
1150
|
+
// silently redirects a lexically-contained path outside the project root
|
|
1151
|
+
// (lstat on the final component alone does not catch this).
|
|
1152
|
+
function resolveSpecRefDir(root, specRef) {
|
|
1153
|
+
if (typeof specRef !== 'string' || !specRef) fail('malformed_input', 'spec_ref must be a non-empty repo-relative path');
|
|
1154
|
+
if (path.isAbsolute(specRef)) fail('path_escape', 'spec_ref must be repo-relative, not absolute');
|
|
1155
|
+
const absolute = path.resolve(root, specRef);
|
|
1156
|
+
const relative = path.relative(root, absolute);
|
|
1157
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail('path_escape', 'spec_ref escapes the project root');
|
|
1158
|
+
let stat;
|
|
1159
|
+
try {
|
|
1160
|
+
stat = fs.lstatSync(absolute);
|
|
1161
|
+
} catch {
|
|
1162
|
+
fail('spec_ref_missing', 'spec_ref directory does not exist');
|
|
1163
|
+
}
|
|
1164
|
+
if (stat.isSymbolicLink()) fail('path_escape', 'spec_ref must not itself be a symlink');
|
|
1165
|
+
if (!stat.isDirectory()) fail('spec_ref_missing', 'spec_ref must be a directory');
|
|
1166
|
+
let realAbsolute;
|
|
1167
|
+
let realRoot;
|
|
1168
|
+
try {
|
|
1169
|
+
realAbsolute = fs.realpathSync(absolute);
|
|
1170
|
+
realRoot = fs.realpathSync(root);
|
|
1171
|
+
} catch {
|
|
1172
|
+
fail('spec_ref_missing', 'spec_ref cannot be resolved');
|
|
1173
|
+
}
|
|
1174
|
+
const realRelative = path.relative(realRoot, realAbsolute);
|
|
1175
|
+
if (realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
|
|
1176
|
+
fail('path_escape', 'spec_ref escapes the project root via a symlinked ancestor');
|
|
1177
|
+
}
|
|
1178
|
+
return absolute;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// C12 artifactPath containment: rejects an absolute path, a lexical `..`
|
|
1182
|
+
// escape (a raw string-prefix check alone is not enough -- a value like
|
|
1183
|
+
// `packages/spec/.cafekit-release/../../../etc/passwd` still starts with the
|
|
1184
|
+
// reserved prefix as text), anything outside the reserved release directory,
|
|
1185
|
+
// a missing file, a symlink at the final component, and a symlinked ancestor
|
|
1186
|
+
// directory that would otherwise silently redirect a lexically-contained
|
|
1187
|
+
// path outside the project root.
|
|
1188
|
+
function assertArtifactPath(root, relativeInput) {
|
|
1189
|
+
if (typeof relativeInput !== 'string' || relativeInput === '') fail('malformed_input', 'artifactPath must be a non-empty string');
|
|
1190
|
+
if (path.isAbsolute(relativeInput)) fail('artifact_path_escape', 'artifactPath must be repo-relative, not absolute');
|
|
1191
|
+
const lexicalCandidate = path.resolve(root, relativeInput);
|
|
1192
|
+
const lexicalRelative = path.relative(root, lexicalCandidate);
|
|
1193
|
+
if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(lexicalRelative)) {
|
|
1194
|
+
fail('artifact_path_escape', 'artifactPath escapes the project root');
|
|
1195
|
+
}
|
|
1196
|
+
const normalizedRelative = lexicalRelative.split(path.sep).join('/');
|
|
1197
|
+
if (!normalizedRelative.startsWith(REL.cafekitReleaseDir)) {
|
|
1198
|
+
fail('artifact_path_escape', 'artifactPath must be restricted to packages/spec/.cafekit-release/');
|
|
1199
|
+
}
|
|
1200
|
+
let stat;
|
|
1201
|
+
try {
|
|
1202
|
+
stat = fs.lstatSync(lexicalCandidate);
|
|
1203
|
+
} catch {
|
|
1204
|
+
fail('artifact_missing', 'release artifact does not exist at artifactPath');
|
|
1205
|
+
}
|
|
1206
|
+
if (stat.isSymbolicLink()) fail('artifact_invalid', 'release artifact must not be a symlink');
|
|
1207
|
+
if (!stat.isFile()) fail('artifact_invalid', 'release artifact must be a regular file');
|
|
1208
|
+
// No path component may be a symlink -- not the artifact's own ancestor
|
|
1209
|
+
// directories, and not packages/spec/.cafekit-release/ itself. Comparing
|
|
1210
|
+
// realpath-resolved endpoints only by their *relative* distance is not a
|
|
1211
|
+
// sound check: if an ancestor symlink (including .cafekit-release/ itself)
|
|
1212
|
+
// redirects the whole subtree elsewhere, both the candidate and the release
|
|
1213
|
+
// directory resolve through that same redirection and still appear
|
|
1214
|
+
// "contained" relative to each other; the same blind spot lets a symlink
|
|
1215
|
+
// planted *inside* the release directory redirect to another real subdir
|
|
1216
|
+
// that is still, itself, inside the release tree. The only check that
|
|
1217
|
+
// catches every case uniformly is that realpath resolution changes nothing
|
|
1218
|
+
// at all from the literal, lexically-resolved paths.
|
|
1219
|
+
const lexicalReleaseDir = absPath(root, REL.cafekitReleaseDir.replace(/\/$/, ''));
|
|
1220
|
+
let realCandidate;
|
|
1221
|
+
let realReleaseDir;
|
|
1222
|
+
try {
|
|
1223
|
+
realCandidate = fs.realpathSync(lexicalCandidate);
|
|
1224
|
+
realReleaseDir = fs.realpathSync(lexicalReleaseDir);
|
|
1225
|
+
} catch {
|
|
1226
|
+
fail('artifact_missing', 'release artifact cannot be resolved');
|
|
1227
|
+
}
|
|
1228
|
+
if (realReleaseDir !== lexicalReleaseDir) {
|
|
1229
|
+
fail('artifact_invalid', 'packages/spec/.cafekit-release/ itself resolves through a symlink');
|
|
1230
|
+
}
|
|
1231
|
+
if (realCandidate !== lexicalCandidate) {
|
|
1232
|
+
fail('artifact_invalid', 'release artifact path resolves through a symlinked component');
|
|
1233
|
+
}
|
|
1234
|
+
return lexicalCandidate;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function readSpecJsonAt(specDir) {
|
|
1238
|
+
const specFile = path.join(specDir, 'spec.json');
|
|
1239
|
+
let stat;
|
|
1240
|
+
try {
|
|
1241
|
+
stat = fs.lstatSync(specFile);
|
|
1242
|
+
} catch {
|
|
1243
|
+
fail('spec_ref_missing', 'spec_ref does not contain spec.json');
|
|
1244
|
+
}
|
|
1245
|
+
if (!stat.isFile()) fail('spec_ref_missing', 'spec_ref spec.json must be a regular file');
|
|
1246
|
+
let parsed;
|
|
1247
|
+
try {
|
|
1248
|
+
parsed = JSON.parse(fs.readFileSync(specFile, 'utf8'));
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
fail('malformed_input', `spec_ref spec.json is not valid JSON (${error.message})`);
|
|
1251
|
+
}
|
|
1252
|
+
return parsed;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function computeSpecSemanticDigest(specDir, spec) {
|
|
1256
|
+
const result = validatorModule().computeSemanticDigest21(specDir, spec);
|
|
1257
|
+
if (result.errors && result.errors.length) fail('spec_digest_failed', `cannot compute spec_ref semantic digest (${result.errors.join('; ')})`);
|
|
1258
|
+
return result.digest;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Shared C15 remediation ground-truth helper (single rule, no per-caller/
|
|
1262
|
+
// per-fixture duplication): the exact set of currently open, blocking,
|
|
1263
|
+
// path-intersecting ledger entries for a given changedPaths set, plus the
|
|
1264
|
+
// union of their opened paths. Always re-derived from the live ledger --
|
|
1265
|
+
// never trusts a caller- or freeze-recorded remediation claim. Used by
|
|
1266
|
+
// assertBenchmarkRemediation, ledgerIntersectsPaths (I8), and
|
|
1267
|
+
// verifyFreezeManifest's independent remediatedFailureIds re-check.
|
|
1268
|
+
function deriveExpectedRemediation(ledgerLoaded, changedPaths) {
|
|
1269
|
+
const intersecting = allOpenFailureIds(ledgerLoaded)
|
|
1270
|
+
.map((failureId) => ({ failureId, state: deriveFailureState(ledgerLoaded, failureId) }))
|
|
1271
|
+
.filter(({ state }) => state.blocking && state.openedPaths.some((entryPath) => changedPaths.includes(entryPath)));
|
|
1272
|
+
return {
|
|
1273
|
+
failureIds: sortedUnique(intersecting.map((entry) => entry.failureId)),
|
|
1274
|
+
pathsUnion: sortedUnique(intersecting.flatMap((entry) => entry.state.openedPaths)),
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function ledgerIntersectsPaths(ledgerLoaded, changedPaths) {
|
|
1279
|
+
return deriveExpectedRemediation(ledgerLoaded, changedPaths).failureIds.length > 0;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function assertBenchmarkRemediation(proposal, changedPaths, ledgerLoaded) {
|
|
1283
|
+
if (!validateBenchmarkRemediationShape(proposal)) fail('malformed_proposal', 'benchmark_remediation proposal fails the exact C1 schema');
|
|
1284
|
+
if (JSON.stringify(sortedUnique(proposal.paths)) !== JSON.stringify(changedPaths)) {
|
|
1285
|
+
fail('paths_mismatch', 'benchmark_remediation paths must exactly equal the derived protected changedPaths');
|
|
1286
|
+
}
|
|
1287
|
+
if (proposal.primary_failure_class !== 'framework_regression') {
|
|
1288
|
+
fail('non_framework_regression', 'benchmark_remediation only accepts primary_failure_class = framework_regression');
|
|
1289
|
+
}
|
|
1290
|
+
const failureIds = sortedUnique(proposal.failure_ids);
|
|
1291
|
+
const expected = deriveExpectedRemediation(ledgerLoaded, changedPaths);
|
|
1292
|
+
if (JSON.stringify(expected.failureIds) !== JSON.stringify(failureIds)) {
|
|
1293
|
+
fail('failure_ids_mismatch', 'failure_ids must exactly name every intersecting open ledger entry');
|
|
1294
|
+
}
|
|
1295
|
+
if (JSON.stringify(expected.pathsUnion) !== JSON.stringify(changedPaths)) {
|
|
1296
|
+
fail('paths_union_mismatch', 'the combined opened.paths union of the named failure_ids must exactly equal the proposal paths');
|
|
1297
|
+
}
|
|
1298
|
+
return { changeKind: 'protected_change', remediatedFailureIds: failureIds };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function assertAuthorizedEvolution(root, proposal, changedPaths, ledgerLoaded) {
|
|
1302
|
+
if (!validateAuthorizedEvolutionShape(proposal)) fail('malformed_proposal', 'authorized_evolution proposal fails the exact C1 schema');
|
|
1303
|
+
if (JSON.stringify(sortedUnique(proposal.planned_write_set)) !== JSON.stringify(changedPaths)) {
|
|
1304
|
+
fail('paths_mismatch', 'authorized_evolution planned_write_set must exactly equal the derived protected changedPaths');
|
|
1305
|
+
}
|
|
1306
|
+
// I8: refuse unconditionally on any open-ledger intersection; no citation passes it through.
|
|
1307
|
+
if (ledgerIntersectsPaths(ledgerLoaded, changedPaths)) {
|
|
1308
|
+
fail('open_ledger_intersection', 'authorized_evolution is refused unconditionally when it intersects an open C15 ledger entry; use benchmark_remediation instead');
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const specDir = resolveSpecRefDir(root, proposal.spec_ref);
|
|
1312
|
+
const spec = readSpecJsonAt(specDir);
|
|
1313
|
+
const planningDepth = spec && spec.workflow_policy && spec.workflow_policy.planning_depth;
|
|
1314
|
+
if (planningDepth !== 'Compact' && planningDepth !== 'Full') {
|
|
1315
|
+
fail('planning_depth_invalid', 'spec_ref workflow_policy.planning_depth must be Compact or Full');
|
|
1316
|
+
}
|
|
1317
|
+
const freshness = DIGEST.assertAuthoringFresh({
|
|
1318
|
+
specDir,
|
|
1319
|
+
authoring: spec.authoring,
|
|
1320
|
+
receipt: spec.validation && spec.validation.authoring_validation,
|
|
1321
|
+
fields: ['requirements', 'design'],
|
|
1322
|
+
});
|
|
1323
|
+
if (!freshness.requirements.fresh || !freshness.design.fresh) {
|
|
1324
|
+
fail('authoring_not_fresh', 'spec_ref authoring.requirements/design must both be validated and C16-digest-fresh (I20)');
|
|
1325
|
+
}
|
|
1326
|
+
const currentDigest = computeSpecSemanticDigest(specDir, spec);
|
|
1327
|
+
if (proposal.spec_semantic_digest !== currentDigest) fail('stale_spec_digest', 'spec_semantic_digest does not equal the current semantic_model digest');
|
|
1328
|
+
|
|
1329
|
+
const history = spec.validation && spec.validation.semantic_review_history;
|
|
1330
|
+
const entries = plain(history) && Array.isArray(history.entries) ? history.entries : [];
|
|
1331
|
+
const matching = entries.filter((entry) => entry && entry.verdict === 'PASS' && entry.semantic_digest === currentDigest);
|
|
1332
|
+
if (matching.length === 0) fail('missing_independent_pass', 'no accepted PASS entry in this lineage matches the current spec digest');
|
|
1333
|
+
const latest = matching.reduce((best, entry) => (entry.sequence > best.sequence ? entry : best), matching[0]);
|
|
1334
|
+
if (proposal.independent_pass_receipt_digest !== latest.review_receipt_digest) {
|
|
1335
|
+
fail('superseded_pass_citation', 'independent_pass_receipt_digest must cite the latest accepted PASS in the lineage (I14), not a superseded entry');
|
|
1336
|
+
}
|
|
1337
|
+
return { changeKind: 'protected_change', remediatedFailureIds: [] };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// D1/I8: gates unproven or unauthorized change without freezing legitimate
|
|
1341
|
+
// evolution. changedPaths must already be self-derived (deriveProtectedChangedPaths).
|
|
1342
|
+
function assertHotspotChangeAllowed(root, { proposal, changedPaths, ledgerLoaded } = {}) {
|
|
1343
|
+
if (!isSortedUniqueArray(changedPaths)) fail('malformed_input', 'changedPaths must be sorted-unique and non-empty');
|
|
1344
|
+
const ledger = ledgerLoaded || assertFailureLedgerUsable(root);
|
|
1345
|
+
if (!plain(proposal) || typeof proposal.change_intent !== 'string') fail('proposal_required', 'a tagged C1 ChangeProposal is required for a protected change');
|
|
1346
|
+
if (proposal.change_intent === 'benchmark_remediation') return assertBenchmarkRemediation(proposal, changedPaths, ledger);
|
|
1347
|
+
if (proposal.change_intent === 'authorized_evolution') return assertAuthorizedEvolution(root, proposal, changedPaths, ledger);
|
|
1348
|
+
fail('malformed_input', 'change_intent must be benchmark_remediation or authorized_evolution');
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// ---------------------------------------------------------------------------
|
|
1352
|
+
// C9 FreezeManifest
|
|
1353
|
+
// ---------------------------------------------------------------------------
|
|
1354
|
+
|
|
1355
|
+
function validateFreezeShape(freeze) {
|
|
1356
|
+
const keys = [
|
|
1357
|
+
'baselineAuthorityDigest', 'baselineGeneration', 'baselineCommit',
|
|
1358
|
+
'failureLedgerDigest', 'failureLedgerGeneration', 'remediatedFailureIds',
|
|
1359
|
+
'changeKind', 'proposalDigest', 'treeDigest', 'changedPaths', 'packageVersion', 'candidateDigest',
|
|
1360
|
+
];
|
|
1361
|
+
if (!exactKeys(freeze, keys)) return false;
|
|
1362
|
+
if (!isSha256Tag(freeze.baselineAuthorityDigest)) return false;
|
|
1363
|
+
if (!Number.isInteger(freeze.baselineGeneration) || freeze.baselineGeneration < 0) return false;
|
|
1364
|
+
if (!isCommitId(freeze.baselineCommit)) return false;
|
|
1365
|
+
if (freeze.failureLedgerDigest !== NO_FAILURES_ATTESTATION_DIGEST && !isSha256Tag(freeze.failureLedgerDigest)) return false;
|
|
1366
|
+
if (freeze.failureLedgerGeneration !== null && !(Number.isInteger(freeze.failureLedgerGeneration) && freeze.failureLedgerGeneration >= 0)) return false;
|
|
1367
|
+
if (!isSortedUniqueArray(freeze.remediatedFailureIds, { allowEmpty: true })) return false;
|
|
1368
|
+
if (!['protected_change', 'no_protected_change'].includes(freeze.changeKind)) return false;
|
|
1369
|
+
if (freeze.proposalDigest !== NO_CHANGE_ATTESTATION_DIGEST && !isSha256Tag(freeze.proposalDigest)) return false;
|
|
1370
|
+
if (!isSha256Tag(freeze.treeDigest)) return false;
|
|
1371
|
+
if (!isSortedUniqueArray(freeze.changedPaths, { allowEmpty: true })) return false;
|
|
1372
|
+
if (freeze.changeKind === 'protected_change' && freeze.changedPaths.length === 0) return false;
|
|
1373
|
+
if (freeze.changeKind === 'no_protected_change' && freeze.changedPaths.length !== 0) return false;
|
|
1374
|
+
if (typeof freeze.packageVersion !== 'string' || !freeze.packageVersion) return false;
|
|
1375
|
+
if (!isSha256Tag(freeze.candidateDigest)) return false;
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function readPackageVersion(root) {
|
|
1380
|
+
const loaded = readJsonFileIfExists(root, REL.packageJson);
|
|
1381
|
+
if (!loaded || typeof loaded.parsed.version !== 'string' || !loaded.parsed.version) {
|
|
1382
|
+
fail('malformed_input', 'packages/spec/package.json version is missing');
|
|
1383
|
+
}
|
|
1384
|
+
return loaded.parsed.version;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function createFreezeManifest(root, proposal) {
|
|
1388
|
+
const authority = assertBaselineUsable(root);
|
|
1389
|
+
if (!isAncestorOrEqual(root, authority.record.baselineCommit, gitHeadCommit(root))) {
|
|
1390
|
+
fail('baseline_not_ancestor', 'authority baselineCommit is not an ancestor of (or equal to) current HEAD');
|
|
1391
|
+
}
|
|
1392
|
+
const ledgerLoaded = assertFailureLedgerUsable(root);
|
|
1393
|
+
const failureLedgerDigest = ledgerLoaded.state === 'present' ? ledgerLoaded.digest : NO_FAILURES_ATTESTATION_DIGEST;
|
|
1394
|
+
const failureLedgerGeneration = ledgerLoaded.state === 'present' ? ledgerLoaded.record.generation : null;
|
|
1395
|
+
|
|
1396
|
+
const protectedPaths = deriveProtectedChangedPaths(root, authority.record.baselineCommit);
|
|
1397
|
+
const packageVersion = readPackageVersion(root);
|
|
1398
|
+
|
|
1399
|
+
let changeKind;
|
|
1400
|
+
let proposalDigest;
|
|
1401
|
+
let remediatedFailureIds = [];
|
|
1402
|
+
let changedPaths = protectedPaths;
|
|
1403
|
+
|
|
1404
|
+
if (protectedPaths.length === 0) {
|
|
1405
|
+
if (authority.state !== 'released') fail('empty_paths_invalid', 'empty derived protected paths are never valid during bootstrap state');
|
|
1406
|
+
if (proposal !== null && proposal !== undefined) fail('malformed_input', 'a no_protected_change freeze must not carry a C1 proposal');
|
|
1407
|
+
changeKind = 'no_protected_change';
|
|
1408
|
+
proposalDigest = NO_CHANGE_ATTESTATION_DIGEST;
|
|
1409
|
+
changedPaths = [];
|
|
1410
|
+
} else {
|
|
1411
|
+
changeKind = 'protected_change';
|
|
1412
|
+
const gate = assertHotspotChangeAllowed(root, { proposal, changedPaths: protectedPaths, ledgerLoaded });
|
|
1413
|
+
proposalDigest = computeProposalDigest(proposal);
|
|
1414
|
+
remediatedFailureIds = gate.remediatedFailureIds;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const treeDigest = computeProtectedTreeDigest(root, changedPaths);
|
|
1418
|
+
const candidateFields = {
|
|
1419
|
+
baselineAuthorityDigest: authority.digest,
|
|
1420
|
+
baselineGeneration: authority.record.generation,
|
|
1421
|
+
baselineCommit: authority.record.baselineCommit,
|
|
1422
|
+
failureLedgerDigest,
|
|
1423
|
+
failureLedgerGeneration,
|
|
1424
|
+
remediatedFailureIds: sortedUnique(remediatedFailureIds),
|
|
1425
|
+
changedPaths,
|
|
1426
|
+
treeDigest,
|
|
1427
|
+
proposalDigest,
|
|
1428
|
+
packageVersion,
|
|
1429
|
+
};
|
|
1430
|
+
const candidateDigest = computeCandidateDigest(candidateFields);
|
|
1431
|
+
|
|
1432
|
+
const manifest = {
|
|
1433
|
+
baselineAuthorityDigest: authority.digest,
|
|
1434
|
+
baselineGeneration: authority.record.generation,
|
|
1435
|
+
baselineCommit: authority.record.baselineCommit,
|
|
1436
|
+
failureLedgerDigest,
|
|
1437
|
+
failureLedgerGeneration,
|
|
1438
|
+
remediatedFailureIds: sortedUnique(remediatedFailureIds),
|
|
1439
|
+
changeKind,
|
|
1440
|
+
proposalDigest,
|
|
1441
|
+
treeDigest,
|
|
1442
|
+
changedPaths,
|
|
1443
|
+
packageVersion,
|
|
1444
|
+
candidateDigest,
|
|
1445
|
+
};
|
|
1446
|
+
atomicWriteJson(absPath(root, REL.freeze), manifest);
|
|
1447
|
+
return manifest;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function verifyFreezeManifest(root) {
|
|
1451
|
+
const loaded = readJsonFileIfExists(root, REL.freeze);
|
|
1452
|
+
if (!loaded) fail('freeze_missing', 'change-firewall-freeze.json does not exist; freeze creation must run first');
|
|
1453
|
+
const freeze = loaded.parsed;
|
|
1454
|
+
if (!validateFreezeShape(freeze)) fail('freeze_malformed', 'change-firewall-freeze.json fails the exact C9 schema');
|
|
1455
|
+
|
|
1456
|
+
const authority = assertBaselineUsable(root);
|
|
1457
|
+
if (freeze.baselineAuthorityDigest !== authority.digest
|
|
1458
|
+
|| freeze.baselineGeneration !== authority.record.generation
|
|
1459
|
+
|| freeze.baselineCommit !== authority.record.baselineCommit) {
|
|
1460
|
+
fail('freeze_stale', 'freeze is stale against the current C10 authority');
|
|
1461
|
+
}
|
|
1462
|
+
if (!isAncestorOrEqual(root, authority.record.baselineCommit, gitHeadCommit(root))) {
|
|
1463
|
+
fail('baseline_not_ancestor', 'authority baselineCommit is not an ancestor of (or equal to) current HEAD');
|
|
1464
|
+
}
|
|
1465
|
+
const ledgerLoaded = assertFailureLedgerUsable(root);
|
|
1466
|
+
const currentLedgerDigest = ledgerLoaded.state === 'present' ? ledgerLoaded.digest : NO_FAILURES_ATTESTATION_DIGEST;
|
|
1467
|
+
const currentLedgerGeneration = ledgerLoaded.state === 'present' ? ledgerLoaded.record.generation : null;
|
|
1468
|
+
if (freeze.failureLedgerDigest !== currentLedgerDigest || freeze.failureLedgerGeneration !== currentLedgerGeneration) {
|
|
1469
|
+
fail('freeze_stale', 'freeze is stale against the current C15 ledger');
|
|
1470
|
+
}
|
|
1471
|
+
if (freeze.changeKind === 'no_protected_change' && authority.state !== 'released') {
|
|
1472
|
+
fail('freeze_malformed', 'no_protected_change is only valid when authority state is released');
|
|
1473
|
+
}
|
|
1474
|
+
const currentPackageVersion = readPackageVersion(root);
|
|
1475
|
+
if (freeze.packageVersion !== currentPackageVersion) {
|
|
1476
|
+
fail('freeze_stale', 'freeze packageVersion no longer matches the current package.json version');
|
|
1477
|
+
}
|
|
1478
|
+
// Re-derive the protected changed-path set fresh from the authority baseline
|
|
1479
|
+
// and the current working tree -- never trust the freeze's own recorded
|
|
1480
|
+
// list. A path added or removed since freeze creation changes this set
|
|
1481
|
+
// even when every already-listed path's own content digest still matches,
|
|
1482
|
+
// so this exact-set comparison is required in addition to the treeDigest
|
|
1483
|
+
// recompute below (which only re-hashes the paths the freeze already lists).
|
|
1484
|
+
const currentChangedPaths = deriveProtectedChangedPaths(root, authority.record.baselineCommit);
|
|
1485
|
+
if (JSON.stringify(currentChangedPaths) !== JSON.stringify(freeze.changedPaths)) {
|
|
1486
|
+
fail('freeze_stale', 'derived protected changed paths no longer match the frozen set');
|
|
1487
|
+
}
|
|
1488
|
+
const recomputedTreeDigest = computeProtectedTreeDigest(root, freeze.changedPaths);
|
|
1489
|
+
if (recomputedTreeDigest !== freeze.treeDigest) fail('freeze_stale', 'freeze treeDigest no longer matches the current working tree');
|
|
1490
|
+
// Re-derive the expected remediation from the live C15 ledger + the current
|
|
1491
|
+
// changedPaths -- never trust freeze.remediatedFailureIds itself. The
|
|
1492
|
+
// candidateDigest recompute below only proves the freeze's own recorded
|
|
1493
|
+
// fields are mutually self-consistent, not that they still reflect ground
|
|
1494
|
+
// truth; a hand-edited freeze that narrows remediatedFailureIds and
|
|
1495
|
+
// self-recomputes a matching candidateDigest must still fail here.
|
|
1496
|
+
const expectedRemediation = deriveExpectedRemediation(ledgerLoaded, freeze.changedPaths);
|
|
1497
|
+
if (JSON.stringify(expectedRemediation.failureIds) !== JSON.stringify(freeze.remediatedFailureIds)) {
|
|
1498
|
+
fail('freeze_stale', 'freeze remediatedFailureIds no longer names exactly the current intersecting open C15 ledger entries');
|
|
1499
|
+
}
|
|
1500
|
+
// Same path-union invariant assertBenchmarkRemediation enforces at proposal
|
|
1501
|
+
// time: when remediatedFailureIds is non-empty, changedPaths must equal
|
|
1502
|
+
// exactly the combined opened.paths union of those named entries -- never a
|
|
1503
|
+
// superset that smuggles an extra, unrelated protected-path change through
|
|
1504
|
+
// under an otherwise-correct failure_ids/remediatedFailureIds match. Skipped
|
|
1505
|
+
// when remediatedFailureIds is empty (authorized_evolution/no_protected_change,
|
|
1506
|
+
// where changedPaths is never required to be covered by any ledger entry).
|
|
1507
|
+
if (freeze.remediatedFailureIds.length > 0
|
|
1508
|
+
&& JSON.stringify(expectedRemediation.pathsUnion) !== JSON.stringify(freeze.changedPaths)) {
|
|
1509
|
+
fail('freeze_stale', 'freeze changedPaths is not exactly the opened.paths union of its own remediatedFailureIds');
|
|
1510
|
+
}
|
|
1511
|
+
const recomputedCandidate = computeCandidateDigest({
|
|
1512
|
+
baselineAuthorityDigest: freeze.baselineAuthorityDigest,
|
|
1513
|
+
baselineGeneration: freeze.baselineGeneration,
|
|
1514
|
+
baselineCommit: freeze.baselineCommit,
|
|
1515
|
+
failureLedgerDigest: freeze.failureLedgerDigest,
|
|
1516
|
+
failureLedgerGeneration: freeze.failureLedgerGeneration,
|
|
1517
|
+
remediatedFailureIds: freeze.remediatedFailureIds,
|
|
1518
|
+
changedPaths: freeze.changedPaths,
|
|
1519
|
+
treeDigest: freeze.treeDigest,
|
|
1520
|
+
proposalDigest: freeze.proposalDigest,
|
|
1521
|
+
packageVersion: freeze.packageVersion,
|
|
1522
|
+
});
|
|
1523
|
+
if (recomputedCandidate !== freeze.candidateDigest) fail('freeze_malformed', "freeze candidateDigest does not match its own bound fields");
|
|
1524
|
+
return freeze;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function invalidateFreeze(root) {
|
|
1528
|
+
const absolute = absPath(root, REL.freeze);
|
|
1529
|
+
try {
|
|
1530
|
+
fs.unlinkSync(absolute);
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
if (error.code !== 'ENOENT') throw error;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// ---------------------------------------------------------------------------
|
|
1537
|
+
// C12 ReleaseReceipt / advanceBaseline (I11/I13)
|
|
1538
|
+
// ---------------------------------------------------------------------------
|
|
1539
|
+
|
|
1540
|
+
function validateReceiptShape(receipt) {
|
|
1541
|
+
if (!exactKeys(receipt, RELEASE_RECEIPT_FIELDS)) return false;
|
|
1542
|
+
if (receipt.schema_version !== '1') return false;
|
|
1543
|
+
if (typeof receipt.receipt_id !== 'string' || !receipt.receipt_id) return false;
|
|
1544
|
+
if (receipt.status !== 'published') return false;
|
|
1545
|
+
if (!isSha256Tag(receipt.baselineAuthorityDigest)) return false;
|
|
1546
|
+
if (!Number.isInteger(receipt.baselineGeneration) || receipt.baselineGeneration < 0) return false;
|
|
1547
|
+
if (!isSha256Tag(receipt.freezeDigest)) return false;
|
|
1548
|
+
if (!isSha256Tag(receipt.candidateDigest)) return false;
|
|
1549
|
+
if (!isSha256Tag(receipt.treeDigest)) return false;
|
|
1550
|
+
if (typeof receipt.packageVersion !== 'string' || !receipt.packageVersion) return false;
|
|
1551
|
+
if (!isCommitId(receipt.releaseCommit)) return false;
|
|
1552
|
+
if (typeof receipt.artifactPath !== 'string' || !receipt.artifactPath) return false;
|
|
1553
|
+
if (!isSha256Tag(receipt.artifactDigest)) return false;
|
|
1554
|
+
if (!isSortedUniqueArray(receipt.resolvedFailureIds, { allowEmpty: true })) return false;
|
|
1555
|
+
return true;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Publishing a release is exactly three independent single-file-atomic events
|
|
1559
|
+
// (I11): (A) persist the canonical receipt, (B) rotate C10 authority (owned
|
|
1560
|
+
// here, as two sequential sub-steps of this one call); (C) resolveFailure per
|
|
1561
|
+
// id is invoked separately by the caller, strictly after this returns success.
|
|
1562
|
+
function advanceBaseline(root, receiptBytes) {
|
|
1563
|
+
if (!Buffer.isBuffer(receiptBytes)) fail('malformed_input', 'advanceBaseline requires the exact receipt bytes as a Buffer');
|
|
1564
|
+
let receipt;
|
|
1565
|
+
try {
|
|
1566
|
+
receipt = JSON.parse(receiptBytes.toString('utf8'));
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
fail('malformed_input', `supplied receipt bytes are not valid JSON (${error.message})`);
|
|
1569
|
+
}
|
|
1570
|
+
const suppliedDigest = sha256Tag(receiptBytes);
|
|
1571
|
+
const suppliedShapeOk = validateReceiptShape(receipt);
|
|
1572
|
+
|
|
1573
|
+
const authority = assertBaselineUsable(root);
|
|
1574
|
+
const publishedDigest = authority.state === 'released' ? authority.record.releaseReceiptDigest : null;
|
|
1575
|
+
|
|
1576
|
+
// (1) idempotency check, before any other read or write.
|
|
1577
|
+
if (publishedDigest && suppliedDigest === publishedDigest) {
|
|
1578
|
+
const canonical = tryReadJsonFile(root, REL.receipt);
|
|
1579
|
+
const canonicalOk = Boolean(canonical) && sha256Tag(canonical.bytes) === publishedDigest && validateReceiptShape(canonical.parsed);
|
|
1580
|
+
if (canonicalOk) return { status: 'already_published', digest: publishedDigest };
|
|
1581
|
+
// (1b) repair from supplied bytes -- only if the supplied bytes themselves
|
|
1582
|
+
// independently verify (their digest already matches the published one).
|
|
1583
|
+
if (!suppliedShapeOk) fail('receipt_unverifiable', 'canonical receipt evidence is missing/corrupt and the supplied bytes fail the exact C12 schema');
|
|
1584
|
+
atomicWriteBytes(absPath(root, REL.receipt), receiptBytes);
|
|
1585
|
+
// Re-read the canonical bytes this write actually produced and reuse the
|
|
1586
|
+
// same digest/schema authority as (1a) to prove the repair landed -- never
|
|
1587
|
+
// trust the write call alone (design.md C12 1b: "re-verify the write").
|
|
1588
|
+
const repairedRead = tryReadJsonFile(root, REL.receipt);
|
|
1589
|
+
const repairedOk = Boolean(repairedRead) && sha256Tag(repairedRead.bytes) === publishedDigest && validateReceiptShape(repairedRead.parsed);
|
|
1590
|
+
if (!repairedOk) fail('receipt_unverifiable', 'idempotent repair write did not durably persist verifiable canonical receipt evidence (re-read failed digest/schema verification)');
|
|
1591
|
+
return { status: 'repaired', digest: publishedDigest };
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// (2) a genuinely new release candidate. Independently re-verify the current
|
|
1595
|
+
// on-disk freeze itself before this receipt is allowed to bind against it --
|
|
1596
|
+
// verifyFreezeManifest re-derives authority/ledger/tree/remediation ground
|
|
1597
|
+
// truth fresh and throws on any staleness or tampering. Trusting the freeze's
|
|
1598
|
+
// own recorded bytes/fields (via only a candidateDigest/treeDigest match
|
|
1599
|
+
// below) is not enough: a hand-edited freeze can recompute its own
|
|
1600
|
+
// candidateDigest to stay internally self-consistent, so this call must run
|
|
1601
|
+
// before any of that freeze's fields are relied on.
|
|
1602
|
+
if (!suppliedShapeOk) fail('malformed_receipt', 'supplied receipt fails the exact C12 schema');
|
|
1603
|
+
if (receipt.baselineAuthorityDigest !== authority.digest) fail('authority_mismatch', 'receipt baselineAuthorityDigest does not match the current authority');
|
|
1604
|
+
if (receipt.baselineGeneration !== authority.record.generation) fail('authority_mismatch', 'receipt baselineGeneration does not match the current authority generation');
|
|
1605
|
+
|
|
1606
|
+
verifyFreezeManifest(root);
|
|
1607
|
+
const freezeLoaded = readJsonFileIfExists(root, REL.freeze);
|
|
1608
|
+
if (!freezeLoaded) fail('freeze_missing', 'advanceBaseline requires a valid current freeze');
|
|
1609
|
+
if (receipt.freezeDigest !== sha256Tag(freezeLoaded.bytes)) fail('freeze_mismatch', 'receipt freezeDigest does not match the current freeze bytes');
|
|
1610
|
+
const freeze = freezeLoaded.parsed;
|
|
1611
|
+
if (receipt.candidateDigest !== freeze.candidateDigest) fail('candidate_mismatch', 'receipt candidateDigest does not match the current freeze');
|
|
1612
|
+
if (receipt.treeDigest !== freeze.treeDigest) fail('candidate_mismatch', 'receipt treeDigest does not match the current freeze');
|
|
1613
|
+
if (receipt.packageVersion !== freeze.packageVersion) fail('candidate_mismatch', 'receipt packageVersion does not match the current freeze');
|
|
1614
|
+
|
|
1615
|
+
const currentRemediated = sortedUnique(freeze.remediatedFailureIds);
|
|
1616
|
+
const receiptResolved = sortedUnique(receipt.resolvedFailureIds);
|
|
1617
|
+
if (JSON.stringify(currentRemediated) !== JSON.stringify(receiptResolved)) {
|
|
1618
|
+
fail('resolved_failure_ids_mismatch', 'receipt resolvedFailureIds diverges from the freeze remediatedFailureIds (I12)');
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (!isAncestorOrEqual(root, authority.record.baselineCommit, receipt.releaseCommit)) {
|
|
1622
|
+
fail('release_commit_unreachable', 'receipt releaseCommit is not reachable from the current baseline');
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
const artifactAbs = assertArtifactPath(root, receipt.artifactPath);
|
|
1626
|
+
const artifactDigest = sha256Tag(fs.readFileSync(artifactAbs));
|
|
1627
|
+
if (artifactDigest !== receipt.artifactDigest) fail('artifact_digest_mismatch', 'artifactDigest does not match the actual packaged bytes');
|
|
1628
|
+
|
|
1629
|
+
// Prepare-then-validate-all, strictly before the first write: Event (A)'s
|
|
1630
|
+
// bytes (receiptBytes) are already fixed and schema/match-validated above;
|
|
1631
|
+
// Event (B)'s candidate is built and independently verified against
|
|
1632
|
+
// committed C10 provenance *here*, before either file is touched. Building
|
|
1633
|
+
// this candidate can throw (e.g. an out-of-band-edit/second-uncommitted-
|
|
1634
|
+
// advance rejection) exactly as easily as any check already run above --
|
|
1635
|
+
// running it only after Event (A) had already mutated release-receipt.json
|
|
1636
|
+
// would leave that file rewritten while release-baseline.json stayed
|
|
1637
|
+
// untouched on the very failure path this whole event sequence exists to
|
|
1638
|
+
// avoid. Neither file may be touched until both candidates are known-good.
|
|
1639
|
+
const previousBytes = authority.bytes;
|
|
1640
|
+
const newRecord = {
|
|
1641
|
+
state: 'released',
|
|
1642
|
+
generation: authority.record.generation + 1,
|
|
1643
|
+
baselineCommit: receipt.releaseCommit,
|
|
1644
|
+
baselinePackageVersion: receipt.packageVersion,
|
|
1645
|
+
releaseReceiptDigest: suppliedDigest,
|
|
1646
|
+
previousBaselineDigest: sha256Tag(previousBytes),
|
|
1647
|
+
};
|
|
1648
|
+
const newBytes = verifyBaselineCandidate(root, newRecord);
|
|
1649
|
+
|
|
1650
|
+
// Event (A): persist the canonical receipt bytes verbatim, strictly before authority mutation.
|
|
1651
|
+
atomicWriteBytes(absPath(root, REL.receipt), receiptBytes);
|
|
1652
|
+
|
|
1653
|
+
// Read-after-write verification (R1.11/I11), symmetric with the (1b) repair
|
|
1654
|
+
// branch above: re-read the exact bytes this write actually produced and
|
|
1655
|
+
// reuse the same digest/schema authority to prove event (A) landed --
|
|
1656
|
+
// never trust atomicWriteBytes alone. A physically corrupted write, a
|
|
1657
|
+
// concurrent clobber, or a filesystem fault throws fail-closed here,
|
|
1658
|
+
// strictly before event (B) ever runs, so authority is never rotated and
|
|
1659
|
+
// this call never returns `published`.
|
|
1660
|
+
const persistedRead = tryReadJsonFile(root, REL.receipt);
|
|
1661
|
+
const persistedOk = Boolean(persistedRead) && sha256Tag(persistedRead.bytes) === suppliedDigest && validateReceiptShape(persistedRead.parsed);
|
|
1662
|
+
if (!persistedOk) fail('receipt_unverifiable', 'event (A) receipt write did not durably persist verifiable canonical receipt evidence (re-read failed digest/schema verification)');
|
|
1663
|
+
|
|
1664
|
+
// Event (B): rotate authority, the sole publication checkpoint, then invalidate the freeze.
|
|
1665
|
+
atomicWriteBytes(absPath(root, REL.releaseBaseline), newBytes);
|
|
1666
|
+
invalidateFreeze(root);
|
|
1667
|
+
|
|
1668
|
+
return { status: 'published', digest: suppliedDigest, record: newRecord };
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// ---------------------------------------------------------------------------
|
|
1672
|
+
// Public factory + production (root-bound) surface
|
|
1673
|
+
// ---------------------------------------------------------------------------
|
|
1674
|
+
|
|
1675
|
+
// Test/harness-only entry point: binds every D1 export to an explicit,
|
|
1676
|
+
// once-verified trusted root. Production code must never call this with a
|
|
1677
|
+
// caller-supplied path per call (I13/I19) -- use the plain top-level exports
|
|
1678
|
+
// below instead, which are always bound to this checkout's real project root.
|
|
1679
|
+
function createChangeFirewall(options = {}) {
|
|
1680
|
+
const root = resolveTrustedRoot(options.root);
|
|
1681
|
+
return {
|
|
1682
|
+
root,
|
|
1683
|
+
loadBaselineAuthority: () => loadBaselineAuthority(root),
|
|
1684
|
+
assertBaselineUsable: (opts) => assertBaselineUsable(root, opts),
|
|
1685
|
+
bootstrapBaseline: (attestation) => bootstrapBaseline(root, attestation),
|
|
1686
|
+
advanceBaseline: (receiptBytes) => advanceBaseline(root, receiptBytes),
|
|
1687
|
+
deriveProtectedChangedPaths: (baselineCommit, opts) => deriveProtectedChangedPaths(root, baselineCommit, opts),
|
|
1688
|
+
computeProtectedTreeDigest: (paths, opts) => computeProtectedTreeDigest(root, paths, opts),
|
|
1689
|
+
computeProposalDigest,
|
|
1690
|
+
computeCandidateDigest,
|
|
1691
|
+
assertHotspotChangeAllowed: (opts) => assertHotspotChangeAllowed(root, opts),
|
|
1692
|
+
createFreezeManifest: (proposal) => createFreezeManifest(root, proposal),
|
|
1693
|
+
verifyFreezeManifest: () => verifyFreezeManifest(root),
|
|
1694
|
+
invalidateFreeze: () => invalidateFreeze(root),
|
|
1695
|
+
loadFailureLedger: () => loadFailureLedger(root),
|
|
1696
|
+
assertFailureLedgerUsable: () => assertFailureLedgerUsable(root),
|
|
1697
|
+
openFailure: (input) => openFailure(root, input),
|
|
1698
|
+
classifyFailure: (input) => classifyFailure(root, input),
|
|
1699
|
+
reclassifyFailure: (input) => reclassifyFailure(root, input),
|
|
1700
|
+
resolveFailure: (input) => resolveFailure(root, input),
|
|
1701
|
+
computeFailureLedgerDigest: () => computeFailureLedgerDigest(root),
|
|
1702
|
+
deriveFailureState: (failureId) => deriveFailureState(loadFailureLedger(root), failureId),
|
|
1703
|
+
assertLegacyBridgeArtifact: () => readLegacyBridgeArtifact(root),
|
|
1704
|
+
NO_CHANGE_ATTESTATION_DIGEST,
|
|
1705
|
+
NO_FAILURES_ATTESTATION_DIGEST,
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
let cachedDefaultRoot = null;
|
|
1710
|
+
function defaultRoot() {
|
|
1711
|
+
if (!cachedDefaultRoot) cachedDefaultRoot = resolveTrustedRoot();
|
|
1712
|
+
return cachedDefaultRoot;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
module.exports = {
|
|
1716
|
+
SCHEMA_VERSION,
|
|
1717
|
+
REL,
|
|
1718
|
+
ChangeFirewallError,
|
|
1719
|
+
NO_CHANGE_ATTESTATION_DIGEST,
|
|
1720
|
+
NO_FAILURES_ATTESTATION_DIGEST,
|
|
1721
|
+
isProtectedPath,
|
|
1722
|
+
sortedUnique,
|
|
1723
|
+
sha256Tag,
|
|
1724
|
+
createChangeFirewall,
|
|
1725
|
+
|
|
1726
|
+
loadBaselineAuthority: () => loadBaselineAuthority(defaultRoot()),
|
|
1727
|
+
assertBaselineUsable: (opts) => assertBaselineUsable(defaultRoot(), opts),
|
|
1728
|
+
bootstrapBaseline: (attestation) => bootstrapBaseline(defaultRoot(), attestation),
|
|
1729
|
+
advanceBaseline: (receiptBytes) => advanceBaseline(defaultRoot(), receiptBytes),
|
|
1730
|
+
deriveProtectedChangedPaths: (baselineCommit, opts) => deriveProtectedChangedPaths(defaultRoot(), baselineCommit, opts),
|
|
1731
|
+
computeProtectedTreeDigest: (paths, opts) => computeProtectedTreeDigest(defaultRoot(), paths, opts),
|
|
1732
|
+
computeProposalDigest,
|
|
1733
|
+
computeCandidateDigest,
|
|
1734
|
+
assertHotspotChangeAllowed: (opts) => assertHotspotChangeAllowed(defaultRoot(), opts),
|
|
1735
|
+
createFreezeManifest: (proposal) => createFreezeManifest(defaultRoot(), proposal),
|
|
1736
|
+
verifyFreezeManifest: () => verifyFreezeManifest(defaultRoot()),
|
|
1737
|
+
invalidateFreeze: () => invalidateFreeze(defaultRoot()),
|
|
1738
|
+
|
|
1739
|
+
loadFailureLedger: () => loadFailureLedger(defaultRoot()),
|
|
1740
|
+
assertFailureLedgerUsable: () => assertFailureLedgerUsable(defaultRoot()),
|
|
1741
|
+
openFailure: (input) => openFailure(defaultRoot(), input),
|
|
1742
|
+
classifyFailure: (input) => classifyFailure(defaultRoot(), input),
|
|
1743
|
+
reclassifyFailure: (input) => reclassifyFailure(defaultRoot(), input),
|
|
1744
|
+
resolveFailure: (input) => resolveFailure(defaultRoot(), input),
|
|
1745
|
+
computeFailureLedgerDigest: () => computeFailureLedgerDigest(defaultRoot()),
|
|
1746
|
+
deriveFailureState: (failureId) => deriveFailureState(loadFailureLedger(defaultRoot()), failureId),
|
|
1747
|
+
assertLegacyBridgeArtifact: () => readLegacyBridgeArtifact(defaultRoot()),
|
|
1748
|
+
};
|