@haposoft/cafekit 0.16.0-rc.1 → 0.16.0-rc.3
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 +355 -12
- package/bin/__tests__/codex-native.test.js +1 -1
- package/bin/__tests__/develop-contract.test.js +1374 -78
- package/bin/__tests__/installer-safety.test.js +43 -29
- package/bin/__tests__/opencode-spec-gate.test.js +791 -0
- package/bin/__tests__/package-inventory.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +707 -2
- package/package.json +1 -1
- package/src/claude/agents/code-auditor.md +8 -1
- package/src/claude/agents/spec-maker.md +107 -200
- package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
- package/src/claude/hooks/completion-authority-check.cjs +307 -0
- package/src/claude/hooks/completion-authority-state.cjs +525 -0
- package/src/claude/hooks/completion-authority.cjs +148 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +173 -61
- package/src/claude/hooks/spec-state.cjs +104 -45
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-resolver.cjs +450 -0
- package/src/claude/scripts/spec-scaffold.cjs +716 -41
- package/src/claude/scripts/validate-spec-output.cjs +355 -53
- package/src/claude/scripts/workflow-policy.cjs +1529 -162
- package/src/claude/settings/settings.json +8 -0
- package/src/claude/skills/backend-development/references/backend-security.md +52 -0
- package/src/claude/skills/code-review/SKILL.md +85 -66
- package/src/claude/skills/develop/SKILL.md +153 -285
- package/src/claude/skills/develop/references/quality-gate.md +112 -124
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +170 -381
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
- package/src/claude/skills/specs/templates/spec-state.json +23 -2
- package/src/claude/skills/specs/templates/task.md +20 -0
- package/src/claude/skills/sync/SKILL.md +15 -8
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +141 -167
- package/src/codex/hooks/completion-authority-check.cjs +260 -0
- package/src/codex/hooks/completion-authority-state.cjs +525 -0
- package/src/codex/hooks/completion-authority.cjs +100 -0
- package/src/codex/hooks/lib/hook-context.cjs +42 -2
- package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
- package/src/codex/hooks/lib/spec-utils.cjs +53 -27
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +132 -20
- package/src/codex/hooks/spec-state.cjs +56 -10
- package/src/codex/hooks.json +10 -0
- 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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const assert = require('node:assert/strict');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
4
5
|
const fs = require('node:fs');
|
|
5
6
|
const os = require('node:os');
|
|
6
7
|
const path = require('node:path');
|
|
@@ -9,6 +10,10 @@ const { test } = require('node:test');
|
|
|
9
10
|
|
|
10
11
|
const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
11
12
|
const CODEX_HOOKS = path.join(PACKAGE_ROOT, 'src/codex/hooks');
|
|
13
|
+
const PROVENANCE = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'));
|
|
14
|
+
const POLICY = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'));
|
|
15
|
+
const VALID_BASE = '0123456789abcdef0123456789abcdef01234567';
|
|
16
|
+
const VALID_HEAD = '89abcdef0123456789abcdef0123456789abcdef';
|
|
12
17
|
const { stateDir: codexStateDir } = require(
|
|
13
18
|
path.join(CODEX_HOOKS, 'lib', 'state-store.cjs')
|
|
14
19
|
);
|
|
@@ -21,17 +26,50 @@ function runHook(file, cwd, payload) {
|
|
|
21
26
|
});
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
function
|
|
29
|
+
function runtimeContext(root, feature = 'auth', session = 'session-a') {
|
|
30
|
+
return PROVENANCE.deriveRuntimeContext({
|
|
31
|
+
projectRoot: root,
|
|
32
|
+
specsRoot: path.join(root, 'specs'),
|
|
33
|
+
specFile: path.join(root, 'specs', feature, 'spec.json'),
|
|
34
|
+
featureName: feature,
|
|
35
|
+
runtimeSession: session,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bindReceipt(root, value, feature = 'auth', session = 'session-a') {
|
|
40
|
+
const context = runtimeContext(root, feature, session);
|
|
41
|
+
return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function inHookFixture(run, options = {}) {
|
|
25
45
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
|
|
26
46
|
const hooks = path.join(root, '.codex', 'hooks');
|
|
27
47
|
try {
|
|
28
48
|
fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
|
|
29
49
|
fs.mkdirSync(path.join(root, '.codex', 'scripts'), { recursive: true });
|
|
50
|
+
if (options.policy !== 'missing') {
|
|
51
|
+
if (options.policy === 'malformed') {
|
|
52
|
+
fs.writeFileSync(path.join(root, '.codex', 'scripts', 'workflow-policy.cjs'), 'module.exports = {\n');
|
|
53
|
+
} else {
|
|
54
|
+
fs.copyFileSync(
|
|
55
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'),
|
|
56
|
+
path.join(root, '.codex', 'scripts', 'workflow-policy.cjs'),
|
|
57
|
+
);
|
|
58
|
+
fs.copyFileSync(
|
|
59
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'),
|
|
60
|
+
path.join(root, '.codex', 'scripts', 'provenance.cjs'),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
30
64
|
fs.copyFileSync(
|
|
31
|
-
path.join(PACKAGE_ROOT, 'src/claude/scripts/
|
|
32
|
-
path.join(root, '.codex', 'scripts', '
|
|
65
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
|
|
66
|
+
path.join(root, '.codex', 'scripts', 'spec-resolver.cjs'),
|
|
33
67
|
);
|
|
34
68
|
fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
|
|
69
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
70
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
71
|
+
assert.equal(result.status, 0, result.stderr);
|
|
72
|
+
}
|
|
35
73
|
return run(root, hooks);
|
|
36
74
|
} finally {
|
|
37
75
|
fs.rmSync(root, { recursive: true, force: true });
|
|
@@ -228,6 +266,7 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
|
|
|
228
266
|
fs.mkdirSync(nested, { recursive: true });
|
|
229
267
|
const spec = {
|
|
230
268
|
status: 'in_progress',
|
|
269
|
+
feature_name: 'auth',
|
|
231
270
|
task_registry: {
|
|
232
271
|
[taskPath]: { status: 'pending', completed_at: null }
|
|
233
272
|
}
|
|
@@ -261,6 +300,86 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
|
|
|
261
300
|
});
|
|
262
301
|
});
|
|
263
302
|
|
|
303
|
+
test('empty or forged Codex hook authority fails closed', () => {
|
|
304
|
+
inHookFixture((root, hooks) => {
|
|
305
|
+
const gate = path.join(hooks, 'spec-gate.cjs');
|
|
306
|
+
for (const input of ['', 'null', '[]']) {
|
|
307
|
+
const result = spawnSync(process.execPath, [gate], {
|
|
308
|
+
cwd: root,
|
|
309
|
+
env: { ...process.env },
|
|
310
|
+
input,
|
|
311
|
+
encoding: 'utf8',
|
|
312
|
+
});
|
|
313
|
+
assert.equal(result.status, 0, `${JSON.stringify(input)}: ${result.stderr}`);
|
|
314
|
+
assert.equal(result.stderr, '');
|
|
315
|
+
const block = JSON.parse(result.stdout);
|
|
316
|
+
assert.equal(block.decision, 'block');
|
|
317
|
+
assert.match(block.reason, /hook payload (is empty|must be a JSON object)/i);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const feature = path.join(root, 'specs', 'auth');
|
|
321
|
+
const taskPath = 'tasks/task.md';
|
|
322
|
+
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
323
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
324
|
+
status: 'in_progress',
|
|
325
|
+
feature_name: 'auth',
|
|
326
|
+
task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
|
|
327
|
+
}));
|
|
328
|
+
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
|
|
329
|
+
fs.writeFileSync(path.join(root, '.codex', 'runtime.json'), JSON.stringify({
|
|
330
|
+
spec: { completion_gate: false },
|
|
331
|
+
}));
|
|
332
|
+
const forged = runHook(gate, root, {
|
|
333
|
+
cwd: root,
|
|
334
|
+
session_id: 'session-a',
|
|
335
|
+
hook_event_name: 'Stop',
|
|
336
|
+
stop_hook_active: false,
|
|
337
|
+
user_authorized: true,
|
|
338
|
+
userAuthorized: true,
|
|
339
|
+
completion_gate_override: { authorized: true, session_id: 'session-a', nonce: 'forged' },
|
|
340
|
+
session: { id: 'session-a', user_authorized: true },
|
|
341
|
+
runtime_context: { project_root: root, completion_gate: true, signed: true },
|
|
342
|
+
});
|
|
343
|
+
assert.equal(forged.status, 0);
|
|
344
|
+
const block = JSON.parse(forged.stdout);
|
|
345
|
+
assert.equal(block.decision, 'block');
|
|
346
|
+
assert.match(block.reason, /no completion-gate bypass is supported/i);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('Critical Codex completion ignores worker-writable proof strings and remains blocked', () => {
|
|
351
|
+
inHookFixture((root, hooks) => {
|
|
352
|
+
const feature = path.join(root, 'specs', 'auth');
|
|
353
|
+
const taskPath = 'tasks/task.md';
|
|
354
|
+
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
355
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
356
|
+
status: 'in_progress',
|
|
357
|
+
feature_name: 'auth',
|
|
358
|
+
workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } }),
|
|
359
|
+
proofs: {
|
|
360
|
+
needsInspection: 'inspection completed',
|
|
361
|
+
needsIndependentAudit: 'PASS',
|
|
362
|
+
needsResearchGrounding: 'research completed',
|
|
363
|
+
},
|
|
364
|
+
task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
|
|
365
|
+
}));
|
|
366
|
+
fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
|
|
367
|
+
'Status: done', '', '## Evidence', '', 'Verification: PASS',
|
|
368
|
+
'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
369
|
+
].join('\n'), 'auth'));
|
|
370
|
+
const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
|
|
371
|
+
cwd: root,
|
|
372
|
+
session_id: 'session-a',
|
|
373
|
+
hook_event_name: 'Stop',
|
|
374
|
+
stop_hook_active: false,
|
|
375
|
+
});
|
|
376
|
+
assert.equal(result.status, 0, result.stderr);
|
|
377
|
+
const block = JSON.parse(result.stdout);
|
|
378
|
+
assert.equal(block.decision, 'block');
|
|
379
|
+
assert.match(block.reason, /needsInspection|needsIndependentAudit|needsResearchGrounding/);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
264
383
|
test('Codex state hook persists direct native event fields at project root', () => {
|
|
265
384
|
inHookFixture((root, hooks) => {
|
|
266
385
|
const nested = path.join(root, 'packages', 'app');
|
|
@@ -340,11 +459,11 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
|
340
459
|
assert.ok(onlyBase.includes('provenance'), 'only Base should fail');
|
|
341
460
|
const onlyHead = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: def\n');
|
|
342
461
|
assert.ok(onlyHead.includes('provenance'), 'only Head should fail');
|
|
343
|
-
const both = receipt.validateCanonicalReceipt(
|
|
462
|
+
const both = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`);
|
|
344
463
|
assert.deepEqual(both, [], 'both Base and Head should pass');
|
|
345
464
|
const onlyBaseSha = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc\n');
|
|
346
465
|
assert.ok(onlyBaseSha.includes('provenance'));
|
|
347
|
-
const bothSha = receipt.validateCanonicalReceipt(
|
|
466
|
+
const bothSha = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: ${VALID_BASE}\nhead_sha: ${VALID_HEAD}\n`);
|
|
348
467
|
assert.deepEqual(bothSha, [], 'both base_sha and head_sha should pass');
|
|
349
468
|
// Empty / bare provenance must fail — non-empty same-line value required
|
|
350
469
|
const emptyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
|
|
@@ -361,9 +480,9 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
|
361
480
|
assert.ok(spacesBaseSha.includes('provenance'), 'base_sha spaces only should fail');
|
|
362
481
|
const bareBoth = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
|
|
363
482
|
assert.ok(bareBoth.includes('provenance'), 'bare base_sha head_sha without colon should fail');
|
|
364
|
-
const validBaseHead = receipt.validateCanonicalReceipt(
|
|
483
|
+
const validBaseHead = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`);
|
|
365
484
|
assert.deepEqual(validBaseHead, [], 'valid Base+Head with values should pass');
|
|
366
|
-
const validSha = receipt.validateCanonicalReceipt(
|
|
485
|
+
const validSha = receipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: ${VALID_BASE}\nhead_sha: ${VALID_HEAD}\n`);
|
|
367
486
|
assert.deepEqual(validSha, [], 'valid base_sha+head_sha with values should pass');
|
|
368
487
|
|
|
369
488
|
// Integration via spec-gate hook: only Base blocks, both passes
|
|
@@ -375,7 +494,7 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
|
375
494
|
const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
376
495
|
|
|
377
496
|
// only Base -> block
|
|
378
|
-
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
497
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
379
498
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
|
|
380
499
|
fs.mkdirSync(payload.cwd, { recursive: true });
|
|
381
500
|
const blocked = runHook(gate, payload.cwd, payload);
|
|
@@ -383,7 +502,7 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
|
383
502
|
assert.match(JSON.parse(blocked.stdout).reason, /\bg\b|\bprovenance\b/);
|
|
384
503
|
|
|
385
504
|
// both -> no block
|
|
386
|
-
fs.writeFileSync(path.join(feature, taskPath),
|
|
505
|
+
fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, `Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`));
|
|
387
506
|
const passed = runHook(gate, payload.cwd, payload);
|
|
388
507
|
assert.equal(passed.stdout, '');
|
|
389
508
|
|
|
@@ -413,16 +532,22 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
|
|
|
413
532
|
fs.mkdirSync(appCwd, { recursive: true });
|
|
414
533
|
const payload = { cwd: appCwd, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
415
534
|
const cacheFile = path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json');
|
|
416
|
-
const
|
|
535
|
+
const validBodyTemplate = [
|
|
536
|
+
'Status: done', '', '## Evidence', '', 'Verification: PASS',
|
|
537
|
+
'Command: pnpm test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
538
|
+
'```', 'pass', '```', '',
|
|
539
|
+
].join('\n');
|
|
417
540
|
|
|
418
541
|
// first-run (no cache) with valid receipt → no block, cache seeded
|
|
419
542
|
try { fs.rmSync(cacheFile, { force: true }); } catch {}
|
|
420
|
-
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
543
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
544
|
+
const validBody = bindReceipt(root, validBodyTemplate);
|
|
421
545
|
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
422
546
|
let res = runHook(gate, appCwd, payload);
|
|
423
547
|
assert.equal(res.stdout, '', 'first-run valid should not block');
|
|
424
548
|
assert.ok(fs.existsSync(cacheFile), 'cache file must be created on first valid run');
|
|
425
|
-
|
|
549
|
+
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
550
|
+
assert.equal(cache.entries[runtimeContext(root).context_id].tasks[taskPath], 'done');
|
|
426
551
|
|
|
427
552
|
// cache-hit unchanged valid → still no block (revalidation passes)
|
|
428
553
|
res = runHook(gate, appCwd, payload);
|
|
@@ -469,3 +594,221 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
|
|
|
469
594
|
assert.equal(JSON.parse(res.stdout).decision, 'block', 'malformed cache with invalid receipt must block');
|
|
470
595
|
});
|
|
471
596
|
});
|
|
597
|
+
|
|
598
|
+
test('Codex cache identity separates same-feature receipts from different roots', () => {
|
|
599
|
+
const contexts = [];
|
|
600
|
+
for (let index = 0; index < 2; index += 1) {
|
|
601
|
+
const context = inHookFixture((root, hooks) => {
|
|
602
|
+
const feature = path.join(root, 'specs', 'auth');
|
|
603
|
+
const taskPath = 'tasks/task.md';
|
|
604
|
+
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
605
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
606
|
+
status: 'in_progress',
|
|
607
|
+
feature_name: 'auth',
|
|
608
|
+
task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
|
|
609
|
+
}));
|
|
610
|
+
fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
|
|
611
|
+
'Status: done', '', '## Evidence', '', 'Verification: PASS',
|
|
612
|
+
'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
613
|
+
].join('\n')));
|
|
614
|
+
const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
|
|
615
|
+
cwd: root,
|
|
616
|
+
session_id: 'session-a',
|
|
617
|
+
hook_event_name: 'Stop',
|
|
618
|
+
stop_hook_active: false,
|
|
619
|
+
});
|
|
620
|
+
assert.equal(result.stdout, '');
|
|
621
|
+
const cache = JSON.parse(fs.readFileSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), 'utf8'));
|
|
622
|
+
const ids = Object.keys(cache.entries);
|
|
623
|
+
assert.equal(ids.length, 1);
|
|
624
|
+
assert.equal(cache.entries[ids[0]].identity.feature_name, 'auth');
|
|
625
|
+
return runtimeContext(root);
|
|
626
|
+
});
|
|
627
|
+
contexts.push(context);
|
|
628
|
+
}
|
|
629
|
+
assert.notEqual(contexts[0].context_id, contexts[1].context_id);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('Codex P0 regression: placeholder, explicit failure, artifact, symlink and invalid_specs', () => {
|
|
633
|
+
const path2 = require('node:path');
|
|
634
|
+
const specReceipt = require(path2.join(__dirname, '../../src/codex/hooks/lib/spec-receipt.cjs'));
|
|
635
|
+
const specUtils = require(path2.join(__dirname, '../../src/codex/hooks/lib/spec-utils.cjs'));
|
|
636
|
+
const fs2 = require('node:fs');
|
|
637
|
+
const os2 = require('node:os');
|
|
638
|
+
// Probe A: Tests failed and multiple Results
|
|
639
|
+
assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests failed: 1\n').length > 0);
|
|
640
|
+
assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nResult: FAIL\nResult: PASS\n').includes('exit_result'));
|
|
641
|
+
// Probe B: placeholder tokens
|
|
642
|
+
assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: TODO\nExit: 0\nBase: a\nHead: b\n').includes('command'));
|
|
643
|
+
assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: TBD\nHead: b\n').includes('provenance'));
|
|
644
|
+
assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: pending\nhead_sha: unknown\n').includes('provenance'));
|
|
645
|
+
assert.deepEqual(specReceipt.validateCanonicalReceipt(`Verification: PASS\nCommand: npm run todo:test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`), []);
|
|
646
|
+
// Probe C: artifact
|
|
647
|
+
assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: TBD\n').includes('artifact_hash'));
|
|
648
|
+
assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: \n').includes('artifact_hash'));
|
|
649
|
+
assert.deepEqual(specReceipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\nArtifact: bundle\nsha256: ${'a'.repeat(64)}\n`), []);
|
|
650
|
+
// Probe D/E: symlink spec/task
|
|
651
|
+
const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-codex-reg-'));
|
|
652
|
+
try {
|
|
653
|
+
const specsDir = path2.join(tmp, 'specs');
|
|
654
|
+
fs2.mkdirSync(specsDir, { recursive: true });
|
|
655
|
+
fs2.mkdirSync(path2.join(specsDir, 'valid'), { recursive: true });
|
|
656
|
+
fs2.writeFileSync(path2.join(specsDir, 'valid', 'spec.json'), JSON.stringify({ status: 'in_progress' }));
|
|
657
|
+
const outside = path2.join(tmp, 'outside');
|
|
658
|
+
fs2.mkdirSync(outside, { recursive: true });
|
|
659
|
+
fs2.writeFileSync(path2.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
|
|
660
|
+
const link = path2.join(specsDir, 'linked');
|
|
661
|
+
fs2.symlinkSync(outside, link);
|
|
662
|
+
assert.equal(specUtils.resolveActiveSpec(tmp, {}, 'linked', null).error, 'explicit_malformed');
|
|
663
|
+
assert.equal(specUtils.resolveActiveSpec(tmp, {}, null, path2.join(specsDir, 'linked', 'spec.json')).error, 'explicit_malformed');
|
|
664
|
+
// task symlink
|
|
665
|
+
const featDir = path2.join(specsDir, 'valid');
|
|
666
|
+
fs2.mkdirSync(path2.join(featDir, 'tasks'), { recursive: true });
|
|
667
|
+
const outsideTask = path2.join(tmp, 'outside-task.md');
|
|
668
|
+
fs2.writeFileSync(outsideTask, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
|
|
669
|
+
const taskLink = path2.join(featDir, 'tasks', 'task.md');
|
|
670
|
+
fs2.symlinkSync(outsideTask, taskLink);
|
|
671
|
+
const fails = specReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
|
|
672
|
+
assert.ok(fails.includes('a'));
|
|
673
|
+
// malformed spec
|
|
674
|
+
fs2.mkdirSync(path2.join(specsDir, 'bad'), { recursive: true });
|
|
675
|
+
fs2.writeFileSync(path2.join(specsDir, 'bad', 'spec.json'), '{ bad');
|
|
676
|
+
const mal = specUtils.resolveActiveSpec(tmp, {}, null, null);
|
|
677
|
+
assert.equal(mal.error, 'invalid_specs');
|
|
678
|
+
} finally {
|
|
679
|
+
fs2.rmSync(tmp, { recursive: true, force: true });
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('Codex adapter parity - phantom vectors via shared validator (r3)', () => {
|
|
684
|
+
const receipt = require(path.join(__dirname, '../../src/codex/hooks/lib/spec-receipt.cjs'));
|
|
685
|
+
const base = `Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`;
|
|
686
|
+
const rejects = [
|
|
687
|
+
'Tests: 0 total',
|
|
688
|
+
'Test Suites: 1 failed, 1 total',
|
|
689
|
+
'FAIL ./foo.test.js',
|
|
690
|
+
'FAIL\tpackage',
|
|
691
|
+
'FAIL\texample.test/probe\t0.431s',
|
|
692
|
+
'--- FAIL: TestName (0.00s)',
|
|
693
|
+
'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
|
|
694
|
+
'FAILED tests/test_demo.py',
|
|
695
|
+
'ERROR collecting tests/test_demo.py',
|
|
696
|
+
'# fail 1',
|
|
697
|
+
'ℹ fail 1',
|
|
698
|
+
'not ok 1 - test',
|
|
699
|
+
'[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
|
|
700
|
+
'[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
|
|
701
|
+
'[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
|
|
702
|
+
'ℹ tests 0',
|
|
703
|
+
'collected 0 items',
|
|
704
|
+
'1 error in 0.12s',
|
|
705
|
+
'ℹ cancelled 1',
|
|
706
|
+
];
|
|
707
|
+
for (const vector of rejects) {
|
|
708
|
+
assert.ok(receipt.validateCanonicalReceipt(base + vector + '\n').length > 0, `should reject via Codex: ${vector}`);
|
|
709
|
+
}
|
|
710
|
+
const passes = [
|
|
711
|
+
'Notes: handles error/failure',
|
|
712
|
+
'collected 1 item',
|
|
713
|
+
'Test Suites: 0 failed',
|
|
714
|
+
'[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
|
|
715
|
+
'[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
|
|
716
|
+
'[ERROR] Error handling is documented',
|
|
717
|
+
'FAILURE mode analysis',
|
|
718
|
+
'Notes: tests failed previously but now fixed',
|
|
719
|
+
];
|
|
720
|
+
for (const vector of passes) {
|
|
721
|
+
assert.deepEqual(receipt.validateCanonicalReceipt(base + vector + '\n'), [], `should pass via Codex: ${vector}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-tap-heading-'));
|
|
725
|
+
try {
|
|
726
|
+
const featureDir = path.join(tmp, 'feature');
|
|
727
|
+
const taskPath = 'tasks/task.md';
|
|
728
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
729
|
+
const taskPrefix = ['# Task', '', '**Status:** done', '', '## Evidence', '', base.trim()];
|
|
730
|
+
fs.writeFileSync(path.join(featureDir, taskPath), [...taskPrefix, '# fail 1', ''].join('\n'));
|
|
731
|
+
const tapFailure = receipt.checkReceipt(featureDir, taskPath, { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
|
|
732
|
+
assert.ok(tapFailure.includes('c'), 'unindented TAP # fail 1 must reach the shared validator');
|
|
733
|
+
|
|
734
|
+
fs.writeFileSync(path.join(featureDir, taskPath), [...taskPrefix, '# Notes', 'FAILED tests/test_demo.py', ''].join('\n'));
|
|
735
|
+
const markdownBody = receipt.evidenceBody(fs.readFileSync(path.join(featureDir, taskPath), 'utf8'));
|
|
736
|
+
assert.doesNotMatch(markdownBody, /FAILED tests\/test_demo\.py/);
|
|
737
|
+
assert.deepEqual(receipt.validateCanonicalReceipt(markdownBody), [], 'a real Markdown heading must still end Evidence');
|
|
738
|
+
} finally {
|
|
739
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test('installed Codex gate fails closed with a controlled decision when policy is missing or malformed', () => {
|
|
744
|
+
for (const policy of ['missing', 'malformed']) {
|
|
745
|
+
inHookFixture((root, hooks) => {
|
|
746
|
+
const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
|
|
747
|
+
cwd: root,
|
|
748
|
+
session_id: 'session-a',
|
|
749
|
+
hook_event_name: 'Stop',
|
|
750
|
+
stop_hook_active: false,
|
|
751
|
+
});
|
|
752
|
+
assert.equal(result.status, 0, `${policy}: ${result.stderr}`);
|
|
753
|
+
assert.equal(result.stderr, '', `${policy} must not leak a stack trace`);
|
|
754
|
+
const block = JSON.parse(result.stdout);
|
|
755
|
+
assert.equal(block.decision, 'block');
|
|
756
|
+
assert.match(block.reason, /workflow policy could not be loaded/i);
|
|
757
|
+
assert.ok(fs.existsSync(path.join(hooks, '.logs', 'hook-log.jsonl')));
|
|
758
|
+
}, { policy });
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test('Codex adapter enforces declared task artifact SHA-256 with missing, invalid, and valid evidence', () => {
|
|
763
|
+
const artifact = Buffer.from('cafekit codex fixture artifact\n');
|
|
764
|
+
const digest = crypto.createHash('sha256').update(artifact).digest('hex');
|
|
765
|
+
const cases = [
|
|
766
|
+
['', true],
|
|
767
|
+
['sha256: abc123\n', true],
|
|
768
|
+
[`sha256: ${'0'.repeat(64)}\n`, true],
|
|
769
|
+
[`sha256: ${digest}\n`, false],
|
|
770
|
+
];
|
|
771
|
+
for (const [hashLine, shouldBlock] of cases) {
|
|
772
|
+
inHookFixture((root, hooks) => {
|
|
773
|
+
const feature = path.join(root, 'specs', 'artifact-demo');
|
|
774
|
+
const taskPath = 'tasks/task.md';
|
|
775
|
+
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
776
|
+
fs.mkdirSync(path.join(root, 'output'), { recursive: true });
|
|
777
|
+
fs.writeFileSync(path.join(root, 'output', 'bundle.js'), artifact);
|
|
778
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
779
|
+
status: 'in_progress',
|
|
780
|
+
feature_name: 'artifact-demo',
|
|
781
|
+
task_registry: {
|
|
782
|
+
[taskPath]: {
|
|
783
|
+
status: 'done',
|
|
784
|
+
completed_at: '2026-08-11T00:00:00.000Z',
|
|
785
|
+
artifacts: ['output/bundle.js'],
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
}));
|
|
789
|
+
fs.writeFileSync(path.join(feature, taskPath), [
|
|
790
|
+
'# Task', '', '**Status:** done', '', '## Evidence', '',
|
|
791
|
+
'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
792
|
+
'Artifact: output/bundle.js', hashLine,
|
|
793
|
+
].join('\n'));
|
|
794
|
+
fs.writeFileSync(
|
|
795
|
+
path.join(feature, taskPath),
|
|
796
|
+
bindReceipt(root, fs.readFileSync(path.join(feature, taskPath), 'utf8'), 'artifact-demo'),
|
|
797
|
+
);
|
|
798
|
+
const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
|
|
799
|
+
cwd: root,
|
|
800
|
+
session_id: 'session-a',
|
|
801
|
+
hook_event_name: 'Stop',
|
|
802
|
+
stop_hook_active: false,
|
|
803
|
+
});
|
|
804
|
+
assert.equal(result.status, 0, result.stderr);
|
|
805
|
+
if (shouldBlock) {
|
|
806
|
+
const block = JSON.parse(result.stdout);
|
|
807
|
+
assert.equal(block.decision, 'block');
|
|
808
|
+
assert.match(block.reason, /\bh\b/);
|
|
809
|
+
} else {
|
|
810
|
+
assert.equal(result.stdout, '');
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
});
|
|
@@ -442,7 +442,7 @@ test('Codex Windows hook launchers stay project-bound without Git from nested cw
|
|
|
442
442
|
fs.readFileSync(path.join(projectRoot, '.codex', 'hooks.json'), 'utf8')
|
|
443
443
|
);
|
|
444
444
|
const handlers = allHookHandlers(config);
|
|
445
|
-
assert.equal(handlers.length,
|
|
445
|
+
assert.equal(handlers.length, 16);
|
|
446
446
|
for (const handler of handlers) {
|
|
447
447
|
assert.doesNotMatch(handler.commandWindows, /\$\(/);
|
|
448
448
|
assert.doesNotMatch(handler.commandWindows, /\bgit\b/i);
|