@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.4
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__/codex-hooks.test.js +794 -21
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +1838 -112
- package/bin/__tests__/installer-safety.test.js +82 -29
- package/bin/__tests__/opencode-spec-gate.test.js +799 -0
- package/bin/__tests__/package-inventory.test.js +841 -10
- package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2469 -71
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +153 -202
- package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
- package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
- package/src/claude/hooks/completion-authority-check.cjs +56 -0
- package/src/claude/hooks/completion-authority-state.cjs +527 -0
- package/src/claude/hooks/completion-authority.cjs +154 -0
- 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 +77 -30
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +181 -139
- package/src/claude/hooks/spec-state.cjs +112 -47
- package/src/claude/migration-manifest.json +15 -2
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-final-state.cjs +244 -0
- package/src/claude/scripts/spec-ground.cjs +535 -173
- package/src/claude/scripts/spec-readiness.cjs +118 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +501 -0
- package/src/claude/scripts/spec-scaffold.cjs +1945 -55
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3204 -262
- package/src/claude/scripts/workflow-policy.cjs +1888 -203
- package/src/claude/settings/settings.json +12 -0
- package/src/claude/skills/code-review/SKILL.md +94 -66
- 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 +138 -283
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +123 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +192 -397
- 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 +200 -260
- 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 +46 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
- 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 +26 -67
- package/src/claude/skills/specs/templates/task.md +30 -82
- package/src/claude/skills/sync/SKILL.md +45 -15
- package/src/claude/skills/sync/references/sync-protocols.md +34 -16
- package/src/claude/skills/test/SKILL.md +151 -167
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +63 -0
- package/src/codex/hooks/completion-authority-state.cjs +527 -0
- package/src/codex/hooks/completion-authority.cjs +106 -0
- package/src/codex/hooks/lib/hook-context.cjs +58 -4
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +38 -16
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
- package/src/codex/hooks/lib/spec-utils.cjs +104 -27
- package/src/codex/hooks/privacy-approval.cjs +1 -1
- package/src/codex/hooks/privacy-block.cjs +67 -11
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +151 -24
- package/src/codex/hooks/spec-state.cjs +65 -12
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +16 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '../../..');
|
|
11
|
+
const CLAUDE_HOOK = path.join(__dirname, '..', 'completion-authority.cjs');
|
|
12
|
+
const CODEX_HOOKS = path.join(ROOT, 'codex/hooks');
|
|
13
|
+
const POLICY = require(path.join(ROOT, 'claude/scripts/workflow-policy.cjs'));
|
|
14
|
+
const PROVENANCE = require(path.join(ROOT, 'claude/scripts/provenance.cjs'));
|
|
15
|
+
const AUTHORITY_CHECK = require(path.join(ROOT, 'claude/hooks/completion-authority-check.cjs'));
|
|
16
|
+
const RESOLVER = require(path.join(ROOT, 'claude/scripts/spec-resolver.cjs'));
|
|
17
|
+
const { copyClaudeTestRuntime } = require(path.join(ROOT, '../bin/__tests__/test-runtime-dependency-closure.cjs'));
|
|
18
|
+
const FEATURE = 'authority-fixture';
|
|
19
|
+
const TASK = 'tasks/closeout.md';
|
|
20
|
+
const TEST_HOMES = new Map();
|
|
21
|
+
|
|
22
|
+
function homeFor(root) {
|
|
23
|
+
if (!TEST_HOMES.has(root)) {
|
|
24
|
+
const home = path.join(os.tmpdir(), 'cafekit-authority-homes', path.basename(root));
|
|
25
|
+
fs.mkdirSync(home, { recursive: true });
|
|
26
|
+
TEST_HOMES.set(root, home);
|
|
27
|
+
}
|
|
28
|
+
return TEST_HOMES.get(root);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanupFixture(root) {
|
|
32
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
33
|
+
const home = TEST_HOMES.get(root);
|
|
34
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
35
|
+
TEST_HOMES.delete(root);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function gitFixture() {
|
|
39
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-authority-'));
|
|
40
|
+
for (const args of [
|
|
41
|
+
['init', '-q'],
|
|
42
|
+
['config', 'user.email', 'cafekit@example.invalid'],
|
|
43
|
+
['config', 'user.name', 'CafeKit Test'],
|
|
44
|
+
['commit', '--allow-empty', '-qm', 'fixture'],
|
|
45
|
+
]) {
|
|
46
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
47
|
+
assert.equal(result.status, 0, result.stderr);
|
|
48
|
+
}
|
|
49
|
+
return root;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function context(root, session = 'session-a', featureName = FEATURE) {
|
|
53
|
+
return PROVENANCE.deriveRuntimeContext({
|
|
54
|
+
projectRoot: root,
|
|
55
|
+
specsRoot: path.join(root, 'specs'),
|
|
56
|
+
specFile: path.join(root, 'specs', featureName, 'spec.json'),
|
|
57
|
+
featureName,
|
|
58
|
+
runtimeSession: session,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function prepare(root, { lane = 'Standard', status = 'in_progress', phase = 'closeout', proofs = true, feature = FEATURE } = {}) {
|
|
63
|
+
const featureDir = path.join(root, 'specs', feature);
|
|
64
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
65
|
+
const policyInput = lane === 'Critical'
|
|
66
|
+
? { riskSignals: { auth: true }, assurance_level: 'Strict' }
|
|
67
|
+
: lane === 'Direct'
|
|
68
|
+
? { reversible: true, lowRisk: true, isolated: true }
|
|
69
|
+
: { riskSignals: {} };
|
|
70
|
+
const spec = {
|
|
71
|
+
status,
|
|
72
|
+
current_phase: phase,
|
|
73
|
+
feature_name: feature,
|
|
74
|
+
workflow_policy: POLICY.canonicalWorkflowPolicySnapshot(policyInput),
|
|
75
|
+
task_registry: { [TASK]: { status: 'done', completed_at: '2026-08-12T00:00:00.000Z' } },
|
|
76
|
+
};
|
|
77
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify(spec));
|
|
78
|
+
const runtime = context(root, 'session-a', feature);
|
|
79
|
+
const body = [
|
|
80
|
+
'Verification: PASS',
|
|
81
|
+
'Command: node --test',
|
|
82
|
+
'Exit: 0',
|
|
83
|
+
`Base: ${runtime.base}`,
|
|
84
|
+
`Head: ${runtime.head}`,
|
|
85
|
+
].join('\n');
|
|
86
|
+
fs.writeFileSync(path.join(featureDir, TASK), `# Closeout\n\nStatus: done\n\n## Evidence\n\n${body}\n`);
|
|
87
|
+
fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), [
|
|
88
|
+
`Feature: ${feature}`,
|
|
89
|
+
'Expected: final integration verification passes',
|
|
90
|
+
'Observed: final integration verification passed',
|
|
91
|
+
body,
|
|
92
|
+
'',
|
|
93
|
+
].join('\n'));
|
|
94
|
+
if (proofs) {
|
|
95
|
+
spec.proofs = {
|
|
96
|
+
needsInspection: { receipt: body },
|
|
97
|
+
needsResearchGrounding: { receipt: body },
|
|
98
|
+
};
|
|
99
|
+
if (lane === 'Critical') {
|
|
100
|
+
spec.proofs.needsIndependentAudit = {
|
|
101
|
+
schema_version: '1',
|
|
102
|
+
reviewer_session_id: 'reviewer-session',
|
|
103
|
+
implementation_session_id: 'session-a',
|
|
104
|
+
expected_provenance: { base: runtime.base, head: runtime.head },
|
|
105
|
+
evidence: 'independent closeout review passed',
|
|
106
|
+
verdict: 'PASS',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify(spec));
|
|
110
|
+
}
|
|
111
|
+
return { featureDir, specFile: path.join(featureDir, 'spec.json') };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function adapter(kind, root) {
|
|
115
|
+
const home = homeFor(root);
|
|
116
|
+
fs.mkdirSync(home, { recursive: true });
|
|
117
|
+
if (kind === 'claude') {
|
|
118
|
+
return {
|
|
119
|
+
root,
|
|
120
|
+
hook: CLAUDE_HOOK,
|
|
121
|
+
sessionHook: path.join(__dirname, '..', 'session.cjs'),
|
|
122
|
+
state: path.join(home, '.cafekit', 'completion-authority', 'projects'),
|
|
123
|
+
home,
|
|
124
|
+
env: { ...process.env, HOME: home, USERPROFILE: home, PROJECT_ROOT: root },
|
|
125
|
+
run(mode, payload) {
|
|
126
|
+
return spawnSync(process.execPath, [CLAUDE_HOOK, mode], {
|
|
127
|
+
cwd: root,
|
|
128
|
+
env: this.env,
|
|
129
|
+
input: JSON.stringify({ cwd: root, session_id: 'session-a', ...payload }),
|
|
130
|
+
encoding: 'utf8',
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
copyClaudeTestRuntime(path.join(ROOT, '..'), path.join(root, '.codex'));
|
|
136
|
+
const hooks = path.join(root, '.codex/hooks');
|
|
137
|
+
fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
|
|
138
|
+
for (const helper of ['privacy-command-analysis.cjs', 'runtime-path-safety.cjs']) {
|
|
139
|
+
fs.copyFileSync(
|
|
140
|
+
path.join(ROOT, 'claude/hooks/lib', helper),
|
|
141
|
+
path.join(hooks, 'lib', helper),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
root,
|
|
146
|
+
hook: path.join(hooks, 'completion-authority.cjs'),
|
|
147
|
+
sessionHook: path.join(hooks, 'session.cjs'),
|
|
148
|
+
state: path.join(home, '.cafekit', 'completion-authority', 'projects'),
|
|
149
|
+
home,
|
|
150
|
+
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
151
|
+
run(mode, payload) {
|
|
152
|
+
return spawnSync(process.execPath, [path.join(hooks, 'completion-authority.cjs'), mode], {
|
|
153
|
+
cwd: root,
|
|
154
|
+
env: this.env,
|
|
155
|
+
input: JSON.stringify({ cwd: root, session_id: 'session-a', ...payload }),
|
|
156
|
+
encoding: 'utf8',
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function stateFiles(runtime) {
|
|
163
|
+
if (!fs.existsSync(runtime.state)) return [];
|
|
164
|
+
return fs.readdirSync(runtime.state, { withFileTypes: true })
|
|
165
|
+
.filter((entry) => entry.isDirectory())
|
|
166
|
+
.flatMap((entry) => fs.readdirSync(path.join(runtime.state, entry.name)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function stateRecord(runtime, prefix) {
|
|
170
|
+
const files = stateFiles(runtime).filter((name) => name.startsWith(prefix));
|
|
171
|
+
assert.equal(files.length, 1, `expected exactly one ${prefix} authority record`);
|
|
172
|
+
const projects = fs.readdirSync(runtime.state, { withFileTypes: true }).find((entry) => entry.isDirectory());
|
|
173
|
+
return path.join(runtime.state, projects.name, files[0]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeRuntime(runtime, value) {
|
|
177
|
+
const platformDir = runtime.hook.includes(`${path.sep}.codex${path.sep}`) ? '.codex' : '.claude';
|
|
178
|
+
const dir = path.join(runtime.root, platformDir);
|
|
179
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
180
|
+
fs.writeFileSync(path.join(dir, 'runtime.json'), `${JSON.stringify(value)}\n`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sessionStart(runtime, source = 'startup', sessionId = 'session-a') {
|
|
184
|
+
return spawnSync(process.execPath, [runtime.sessionHook], {
|
|
185
|
+
cwd: runtime.root,
|
|
186
|
+
env: runtime.env,
|
|
187
|
+
input: JSON.stringify({ cwd: runtime.root, session_id: sessionId, source, hook_event_name: 'SessionStart' }),
|
|
188
|
+
encoding: 'utf8',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function stop(runtime, payload = {}) {
|
|
193
|
+
return runtime.run('--stop', { hook_event_name: 'Stop', ...payload });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function approve(runtime, nonce, payload = {}) {
|
|
197
|
+
return runtime.run('--approve', {
|
|
198
|
+
hook_event_name: 'UserPromptSubmit',
|
|
199
|
+
prompt: `APPROVE CAFEKIT COMPLETION ${nonce}`,
|
|
200
|
+
...payload,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function block(result) {
|
|
205
|
+
assert.equal(result.status, 0, result.stderr);
|
|
206
|
+
const output = result.stdout.trim();
|
|
207
|
+
assert.notEqual(output, '', 'expected a controlled block');
|
|
208
|
+
const line = output.split('\n').find((value) => value.includes('"decision"'));
|
|
209
|
+
return JSON.parse(line || output);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function nonceFrom(result) {
|
|
213
|
+
const body = block(result);
|
|
214
|
+
const match = body.reason.match(/APPROVE CAFEKIT COMPLETION ([a-f0-9]{24})/);
|
|
215
|
+
assert.ok(match, body.reason);
|
|
216
|
+
return { body, nonce: match[1] };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const kind of ['claude', 'codex']) {
|
|
220
|
+
test(`${kind}: authoring, partial execution, done-before-closeout, and stop loops are silent`, () => {
|
|
221
|
+
const root = gitFixture();
|
|
222
|
+
try {
|
|
223
|
+
const runtime = adapter(kind, root);
|
|
224
|
+
const fixture = prepare(root, { phase: 'authoring' });
|
|
225
|
+
let spec = JSON.parse(fs.readFileSync(fixture.specFile, 'utf8'));
|
|
226
|
+
spec.task_registry = {};
|
|
227
|
+
fs.writeFileSync(fixture.specFile, JSON.stringify(spec));
|
|
228
|
+
fs.rmSync(path.join(fixture.featureDir, 'feature-receipt.md'), { force: true });
|
|
229
|
+
assert.equal(stop(runtime).stdout.trim(), '', 'Compact taskless authoring pause must be silent');
|
|
230
|
+
|
|
231
|
+
spec.task_registry = { [TASK]: { status: 'in_progress', completed_at: null } };
|
|
232
|
+
spec.current_phase = 'implementation';
|
|
233
|
+
fs.writeFileSync(fixture.specFile, JSON.stringify(spec));
|
|
234
|
+
assert.equal(stop(runtime).stdout.trim(), '', 'partial task execution must be silent');
|
|
235
|
+
|
|
236
|
+
spec.task_registry[TASK] = { status: 'done', completed_at: '2026-08-12T00:00:00.000Z' };
|
|
237
|
+
fs.writeFileSync(fixture.specFile, JSON.stringify(spec));
|
|
238
|
+
assert.equal(stop(runtime).stdout.trim(), '', 'done tasks without explicit closeout must be silent');
|
|
239
|
+
assert.equal(stateFiles(runtime).length, 0, 'inactive stops must not mutate authority state');
|
|
240
|
+
|
|
241
|
+
const loop = stop(runtime, { stop_hook_active: true });
|
|
242
|
+
assert.equal(loop.status, 0, loop.stderr);
|
|
243
|
+
assert.equal(loop.stdout.trim(), '', 'recursive Stop invocation must be silent');
|
|
244
|
+
|
|
245
|
+
spec.current_phase = 'closeout';
|
|
246
|
+
fs.writeFileSync(fixture.specFile, JSON.stringify(spec));
|
|
247
|
+
assert.match(block(stop(runtime)).reason, /technical completion proof|feature-receipt/i);
|
|
248
|
+
} finally {
|
|
249
|
+
cleanupFixture(root);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test(`${kind}: exact user approval is one-time and runtime-bound`, () => {
|
|
254
|
+
const root = gitFixture();
|
|
255
|
+
try {
|
|
256
|
+
const runtime = adapter(kind, root);
|
|
257
|
+
prepare(root);
|
|
258
|
+
const first = nonceFrom(stop(runtime));
|
|
259
|
+
const forged = runtime.run('--approve', { hook_event_name: 'UserPromptSubmit', user_authorized: true });
|
|
260
|
+
assert.equal(forged.status, 0);
|
|
261
|
+
assert.equal(stateFiles(runtime).filter((name) => name.startsWith('grant-')).length, 0);
|
|
262
|
+
approve(runtime, first.nonce);
|
|
263
|
+
assert.equal(stop(runtime).stdout.trim(), '', 'approved closeout must pass once');
|
|
264
|
+
const replay = approve(runtime, first.nonce);
|
|
265
|
+
assert.equal(replay.status, 0);
|
|
266
|
+
const second = nonceFrom(stop(runtime));
|
|
267
|
+
assert.notEqual(second.nonce, first.nonce, 'replayed nonce must not grant a second closeout');
|
|
268
|
+
} finally {
|
|
269
|
+
cleanupFixture(root);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test(`${kind}: valid Critical closeout succeeds only after exact approval`, () => {
|
|
274
|
+
const root = gitFixture();
|
|
275
|
+
try {
|
|
276
|
+
const runtime = adapter(kind, root);
|
|
277
|
+
prepare(root, { lane: 'Critical' });
|
|
278
|
+
const pending = nonceFrom(stop(runtime));
|
|
279
|
+
assert.equal(stop(runtime).stdout.match(/APPROVE CAFEKIT COMPLETION/g).length, 1);
|
|
280
|
+
approve(runtime, pending.nonce);
|
|
281
|
+
assert.equal(stop(runtime).stdout.trim(), '');
|
|
282
|
+
} finally {
|
|
283
|
+
cleanupFixture(root);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test(`${kind}: wrong nonce/session/target and forged approval fields never grant`, () => {
|
|
288
|
+
const root = gitFixture();
|
|
289
|
+
try {
|
|
290
|
+
const runtime = adapter(kind, root);
|
|
291
|
+
prepare(root);
|
|
292
|
+
const pending = nonceFrom(stop(runtime));
|
|
293
|
+
const replacement = pending.nonce.endsWith('0') ? '1' : '0';
|
|
294
|
+
const wrongNonce = approve(runtime, `${pending.nonce.slice(0, -1)}${replacement}`);
|
|
295
|
+
assert.equal(wrongNonce.status, 0);
|
|
296
|
+
assert.equal(stateFiles(runtime).some((name) => name.startsWith('grant-')), false);
|
|
297
|
+
const wrongEvent = approve(runtime, pending.nonce, { hook_event_name: 'Stop' });
|
|
298
|
+
assert.equal(wrongEvent.status, 0);
|
|
299
|
+
assert.equal(stateFiles(runtime).some((name) => name.startsWith('grant-')), false);
|
|
300
|
+
const wrongSession = approve(runtime, pending.nonce, { session_id: 'other-session' });
|
|
301
|
+
assert.equal(wrongSession.status, 0);
|
|
302
|
+
assert.equal(stateFiles(runtime).some((name) => name.startsWith('grant-')), false);
|
|
303
|
+
const forged = runtime.run('--approve', {
|
|
304
|
+
hook_event_name: 'UserPromptSubmit',
|
|
305
|
+
user_authorized: true,
|
|
306
|
+
userAuthorized: true,
|
|
307
|
+
session: { id: 'session-a', user_authorized: true },
|
|
308
|
+
});
|
|
309
|
+
assert.equal(forged.status, 0);
|
|
310
|
+
assert.equal(stateFiles(runtime).some((name) => name.startsWith('grant-')), false);
|
|
311
|
+
approve(runtime, pending.nonce);
|
|
312
|
+
const wrongStop = stop(runtime, {
|
|
313
|
+
session_id: 'other-session',
|
|
314
|
+
featureName: 'wrong-feature',
|
|
315
|
+
runtime_context: { project_root: '/forged', head: 'forged', context_id: 'forged' },
|
|
316
|
+
user_authorized: true,
|
|
317
|
+
});
|
|
318
|
+
assert.equal(block(wrongStop).decision, 'block');
|
|
319
|
+
} finally {
|
|
320
|
+
cleanupFixture(root);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test(`${kind}: status, policy, registry, and artifact/proof mutation invalidate approval`, () => {
|
|
325
|
+
const root = gitFixture();
|
|
326
|
+
try {
|
|
327
|
+
const runtime = adapter(kind, root);
|
|
328
|
+
const fixture = prepare(root);
|
|
329
|
+
const initial = nonceFrom(stop(runtime));
|
|
330
|
+
approve(runtime, initial.nonce);
|
|
331
|
+
const spec = JSON.parse(fs.readFileSync(fixture.specFile, 'utf8'));
|
|
332
|
+
spec.status = 'completed';
|
|
333
|
+
fs.writeFileSync(fixture.specFile, JSON.stringify(spec));
|
|
334
|
+
const statusMutation = nonceFrom(stop(runtime));
|
|
335
|
+
assert.notEqual(statusMutation.nonce, initial.nonce);
|
|
336
|
+
|
|
337
|
+
approve(runtime, statusMutation.nonce);
|
|
338
|
+
const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
|
|
339
|
+
spec.workflow_policy = direct;
|
|
340
|
+
fs.writeFileSync(fixture.specFile, JSON.stringify(spec));
|
|
341
|
+
const policyMutation = block(stop(runtime));
|
|
342
|
+
assert.match(policyMutation.reason, /monotonic|downgraded/i);
|
|
343
|
+
spec.task_registry = {};
|
|
344
|
+
fs.writeFileSync(fixture.specFile, JSON.stringify(spec));
|
|
345
|
+
const missingRegistry = block(stop(runtime));
|
|
346
|
+
assert.match(missingRegistry.reason, /monotonic|downgraded/i);
|
|
347
|
+
|
|
348
|
+
prepare(root, { lane: 'Direct' });
|
|
349
|
+
const directRuntime = adapter(kind, root);
|
|
350
|
+
const artifactMutation = block(stop(directRuntime));
|
|
351
|
+
assert.match(artifactMutation.reason, /monotonic|downgraded/i);
|
|
352
|
+
} finally {
|
|
353
|
+
cleanupFixture(root);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test(`${kind}: completed, invalid/malformed candidates, Critical fake proofs, and Direct no-spec are fail-closed`, () => {
|
|
358
|
+
const root = gitFixture();
|
|
359
|
+
try {
|
|
360
|
+
const runtime = adapter(kind, root);
|
|
361
|
+
for (const input of ['', 'null', '[]']) {
|
|
362
|
+
const malformed = spawnSync(process.execPath, [runtime.hook, '--stop'], {
|
|
363
|
+
cwd: root,
|
|
364
|
+
env: runtime.env,
|
|
365
|
+
input,
|
|
366
|
+
encoding: 'utf8',
|
|
367
|
+
});
|
|
368
|
+
assert.equal(malformed.status, 0);
|
|
369
|
+
assert.equal(block(malformed).decision, 'block');
|
|
370
|
+
}
|
|
371
|
+
prepare(root, { status: 'completed' });
|
|
372
|
+
assert.match(nonceFrom(stop(runtime)).body.reason, /approval/i);
|
|
373
|
+
|
|
374
|
+
const specFile = path.join(root, 'specs', FEATURE, 'spec.json');
|
|
375
|
+
const spec = JSON.parse(fs.readFileSync(specFile, 'utf8'));
|
|
376
|
+
spec.task_registry[TASK].completed_at = null;
|
|
377
|
+
fs.writeFileSync(specFile, JSON.stringify(spec));
|
|
378
|
+
assert.match(block(stop(runtime)).reason, /completed_at/i);
|
|
379
|
+
|
|
380
|
+
fs.writeFileSync(specFile, '{ malformed');
|
|
381
|
+
assert.equal(block(stop(runtime)).decision, 'block');
|
|
382
|
+
|
|
383
|
+
fs.rmSync(path.join(root, 'specs'), { recursive: true, force: true });
|
|
384
|
+
const disappeared = block(stop(runtime));
|
|
385
|
+
assert.match(disappeared.reason, /disappeared|persisted/i);
|
|
386
|
+
|
|
387
|
+
const noSpecRoot = gitFixture();
|
|
388
|
+
try {
|
|
389
|
+
const noSpec = adapter(kind, noSpecRoot);
|
|
390
|
+
assert.equal(stop(noSpec).stdout.trim(), '', 'true no-spec Direct path remains silent');
|
|
391
|
+
} finally {
|
|
392
|
+
cleanupFixture(noSpecRoot);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
prepare(root, { lane: 'Critical', proofs: false });
|
|
396
|
+
const critical = adapter(kind, root);
|
|
397
|
+
const criticalBlock = block(stop(critical));
|
|
398
|
+
assert.match(criticalBlock.reason, /technical completion proof|needsIndependentAudit/i);
|
|
399
|
+
|
|
400
|
+
fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
|
|
401
|
+
fs.writeFileSync(path.join(root, '.claude', 'runtime.json'), JSON.stringify({ spec: { completion_gate: false } }));
|
|
402
|
+
if (kind === 'codex') {
|
|
403
|
+
fs.rmSync(path.join(root, '.claude'), { recursive: true, force: true });
|
|
404
|
+
fs.mkdirSync(path.join(root, '.codex'), { recursive: true });
|
|
405
|
+
fs.writeFileSync(path.join(root, '.codex', 'runtime.json'), JSON.stringify({ spec: { completion_gate: false } }));
|
|
406
|
+
}
|
|
407
|
+
assert.match(block(stop(critical)).reason, /worker-writable|cannot authorize/i);
|
|
408
|
+
} finally {
|
|
409
|
+
cleanupFixture(root);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test(`${kind}: copied, unsigned, MAC-invalid, wrong-key, and project-local grants never authorize`, () => {
|
|
414
|
+
const root = gitFixture();
|
|
415
|
+
try {
|
|
416
|
+
const runtime = adapter(kind, root);
|
|
417
|
+
prepare(root);
|
|
418
|
+
const localDir = path.join(root, kind === 'claude' ? '.claude/hooks/.logs/completion-authority' : '.codex/hooks/.logs/completion-authority');
|
|
419
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
420
|
+
fs.writeFileSync(path.join(localDir, 'grant-deadbeefdeadbeefdeadbeef.json'), JSON.stringify({ kind: 'grant', nonce: 'deadbeefdeadbeefdeadbeef', binding: {} }));
|
|
421
|
+
const pending = nonceFrom(stop(runtime));
|
|
422
|
+
approve(runtime, pending.nonce);
|
|
423
|
+
assert.equal(stop(runtime).stdout.trim(), '', 'project-local legacy grant must be ignored');
|
|
424
|
+
fs.rmSync(localDir, { recursive: true, force: true });
|
|
425
|
+
|
|
426
|
+
const retry = nonceFrom(stop(runtime));
|
|
427
|
+
const pendingFile = stateRecord(runtime, 'pending-');
|
|
428
|
+
const copiedGrant = path.join(path.dirname(pendingFile), `grant-${retry.nonce}.json`);
|
|
429
|
+
fs.copyFileSync(pendingFile, copiedGrant);
|
|
430
|
+
const copiedApproval = approve(runtime, retry.nonce);
|
|
431
|
+
assert.doesNotMatch(copiedApproval.stdout, /accepted/i);
|
|
432
|
+
assert.equal(stateFiles(runtime).some((name) => name.startsWith('grant-')), true);
|
|
433
|
+
fs.rmSync(copiedGrant);
|
|
434
|
+
approve(runtime, retry.nonce);
|
|
435
|
+
assert.equal(stop(runtime).stdout.trim(), '');
|
|
436
|
+
|
|
437
|
+
const clean = gitFixture();
|
|
438
|
+
try {
|
|
439
|
+
const cleanRuntime = adapter(kind, clean);
|
|
440
|
+
prepare(clean);
|
|
441
|
+
const cleanPending = nonceFrom(stop(cleanRuntime));
|
|
442
|
+
const cleanFile = stateRecord(cleanRuntime, 'pending-');
|
|
443
|
+
const record = JSON.parse(fs.readFileSync(cleanFile, 'utf8'));
|
|
444
|
+
record.mac = `${record.mac.slice(0, -1)}${record.mac.endsWith('0') ? '1' : '0'}`;
|
|
445
|
+
fs.writeFileSync(cleanFile, JSON.stringify(record));
|
|
446
|
+
assert.doesNotMatch(approve(cleanRuntime, cleanPending.nonce).stdout, /accepted/i);
|
|
447
|
+
} finally {
|
|
448
|
+
cleanupFixture(clean);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const wrongKeyRoot = gitFixture();
|
|
452
|
+
try {
|
|
453
|
+
const wrongKeyRuntime = adapter(kind, wrongKeyRoot);
|
|
454
|
+
prepare(wrongKeyRoot);
|
|
455
|
+
const wrongKeyPending = nonceFrom(stop(wrongKeyRuntime));
|
|
456
|
+
fs.writeFileSync(path.join(wrongKeyRuntime.home, '.cafekit', 'completion-authority', 'hmac.key'), Buffer.alloc(32, 7));
|
|
457
|
+
assert.doesNotMatch(approve(wrongKeyRuntime, wrongKeyPending.nonce).stdout, /accepted/i);
|
|
458
|
+
} finally {
|
|
459
|
+
cleanupFixture(wrongKeyRoot);
|
|
460
|
+
}
|
|
461
|
+
} finally {
|
|
462
|
+
cleanupFixture(root);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test(`${kind}: replay and signed-record mutation remain one-use and bound`, () => {
|
|
467
|
+
const root = gitFixture();
|
|
468
|
+
try {
|
|
469
|
+
const runtime = adapter(kind, root);
|
|
470
|
+
prepare(root);
|
|
471
|
+
const pending = nonceFrom(stop(runtime));
|
|
472
|
+
approve(runtime, pending.nonce);
|
|
473
|
+
assert.equal(stop(runtime).stdout.trim(), '');
|
|
474
|
+
assert.doesNotMatch(approve(runtime, pending.nonce).stdout, /accepted/i);
|
|
475
|
+
const next = nonceFrom(stop(runtime));
|
|
476
|
+
const specFile = path.join(root, 'specs', FEATURE, 'spec.json');
|
|
477
|
+
const spec = JSON.parse(fs.readFileSync(specFile, 'utf8'));
|
|
478
|
+
spec.task_registry[TASK].completed_at = '2026-08-13T00:00:00.000Z';
|
|
479
|
+
fs.writeFileSync(specFile, JSON.stringify(spec));
|
|
480
|
+
approve(runtime, next.nonce);
|
|
481
|
+
const mutated = block(stop(runtime));
|
|
482
|
+
assert.match(mutated.reason, /approval|technical|binding|proof/i);
|
|
483
|
+
} finally {
|
|
484
|
+
cleanupFixture(root);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test(`${kind}: policy baseline is recorded before proof completion, survives SessionStart, and blocks Critical to Direct`, () => {
|
|
489
|
+
const root = gitFixture();
|
|
490
|
+
try {
|
|
491
|
+
const runtime = adapter(kind, root);
|
|
492
|
+
prepare(root, { lane: 'Critical', proofs: false });
|
|
493
|
+
const incomplete = block(stop(runtime));
|
|
494
|
+
assert.match(incomplete.reason, /technical|proof|missing/i);
|
|
495
|
+
assert.equal(stateFiles(runtime).filter((name) => name.startsWith('baseline-')).length, 1);
|
|
496
|
+
|
|
497
|
+
prepare(root, { lane: 'Critical' });
|
|
498
|
+
const pending = nonceFrom(stop(runtime));
|
|
499
|
+
assert.equal(sessionStart(runtime).status, 0);
|
|
500
|
+
assert.equal(stateFiles(runtime).filter((name) => name.startsWith('baseline-')).length, 1);
|
|
501
|
+
assert.equal(stateFiles(runtime).some((name) => name.startsWith('pending-') || name.startsWith('grant-')), false);
|
|
502
|
+
const freshPending = nonceFrom(stop(runtime));
|
|
503
|
+
assert.notEqual(freshPending.nonce, pending.nonce);
|
|
504
|
+
|
|
505
|
+
approve(runtime, freshPending.nonce);
|
|
506
|
+
assert.equal(stop(runtime).stdout.trim(), '');
|
|
507
|
+
const retry = nonceFrom(stop(runtime));
|
|
508
|
+
approve(runtime, retry.nonce);
|
|
509
|
+
const specFile = path.join(root, 'specs', FEATURE, 'spec.json');
|
|
510
|
+
const spec = JSON.parse(fs.readFileSync(specFile, 'utf8'));
|
|
511
|
+
spec.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Routine' });
|
|
512
|
+
fs.writeFileSync(specFile, JSON.stringify(spec));
|
|
513
|
+
const downgrade = block(stop(runtime));
|
|
514
|
+
assert.match(downgrade.reason, /monotonic|downgraded/i);
|
|
515
|
+
} finally {
|
|
516
|
+
cleanupFixture(root);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test(`${kind}: Critical feature A cannot raise ceremony for Compact Routine feature B`, () => {
|
|
521
|
+
const root = gitFixture();
|
|
522
|
+
try {
|
|
523
|
+
const runtime = adapter(kind, root);
|
|
524
|
+
prepare(root, { feature: 'critical-feature-a', lane: 'Critical' });
|
|
525
|
+
nonceFrom(stop(runtime));
|
|
526
|
+
assert.equal(stateFiles(runtime).filter((name) => name === 'policy-floor.json').length, 1);
|
|
527
|
+
|
|
528
|
+
fs.rmSync(path.join(root, 'specs', 'critical-feature-a'), { recursive: true, force: true });
|
|
529
|
+
prepare(root, { feature: 'compact-feature-b', lane: 'Standard' });
|
|
530
|
+
const light = nonceFrom(stop(runtime));
|
|
531
|
+
assert.match(light.body.reason, /approval/i);
|
|
532
|
+
const floor = JSON.parse(fs.readFileSync(stateRecord(runtime, 'policy-floor'), 'utf8'));
|
|
533
|
+
assert.deepEqual(floor.policy, POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Routine' }));
|
|
534
|
+
assert.equal(stateFiles(runtime).filter((name) => name.startsWith('baseline-')).length, 2);
|
|
535
|
+
} finally {
|
|
536
|
+
cleanupFixture(root);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test(`${kind}: Critical policy floor survives same-path recreation and a new session`, () => {
|
|
541
|
+
const root = gitFixture();
|
|
542
|
+
try {
|
|
543
|
+
const runtime = adapter(kind, root);
|
|
544
|
+
const featureDir = path.join(root, 'specs', FEATURE);
|
|
545
|
+
prepare(root, { lane: 'Critical' });
|
|
546
|
+
nonceFrom(stop(runtime));
|
|
547
|
+
fs.rmSync(featureDir, { recursive: true, force: true });
|
|
548
|
+
prepare(root, { lane: 'Direct' });
|
|
549
|
+
const recreated = block(stop(runtime));
|
|
550
|
+
assert.match(recreated.reason, /project policy floor|monotonic|downgraded/i);
|
|
551
|
+
} finally {
|
|
552
|
+
cleanupFixture(root);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const sessionRoot = gitFixture();
|
|
556
|
+
try {
|
|
557
|
+
const runtime = adapter(kind, sessionRoot);
|
|
558
|
+
prepare(sessionRoot, { lane: 'Critical' });
|
|
559
|
+
nonceFrom(stop(runtime));
|
|
560
|
+
assert.equal(sessionStart(runtime, 'startup', 'session-b').status, 0);
|
|
561
|
+
assert.equal(stateFiles(runtime).filter((name) => name === 'policy-floor.json').length, 1);
|
|
562
|
+
assert.equal(stateFiles(runtime).some((name) => name.startsWith('pending-') || name.startsWith('grant-')), false);
|
|
563
|
+
prepare(sessionRoot, { lane: 'Direct' });
|
|
564
|
+
const newSession = block(stop(runtime, { session_id: 'session-b' }));
|
|
565
|
+
assert.match(newSession.reason, /project policy floor|monotonic|downgraded/i);
|
|
566
|
+
} finally {
|
|
567
|
+
cleanupFixture(sessionRoot);
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test(`${kind}: project policy floor prevents risk removal without coupling assurance to durable state`, () => {
|
|
572
|
+
const root = gitFixture();
|
|
573
|
+
try {
|
|
574
|
+
const runtime = adapter(kind, root);
|
|
575
|
+
prepare(root, { lane: 'Critical' });
|
|
576
|
+
const specFile = path.join(root, 'specs', FEATURE, 'spec.json');
|
|
577
|
+
const initialSpec = JSON.parse(fs.readFileSync(specFile, 'utf8'));
|
|
578
|
+
initialSpec.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ riskSignals: { auth: true, privacy: true } });
|
|
579
|
+
assert.equal(POLICY.readWorkflowPolicySnapshot(initialSpec).proof_obligations.includes('needsDurableTaskState'), false);
|
|
580
|
+
fs.writeFileSync(specFile, JSON.stringify(initialSpec));
|
|
581
|
+
nonceFrom(stop(runtime));
|
|
582
|
+
const spec = JSON.parse(fs.readFileSync(specFile, 'utf8'));
|
|
583
|
+
assert.ok(spec.workflow_policy.risks.length > 0);
|
|
584
|
+
assert.ok(POLICY.readWorkflowPolicySnapshot(spec).proof_obligations.length > 0);
|
|
585
|
+
spec.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ riskSignals: { auth: true } });
|
|
586
|
+
fs.writeFileSync(specFile, JSON.stringify(spec));
|
|
587
|
+
const blocked = block(stop(runtime));
|
|
588
|
+
assert.match(blocked.reason, /risk .*removed/i);
|
|
589
|
+
} finally {
|
|
590
|
+
cleanupFixture(root);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test(`${kind}: missing legacy floor migrates from config while corrupt floor fails closed`, () => {
|
|
595
|
+
const missingRoot = gitFixture();
|
|
596
|
+
try {
|
|
597
|
+
const runtime = adapter(kind, missingRoot);
|
|
598
|
+
prepare(missingRoot);
|
|
599
|
+
nonceFrom(stop(runtime));
|
|
600
|
+
fs.rmSync(stateRecord(runtime, 'policy-floor'), { force: true });
|
|
601
|
+
const migrated = nonceFrom(stop(runtime));
|
|
602
|
+
assert.match(migrated.body.reason, /approval/i);
|
|
603
|
+
assert.equal(JSON.parse(fs.readFileSync(stateRecord(runtime, 'policy-floor'), 'utf8')).schema_version, 3);
|
|
604
|
+
} finally {
|
|
605
|
+
cleanupFixture(missingRoot);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const corruptRoot = gitFixture();
|
|
609
|
+
try {
|
|
610
|
+
const runtime = adapter(kind, corruptRoot);
|
|
611
|
+
prepare(corruptRoot);
|
|
612
|
+
nonceFrom(stop(runtime));
|
|
613
|
+
fs.writeFileSync(stateRecord(runtime, 'policy-floor'), '{ corrupt');
|
|
614
|
+
assert.match(block(stop(runtime)).reason, /floor|malformed|authority/i);
|
|
615
|
+
} finally {
|
|
616
|
+
cleanupFixture(corruptRoot);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test(`${kind}: configured minimum is stable across feature escalation, while missing policy is blocked`, () => {
|
|
621
|
+
const root = gitFixture();
|
|
622
|
+
try {
|
|
623
|
+
const runtime = adapter(kind, root);
|
|
624
|
+
prepare(root, { lane: 'Standard' });
|
|
625
|
+
nonceFrom(stop(runtime));
|
|
626
|
+
const standardFloor = JSON.parse(fs.readFileSync(stateRecord(runtime, 'policy-floor'), 'utf8'));
|
|
627
|
+
assert.equal(POLICY.readWorkflowPolicySnapshot({ workflow_policy: standardFloor.policy }).lane, 'Standard');
|
|
628
|
+
prepare(root, { lane: 'Critical' });
|
|
629
|
+
const escalated = nonceFrom(stop(runtime));
|
|
630
|
+
assert.match(escalated.body.reason, /approval/i);
|
|
631
|
+
const criticalFloor = JSON.parse(fs.readFileSync(stateRecord(runtime, 'policy-floor'), 'utf8'));
|
|
632
|
+
assert.deepEqual(criticalFloor.policy, standardFloor.policy);
|
|
633
|
+
|
|
634
|
+
const missingRoot = gitFixture();
|
|
635
|
+
try {
|
|
636
|
+
const missingRuntime = adapter(kind, missingRoot);
|
|
637
|
+
const fixture = prepare(missingRoot);
|
|
638
|
+
const spec = JSON.parse(fs.readFileSync(fixture.specFile, 'utf8'));
|
|
639
|
+
delete spec.workflow_policy;
|
|
640
|
+
fs.writeFileSync(fixture.specFile, JSON.stringify(spec));
|
|
641
|
+
assert.match(block(stop(missingRuntime)).reason, /workflow_policy.*missing/i);
|
|
642
|
+
} finally {
|
|
643
|
+
cleanupFixture(missingRoot);
|
|
644
|
+
}
|
|
645
|
+
} finally {
|
|
646
|
+
cleanupFixture(root);
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test(`${kind}: canonical config minimum applies without inheriting feature policy`, () => {
|
|
651
|
+
const root = gitFixture();
|
|
652
|
+
try {
|
|
653
|
+
const runtime = adapter(kind, root);
|
|
654
|
+
writeRuntime(runtime, { spec: { policy_minimum: { planning_depth: 'Full', assurance_level: 'Elevated' } } });
|
|
655
|
+
prepare(root, { lane: 'Standard' });
|
|
656
|
+
assert.match(block(stop(runtime)).reason, /configured project minimum/i);
|
|
657
|
+
const strict = prepare(root, { lane: 'Critical' });
|
|
658
|
+
const spec = JSON.parse(fs.readFileSync(strict.specFile, 'utf8'));
|
|
659
|
+
spec.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Full', assurance_level: 'Strict', risks: ['auth'] });
|
|
660
|
+
fs.writeFileSync(strict.specFile, JSON.stringify(spec));
|
|
661
|
+
nonceFrom(stop(runtime));
|
|
662
|
+
const floor = JSON.parse(fs.readFileSync(stateRecord(runtime, 'policy-floor'), 'utf8'));
|
|
663
|
+
assert.deepEqual(floor.policy, POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Full', assurance_level: 'Elevated' }));
|
|
664
|
+
} finally {
|
|
665
|
+
cleanupFixture(root);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test(`${kind}: legacy v2 baseline migrates to canonical 2.1 without losing feature history`, () => {
|
|
670
|
+
const root = gitFixture();
|
|
671
|
+
try {
|
|
672
|
+
const runtime = adapter(kind, root);
|
|
673
|
+
const fixture = prepare(root);
|
|
674
|
+
const spec = JSON.parse(fs.readFileSync(fixture.specFile, 'utf8'));
|
|
675
|
+
spec.workflow_policy = POLICY.workflowPolicySnapshot({ riskSignals: {} });
|
|
676
|
+
fs.writeFileSync(fixture.specFile, JSON.stringify(spec));
|
|
677
|
+
nonceFrom(stop(runtime));
|
|
678
|
+
const first = JSON.parse(fs.readFileSync(stateRecord(runtime, 'baseline-'), 'utf8'));
|
|
679
|
+
assert.equal(first.policy.version, '2.1');
|
|
680
|
+
assert.equal(first.schema_version, 3);
|
|
681
|
+
|
|
682
|
+
spec.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Routine' });
|
|
683
|
+
fs.writeFileSync(fixture.specFile, JSON.stringify(spec));
|
|
684
|
+
nonceFrom(stop(runtime));
|
|
685
|
+
const migrated = JSON.parse(fs.readFileSync(stateRecord(runtime, 'baseline-'), 'utf8'));
|
|
686
|
+
assert.equal(migrated.policy.version, '2.1');
|
|
687
|
+
assert.equal(migrated.issued_at, first.issued_at);
|
|
688
|
+
} finally {
|
|
689
|
+
cleanupFixture(root);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test(`${kind}: explicit target resolves before sibling ambiguity while no target remains ambiguous`, () => {
|
|
694
|
+
const root = gitFixture();
|
|
695
|
+
try {
|
|
696
|
+
const runtime = adapter(kind, root);
|
|
697
|
+
prepare(root);
|
|
698
|
+
assert.match(block(stop(runtime, { featureName: 'other-feature' })).reason, /explicit/i);
|
|
699
|
+
prepare(root, { feature: 'other-feature', phase: 'implementation' });
|
|
700
|
+
assert.ok(nonceFrom(stop(runtime, { featureName: FEATURE })).nonce);
|
|
701
|
+
assert.match(block(stop(runtime)).reason, /multiple_persisted|multiple/i);
|
|
702
|
+
for (const malformed of [
|
|
703
|
+
{ featureName: '' },
|
|
704
|
+
{ featureName: 42 },
|
|
705
|
+
{ specPath: '../escape/spec.json' },
|
|
706
|
+
]) assert.match(block(stop(runtime, malformed)).reason, /explicit|malformed|escape/i);
|
|
707
|
+
} finally {
|
|
708
|
+
cleanupFixture(root);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
test('Claude hook: CLAUDE_PROJECT_DIR wins over malicious payload.cwd and stale process cwd', () => {
|
|
714
|
+
const target = gitFixture();
|
|
715
|
+
const stale = gitFixture();
|
|
716
|
+
try {
|
|
717
|
+
prepare(target);
|
|
718
|
+
const home = homeFor(target);
|
|
719
|
+
const result = spawnSync(process.execPath, [CLAUDE_HOOK, '--stop'], {
|
|
720
|
+
cwd: stale,
|
|
721
|
+
env: {
|
|
722
|
+
...process.env,
|
|
723
|
+
HOME: home,
|
|
724
|
+
USERPROFILE: home,
|
|
725
|
+
CLAUDE_PROJECT_DIR: target,
|
|
726
|
+
PROJECT_ROOT: stale,
|
|
727
|
+
},
|
|
728
|
+
input: JSON.stringify({
|
|
729
|
+
hook_event_name: 'Stop',
|
|
730
|
+
session_id: 'session-a',
|
|
731
|
+
cwd: stale,
|
|
732
|
+
}),
|
|
733
|
+
encoding: 'utf8',
|
|
734
|
+
});
|
|
735
|
+
assert.match(block(result).reason, /approval/i);
|
|
736
|
+
} finally {
|
|
737
|
+
cleanupFixture(target);
|
|
738
|
+
cleanupFixture(stale);
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('installed scaffold observes Critical policy before Stop and source scaffold does not touch authority state', () => {
|
|
743
|
+
const root = gitFixture();
|
|
744
|
+
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-source-scaffold-'));
|
|
745
|
+
const sourceHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-source-home-'));
|
|
746
|
+
const installedHome = homeFor(root);
|
|
747
|
+
try {
|
|
748
|
+
const installedClaude = path.join(root, '.claude');
|
|
749
|
+
fs.cpSync(path.join(ROOT, 'claude/scripts'), path.join(installedClaude, 'scripts'), { recursive: true });
|
|
750
|
+
fs.cpSync(path.join(ROOT, 'claude/skills/specs/templates'), path.join(installedClaude, 'skills/specs/templates'), { recursive: true });
|
|
751
|
+
fs.cpSync(path.join(ROOT, 'claude/hooks'), path.join(installedClaude, 'hooks'), { recursive: true });
|
|
752
|
+
|
|
753
|
+
const installedScaffold = path.join(installedClaude, 'scripts/spec-scaffold.cjs');
|
|
754
|
+
const installedHook = path.join(installedClaude, 'hooks/completion-authority.cjs');
|
|
755
|
+
const installedRuntime = {
|
|
756
|
+
root,
|
|
757
|
+
home: installedHome,
|
|
758
|
+
state: path.join(installedHome, '.cafekit', 'completion-authority', 'projects'),
|
|
759
|
+
run(mode, payload) {
|
|
760
|
+
return spawnSync(process.execPath, [installedHook, mode], {
|
|
761
|
+
cwd: root,
|
|
762
|
+
env: {
|
|
763
|
+
...process.env,
|
|
764
|
+
HOME: installedHome,
|
|
765
|
+
USERPROFILE: installedHome,
|
|
766
|
+
CLAUDE_PROJECT_DIR: root,
|
|
767
|
+
PROJECT_ROOT: root,
|
|
768
|
+
},
|
|
769
|
+
input: JSON.stringify({ cwd: root, session_id: 'session-a', ...payload }),
|
|
770
|
+
encoding: 'utf8',
|
|
771
|
+
});
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
const scaffolded = spawnSync(process.execPath, [installedScaffold, 'installed-critical', '--lane', 'Critical'], {
|
|
775
|
+
cwd: root,
|
|
776
|
+
env: { ...process.env, HOME: installedHome, USERPROFILE: installedHome },
|
|
777
|
+
encoding: 'utf8',
|
|
778
|
+
});
|
|
779
|
+
assert.equal(scaffolded.status, 0, `${scaffolded.stdout}\n${scaffolded.stderr}`);
|
|
780
|
+
|
|
781
|
+
const floor = JSON.parse(fs.readFileSync(stateRecord(installedRuntime, 'policy-floor'), 'utf8'));
|
|
782
|
+
const baseline = JSON.parse(fs.readFileSync(stateRecord(installedRuntime, 'baseline-'), 'utf8'));
|
|
783
|
+
assert.equal(typeof floor.mac, 'string');
|
|
784
|
+
assert.ok(floor.mac.length > 0);
|
|
785
|
+
assert.equal(typeof baseline.mac, 'string');
|
|
786
|
+
assert.ok(baseline.mac.length > 0);
|
|
787
|
+
|
|
788
|
+
fs.rmSync(path.join(root, 'specs', 'installed-critical'), { recursive: true, force: true });
|
|
789
|
+
const disappeared = block(stop(installedRuntime));
|
|
790
|
+
assert.match(disappeared.reason, /disappeared/i);
|
|
791
|
+
|
|
792
|
+
const sourceScaffolded = spawnSync(process.execPath, [path.join(ROOT, 'claude/scripts/spec-scaffold.cjs'), 'source-critical', '--lane', 'Critical'], {
|
|
793
|
+
cwd: sourceRoot,
|
|
794
|
+
env: { ...process.env, HOME: sourceHome, USERPROFILE: sourceHome },
|
|
795
|
+
encoding: 'utf8',
|
|
796
|
+
});
|
|
797
|
+
assert.equal(sourceScaffolded.status, 0, `${sourceScaffolded.stdout}\n${sourceScaffolded.stderr}`);
|
|
798
|
+
assert.equal(fs.existsSync(path.join(sourceHome, '.cafekit', 'completion-authority')), false);
|
|
799
|
+
} finally {
|
|
800
|
+
cleanupFixture(root);
|
|
801
|
+
fs.rmSync(sourceRoot, { recursive: true, force: true });
|
|
802
|
+
fs.rmSync(sourceHome, { recursive: true, force: true });
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('canonical final-state decision rejects every stale mutation class before execution proof', () => {
|
|
807
|
+
const root = gitFixture();
|
|
808
|
+
try {
|
|
809
|
+
const featureDir = path.join(root, 'specs', 'final-state-matrix');
|
|
810
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
811
|
+
const specFile = path.join(featureDir, 'spec.json');
|
|
812
|
+
const digest = `sha256:${'a'.repeat(64)}`;
|
|
813
|
+
const baseSpec = {
|
|
814
|
+
schema_version: '2.1', feature_name: 'final-state-matrix', ready_for_implementation: true,
|
|
815
|
+
validation: { status: 'completed', semantic_review: { status: 'completed', semantic_digest: digest } },
|
|
816
|
+
workflow_policy: POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Routine' }),
|
|
817
|
+
};
|
|
818
|
+
fs.writeFileSync(specFile, JSON.stringify(baseSpec));
|
|
819
|
+
const candidate = { featureName: 'final-state-matrix', featureDir, specFile, spec: baseSpec };
|
|
820
|
+
const state = { validationErrors: [], groundingErrors: [], digest, observed: false };
|
|
821
|
+
const dependencies = {
|
|
822
|
+
validator: {
|
|
823
|
+
validateSpec: () => ({ errors: state.validationErrors, warnings: [] }),
|
|
824
|
+
computeSemanticDigest21: () => ({ errors: [], digest: state.digest }),
|
|
825
|
+
},
|
|
826
|
+
grounder: { groundSpec: () => ({ errors: state.groundingErrors, warnings: [], checked: 0 }) },
|
|
827
|
+
semanticAuthority: { verifyAttestation: () => ({ ok: state.observed, reason: 'missing observation' }) },
|
|
828
|
+
};
|
|
829
|
+
const cases = [
|
|
830
|
+
['readiness', (spec) => { spec.ready_for_implementation = false; }, /readiness/],
|
|
831
|
+
['canonical validation', () => { state.validationErrors = ['invalid current state']; }, /validation failed/],
|
|
832
|
+
['grounding', () => { state.groundingErrors = ['ungrounded target']; }, /grounding failed/],
|
|
833
|
+
['semantic digest', () => { state.digest = `sha256:${'b'.repeat(64)}`; }, /digest is stale/],
|
|
834
|
+
];
|
|
835
|
+
for (const [name, mutate, expected] of cases) {
|
|
836
|
+
candidate.spec = structuredClone(baseSpec);
|
|
837
|
+
state.validationErrors = [];
|
|
838
|
+
state.groundingErrors = [];
|
|
839
|
+
state.digest = digest;
|
|
840
|
+
mutate(candidate.spec);
|
|
841
|
+
const result = AUTHORITY_CHECK.validateCanonicalFinalState({ policy: POLICY, projectRoot: root, candidate, dependencies });
|
|
842
|
+
assert.equal(result.ok, false, name);
|
|
843
|
+
assert.match(result.reason, expected, name);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
candidate.spec = structuredClone(baseSpec);
|
|
847
|
+
state.digest = digest;
|
|
848
|
+
assert.equal(AUTHORITY_CHECK.validateCanonicalFinalState({ policy: POLICY, projectRoot: root, candidate, dependencies }).ok, true);
|
|
849
|
+
candidate.spec.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Full', assurance_level: 'Strict' });
|
|
850
|
+
assert.match(
|
|
851
|
+
AUTHORITY_CHECK.validateCanonicalFinalState({ policy: POLICY, projectRoot: root, candidate, dependencies }).reason,
|
|
852
|
+
/host-hook-observed reviewer PASS/,
|
|
853
|
+
);
|
|
854
|
+
state.observed = true;
|
|
855
|
+
assert.equal(AUTHORITY_CHECK.validateCanonicalFinalState({ policy: POLICY, projectRoot: root, candidate, dependencies }).ok, true);
|
|
856
|
+
} finally { cleanupFixture(root); }
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test('schema 2.1 lifecycle vocabulary is exact and legacy aliases stay in the legacy adapter', () => {
|
|
860
|
+
const canonical = [
|
|
861
|
+
['in_progress', false],
|
|
862
|
+
['paused', false],
|
|
863
|
+
['blocked', false],
|
|
864
|
+
['done', true],
|
|
865
|
+
];
|
|
866
|
+
for (const checker of [AUTHORITY_CHECK, require(path.join(ROOT, 'codex/hooks/completion-authority-check.cjs'))]) {
|
|
867
|
+
for (const [status, terminal] of canonical) {
|
|
868
|
+
const spec = { schema_version: '2.1', status };
|
|
869
|
+
assert.equal(checker.isValidLifecycleStatus(spec), true, status);
|
|
870
|
+
assert.equal(checker.isDurableCloseout(spec), terminal, status);
|
|
871
|
+
}
|
|
872
|
+
for (const alias of ['completed', 'complete', 'in-progress']) {
|
|
873
|
+
const spec = { schema_version: '2.1', status: alias, current_phase: 'closeout' };
|
|
874
|
+
assert.equal(checker.isValidLifecycleStatus(spec), false, alias);
|
|
875
|
+
assert.equal(checker.isDurableCloseout(spec), false, alias);
|
|
876
|
+
}
|
|
877
|
+
assert.equal(checker.isValidLifecycleStatus({ schema_version: '2.0', status: 'completed' }), true);
|
|
878
|
+
assert.equal(checker.isDurableCloseout({ schema_version: '2.0', status: 'in_progress', current_phase: 'closeout' }), true);
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test('final-state authority loads the validator CommonJS API directly and propagates canonical failures', () => {
|
|
883
|
+
const root = gitFixture();
|
|
884
|
+
try {
|
|
885
|
+
const validatorPath = path.join(ROOT, 'claude/scripts/validate-spec-output.cjs');
|
|
886
|
+
const canonicalValidator = require(validatorPath);
|
|
887
|
+
const featureDir = path.join(root, 'specs', 'direct-validator-api');
|
|
888
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
889
|
+
const specFile = path.join(featureDir, 'spec.json');
|
|
890
|
+
const digest = `sha256:${'a'.repeat(64)}`;
|
|
891
|
+
const candidate = {
|
|
892
|
+
featureName: 'direct-validator-api', featureDir, specFile,
|
|
893
|
+
spec: {
|
|
894
|
+
schema_version: '2.1', status: 'done', ready_for_implementation: true,
|
|
895
|
+
validation: { status: 'completed', semantic_review: { status: 'completed', semantic_digest: digest } },
|
|
896
|
+
workflow_policy: POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Routine' }),
|
|
897
|
+
},
|
|
898
|
+
};
|
|
899
|
+
fs.writeFileSync(specFile, `${JSON.stringify(candidate.spec, null, 2)}\n`);
|
|
900
|
+
const validatorFixture = path.join(root, 'direct-validator.cjs');
|
|
901
|
+
fs.writeFileSync(validatorFixture, [
|
|
902
|
+
"'use strict';",
|
|
903
|
+
"module.exports = {",
|
|
904
|
+
" validateSpec() { return { errors: ['direct-api-canonical-failure'], warnings: [] }; },",
|
|
905
|
+
` computeSemanticDigest21() { return { errors: [], digest: '${digest}' }; },`,
|
|
906
|
+
"};",
|
|
907
|
+
'',
|
|
908
|
+
].join('\n'));
|
|
909
|
+
const invalidFixture = path.join(root, 'invalid-validator.cjs');
|
|
910
|
+
fs.writeFileSync(invalidFixture, 'module.exports = { validateSpec() {} };\n');
|
|
911
|
+
|
|
912
|
+
for (const [name, checkerPath] of [
|
|
913
|
+
['claude', path.join(ROOT, 'claude/hooks/completion-authority-check.cjs')],
|
|
914
|
+
['codex', path.join(ROOT, 'codex/hooks/completion-authority-check.cjs')],
|
|
915
|
+
]) {
|
|
916
|
+
const checker = require(checkerPath);
|
|
917
|
+
assert.doesNotMatch(fs.readFileSync(checkerPath, 'utf8'), /spawnSync|node:child_process|require\(['"]child_process['"]\)/, `${name} has no validator CLI path`);
|
|
918
|
+
const dependencies = checker.finalStateDependencies();
|
|
919
|
+
assert.equal(dependencies.validator.validateSpec, canonicalValidator.validateSpec, `${name} resolves the source CommonJS API directly`);
|
|
920
|
+
assert.equal(dependencies.validator.computeSemanticDigest21, canonicalValidator.computeSemanticDigest21, `${name} resolves the digest API directly`);
|
|
921
|
+
const directValidator = checker.validatorApi(validatorFixture);
|
|
922
|
+
const result = checker.validateCanonicalFinalState({
|
|
923
|
+
policy: POLICY, projectRoot: root, candidate,
|
|
924
|
+
dependencies: {
|
|
925
|
+
validator: directValidator,
|
|
926
|
+
grounder: { groundSpec: () => ({ errors: [], warnings: [] }) },
|
|
927
|
+
semanticAuthority: { verifyAttestation: () => ({ ok: true }) },
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
assert.equal(result.ok, false, name);
|
|
931
|
+
assert.match(result.reason, /direct-api-canonical-failure/, name);
|
|
932
|
+
assert.throws(() => checker.validatorApi(invalidFixture), /must export validateSpec and computeSemanticDigest21/, name);
|
|
933
|
+
}
|
|
934
|
+
} finally { cleanupFixture(root); }
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test('persisted resolver isolates exact explicit target from malformed siblings and scans globally only without target', () => {
|
|
938
|
+
const root = gitFixture();
|
|
939
|
+
try {
|
|
940
|
+
prepare(root, { feature: 'exact-target', phase: 'implementation' });
|
|
941
|
+
const malformedDir = path.join(root, 'specs', 'malformed-sibling');
|
|
942
|
+
fs.mkdirSync(malformedDir, { recursive: true });
|
|
943
|
+
fs.writeFileSync(path.join(malformedDir, 'spec.json'), '{ malformed');
|
|
944
|
+
const explicit = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {}, target: { featureName: 'exact-target' } });
|
|
945
|
+
assert.equal(explicit.error, undefined);
|
|
946
|
+
assert.equal(explicit.featureName, 'exact-target');
|
|
947
|
+
const global = RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {} });
|
|
948
|
+
assert.equal(global.error, 'invalid_specs');
|
|
949
|
+
for (const target of [{ featureName: '' }, { featureName: 42 }, { specPath: '../escape/spec.json' }]) {
|
|
950
|
+
assert.match(RESOLVER.resolvePersistedSpec({ projectRoot: root, runtime: {}, target }).error, /^explicit_/);
|
|
951
|
+
}
|
|
952
|
+
} finally { cleanupFixture(root); }
|
|
953
|
+
});
|