@haposoft/cafekit 0.16.0-rc.3 → 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 +444 -14
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +615 -185
- 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 +687 -13
- 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 +1914 -221
- 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 +143 -99
- package/src/claude/hooks/__tests__/completion-authority.test.js +291 -58
- 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 +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 +77 -30
- 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/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 +67 -16
- package/src/claude/scripts/spec-scaffold.cjs +1354 -139
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +2782 -142
- 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 +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 +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 +23 -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 +38 -16
- 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 +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/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
|
@@ -19,6 +19,10 @@ const { stateDir: codexStateDir } = require(
|
|
|
19
19
|
);
|
|
20
20
|
|
|
21
21
|
function runHook(file, cwd, payload) {
|
|
22
|
+
if (path.basename(file) === 'spec-gate.cjs') {
|
|
23
|
+
const rootResult = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
24
|
+
if (rootResult.status === 0) installFeatureReceipts(rootResult.stdout.trim(), payload.session_id || 'session-a');
|
|
25
|
+
}
|
|
22
26
|
return spawnSync(process.execPath, [file], {
|
|
23
27
|
cwd,
|
|
24
28
|
input: JSON.stringify(payload),
|
|
@@ -41,11 +45,49 @@ function bindReceipt(root, value, feature = 'auth', session = 'session-a') {
|
|
|
41
45
|
return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
function installFeatureReceipts(root, session = 'session-a') {
|
|
49
|
+
const specsRoot = path.join(root, 'specs');
|
|
50
|
+
if (!fs.existsSync(specsRoot)) return;
|
|
51
|
+
for (const feature of fs.readdirSync(specsRoot)) {
|
|
52
|
+
const featureDir = path.join(specsRoot, feature);
|
|
53
|
+
const specFile = path.join(featureDir, 'spec.json');
|
|
54
|
+
if (!fs.existsSync(specFile)) continue;
|
|
55
|
+
let spec;
|
|
56
|
+
try { spec = JSON.parse(fs.readFileSync(specFile, 'utf8')); } catch { continue; }
|
|
57
|
+
const lifecyclePhase = spec.current_phase || spec.phase;
|
|
58
|
+
const explicitCloseout = ['completed', 'complete'].includes(spec.status)
|
|
59
|
+
|| ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
|
|
60
|
+
if (!explicitCloseout) continue;
|
|
61
|
+
const tasks = Object.values(spec.task_registry || {});
|
|
62
|
+
if (tasks.length === 0 || tasks.some((task) => task.status !== 'done')) continue;
|
|
63
|
+
const context = runtimeContext(root, feature, session);
|
|
64
|
+
fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), [
|
|
65
|
+
`Feature: ${feature}`,
|
|
66
|
+
'Expected: final integration verification passes',
|
|
67
|
+
'Observed: final integration verification passed',
|
|
68
|
+
'Verification: PASS',
|
|
69
|
+
'Command: node --test',
|
|
70
|
+
'Exit: 0',
|
|
71
|
+
`Base: ${context.base}`,
|
|
72
|
+
`Head: ${context.head}`,
|
|
73
|
+
'',
|
|
74
|
+
].join('\n'));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
44
78
|
function inHookFixture(run, options = {}) {
|
|
45
79
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
|
|
46
80
|
const hooks = path.join(root, '.codex', 'hooks');
|
|
47
81
|
try {
|
|
48
82
|
fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
|
|
83
|
+
fs.copyFileSync(
|
|
84
|
+
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-command-analysis.cjs'),
|
|
85
|
+
path.join(hooks, 'lib/privacy-command-analysis.cjs'),
|
|
86
|
+
);
|
|
87
|
+
fs.copyFileSync(
|
|
88
|
+
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
|
|
89
|
+
path.join(hooks, 'lib/runtime-path-safety.cjs'),
|
|
90
|
+
);
|
|
49
91
|
fs.mkdirSync(path.join(root, '.codex', 'scripts'), { recursive: true });
|
|
50
92
|
if (options.policy !== 'missing') {
|
|
51
93
|
if (options.policy === 'malformed') {
|
|
@@ -59,12 +101,22 @@ function inHookFixture(run, options = {}) {
|
|
|
59
101
|
path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'),
|
|
60
102
|
path.join(root, '.codex', 'scripts', 'provenance.cjs'),
|
|
61
103
|
);
|
|
104
|
+
fs.copyFileSync(
|
|
105
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-receipt.cjs'),
|
|
106
|
+
path.join(root, '.codex', 'scripts', 'spec-receipt.cjs'),
|
|
107
|
+
);
|
|
62
108
|
}
|
|
63
109
|
}
|
|
64
110
|
fs.copyFileSync(
|
|
65
111
|
path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
|
|
66
112
|
path.join(root, '.codex', 'scripts', 'spec-resolver.cjs'),
|
|
67
113
|
);
|
|
114
|
+
for (const file of ['validate-spec-output.cjs', 'spec-ground.cjs', 'spec-semantic-model.cjs', 'spec-final-state.cjs']) {
|
|
115
|
+
fs.copyFileSync(
|
|
116
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts', file),
|
|
117
|
+
path.join(root, '.codex', 'scripts', file),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
68
120
|
fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
|
|
69
121
|
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
70
122
|
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
@@ -121,6 +173,14 @@ test('Codex privacy approval is exact, session-bound, and one-time', () => {
|
|
|
121
173
|
});
|
|
122
174
|
assert.match(wrongSession.stdout, /rejected/);
|
|
123
175
|
|
|
176
|
+
const padded = runHook(approve, nested, {
|
|
177
|
+
cwd: nested,
|
|
178
|
+
session_id: 'session-a',
|
|
179
|
+
hook_event_name: 'UserPromptSubmit',
|
|
180
|
+
prompt: ` ${prompt}\n`
|
|
181
|
+
});
|
|
182
|
+
assert.equal(padded.stdout, '', 'whitespace-padded prompt must not issue a token');
|
|
183
|
+
|
|
124
184
|
const approved = runHook(approve, nested, {
|
|
125
185
|
cwd: nested,
|
|
126
186
|
session_id: 'session-a',
|
|
@@ -293,10 +353,25 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
|
|
|
293
353
|
fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
|
|
294
354
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
|
|
295
355
|
|
|
296
|
-
const
|
|
297
|
-
assert.equal(
|
|
298
|
-
assert.equal(JSON.parse(
|
|
299
|
-
assert.match(JSON.parse(
|
|
356
|
+
const blockedBeforeCloseout = runHook(gate, nested, payload);
|
|
357
|
+
assert.equal(blockedBeforeCloseout.status, 0);
|
|
358
|
+
assert.equal(JSON.parse(blockedBeforeCloseout.stdout).decision, 'block');
|
|
359
|
+
assert.match(JSON.parse(blockedBeforeCloseout.stdout).reason, /verification receipt/);
|
|
360
|
+
|
|
361
|
+
fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
|
|
362
|
+
'Status: done', '', '## Evidence', '', 'Verification: PASS',
|
|
363
|
+
'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
364
|
+
].join('\n'), 'auth'));
|
|
365
|
+
const validBeforeCloseout = runHook(gate, nested, payload);
|
|
366
|
+
assert.equal(validBeforeCloseout.status, 0);
|
|
367
|
+
assert.equal(validBeforeCloseout.stdout, '');
|
|
368
|
+
assert.equal(fs.existsSync(path.join(feature, 'feature-receipt.md')), false);
|
|
369
|
+
|
|
370
|
+
spec.current_phase = 'closeout';
|
|
371
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
|
|
372
|
+
const closeout = runHook(gate, nested, payload);
|
|
373
|
+
assert.equal(closeout.status, 0);
|
|
374
|
+
assert.equal(closeout.stdout, '');
|
|
300
375
|
});
|
|
301
376
|
});
|
|
302
377
|
|
|
@@ -322,6 +397,7 @@ test('empty or forged Codex hook authority fails closed', () => {
|
|
|
322
397
|
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
323
398
|
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
324
399
|
status: 'in_progress',
|
|
400
|
+
current_phase: 'closeout',
|
|
325
401
|
feature_name: 'auth',
|
|
326
402
|
task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
|
|
327
403
|
}));
|
|
@@ -347,15 +423,16 @@ test('empty or forged Codex hook authority fails closed', () => {
|
|
|
347
423
|
});
|
|
348
424
|
});
|
|
349
425
|
|
|
350
|
-
test('
|
|
426
|
+
test('explicit Strict Codex completion ignores worker-writable proof strings and remains blocked', () => {
|
|
351
427
|
inHookFixture((root, hooks) => {
|
|
352
428
|
const feature = path.join(root, 'specs', 'auth');
|
|
353
429
|
const taskPath = 'tasks/task.md';
|
|
354
430
|
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
355
431
|
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
356
432
|
status: 'in_progress',
|
|
433
|
+
current_phase: 'closeout',
|
|
357
434
|
feature_name: 'auth',
|
|
358
|
-
workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } }),
|
|
435
|
+
workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' }),
|
|
359
436
|
proofs: {
|
|
360
437
|
needsInspection: 'inspection completed',
|
|
361
438
|
needsIndependentAudit: 'PASS',
|
|
@@ -453,6 +530,24 @@ test('Codex state hook persists direct native event fields at project root', ()
|
|
|
453
530
|
});
|
|
454
531
|
});
|
|
455
532
|
|
|
533
|
+
test('semantic review authority hook is registered only for SubagentStop on Claude and Codex', () => {
|
|
534
|
+
const configurations = [
|
|
535
|
+
JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/settings/settings.json'), 'utf8')).hooks,
|
|
536
|
+
JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks.json'), 'utf8')).hooks,
|
|
537
|
+
];
|
|
538
|
+
for (const hooks of configurations) {
|
|
539
|
+
const registrations = [];
|
|
540
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
541
|
+
for (const group of groups) {
|
|
542
|
+
for (const hook of group.hooks || []) {
|
|
543
|
+
if (String(hook.command || '').includes('semantic-review-authority.cjs')) registrations.push(event);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
assert.deepEqual(registrations, ['SubagentStop']);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
456
551
|
test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
457
552
|
const receipt = require(path.join(CODEX_HOOKS, 'lib', 'spec-receipt.cjs'));
|
|
458
553
|
const onlyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
|
|
@@ -494,12 +589,12 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
|
494
589
|
const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
495
590
|
|
|
496
591
|
// only Base -> block
|
|
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' } } }));
|
|
592
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', current_phase: 'closeout', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
498
593
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
|
|
499
594
|
fs.mkdirSync(payload.cwd, { recursive: true });
|
|
500
595
|
const blocked = runHook(gate, payload.cwd, payload);
|
|
501
596
|
assert.equal(JSON.parse(blocked.stdout).decision, 'block');
|
|
502
|
-
assert.match(JSON.parse(blocked.stdout).reason, /\
|
|
597
|
+
assert.match(JSON.parse(blocked.stdout).reason, /\bprovenance\b/);
|
|
503
598
|
|
|
504
599
|
// both -> no block
|
|
505
600
|
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`));
|
|
@@ -511,14 +606,14 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
|
511
606
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
|
|
512
607
|
const blockedEmpty = runHook(gate, payload.cwd, payload);
|
|
513
608
|
assert.equal(JSON.parse(blockedEmpty.stdout).decision, 'block');
|
|
514
|
-
assert.match(JSON.parse(blockedEmpty.stdout).reason, /\
|
|
609
|
+
assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bprovenance\b/);
|
|
515
610
|
|
|
516
611
|
// bare base_sha without colon should block — reset cache again
|
|
517
612
|
try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
|
|
518
613
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
|
|
519
614
|
const blockedBare = runHook(gate, payload.cwd, payload);
|
|
520
615
|
assert.equal(JSON.parse(blockedBare.stdout).decision, 'block');
|
|
521
|
-
assert.match(JSON.parse(blockedBare.stdout).reason, /\
|
|
616
|
+
assert.match(JSON.parse(blockedBare.stdout).reason, /\bprovenance\b/);
|
|
522
617
|
});
|
|
523
618
|
});
|
|
524
619
|
|
|
@@ -540,7 +635,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
|
|
|
540
635
|
|
|
541
636
|
// first-run (no cache) with valid receipt → no block, cache seeded
|
|
542
637
|
try { fs.rmSync(cacheFile, { force: true }); } catch {}
|
|
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' } } }));
|
|
638
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', current_phase: 'closeout', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
544
639
|
const validBody = bindReceipt(root, validBodyTemplate);
|
|
545
640
|
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
546
641
|
let res = runHook(gate, appCwd, payload);
|
|
@@ -557,7 +652,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
|
|
|
557
652
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n');
|
|
558
653
|
res = runHook(gate, appCwd, payload);
|
|
559
654
|
assert.equal(JSON.parse(res.stdout).decision, 'block', 'mutated receipt missing Verification/Command should block on cache-hit');
|
|
560
|
-
assert.match(JSON.parse(res.stdout).reason, /\
|
|
655
|
+
assert.match(JSON.parse(res.stdout).reason, /\bverification_state\b|\bcommand\b/);
|
|
561
656
|
// second hit still blocks
|
|
562
657
|
res = runHook(gate, appCwd, payload);
|
|
563
658
|
assert.equal(JSON.parse(res.stdout).decision, 'block', 'second hit after mutation still blocks');
|
|
@@ -568,7 +663,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
|
|
|
568
663
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\n```\npass\n```\n');
|
|
569
664
|
res = runHook(gate, appCwd, payload);
|
|
570
665
|
assert.equal(JSON.parse(res.stdout).decision, 'block');
|
|
571
|
-
assert.match(JSON.parse(res.stdout).reason, /\
|
|
666
|
+
assert.match(JSON.parse(res.stdout).reason, /\bprovenance\b/);
|
|
572
667
|
|
|
573
668
|
// deletion → block with check a
|
|
574
669
|
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
@@ -777,6 +872,7 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
|
|
|
777
872
|
fs.writeFileSync(path.join(root, 'output', 'bundle.js'), artifact);
|
|
778
873
|
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
779
874
|
status: 'in_progress',
|
|
875
|
+
current_phase: 'closeout',
|
|
780
876
|
feature_name: 'artifact-demo',
|
|
781
877
|
task_registry: {
|
|
782
878
|
[taskPath]: {
|
|
@@ -805,10 +901,344 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
|
|
|
805
901
|
if (shouldBlock) {
|
|
806
902
|
const block = JSON.parse(result.stdout);
|
|
807
903
|
assert.equal(block.decision, 'block');
|
|
808
|
-
assert.match(block.reason, /\
|
|
904
|
+
assert.match(block.reason, /\bartifact_hash\b/);
|
|
809
905
|
} else {
|
|
810
906
|
assert.equal(result.stdout, '');
|
|
811
907
|
}
|
|
812
908
|
});
|
|
813
909
|
}
|
|
814
910
|
});
|
|
911
|
+
|
|
912
|
+
test('Codex privacy denies obfuscated and session paths, allows benign', () => {
|
|
913
|
+
inHookFixture((root, hooks) => {
|
|
914
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
915
|
+
const denied = [
|
|
916
|
+
{ command: "cat .e''nv", tool: 'exec_command' },
|
|
917
|
+
{ command: "cat .codex/sessions/test.json", tool: 'exec_command' },
|
|
918
|
+
{ command: "cat $HOME/.cafekit/completion-authority/hmac.key", tool: 'exec_command' },
|
|
919
|
+
{ command: "cat .codex/sessions/abc.json", tool: 'Bash' },
|
|
920
|
+
{ command: "python3 -c \"open('.env').read()\"", tool: 'exec_command' },
|
|
921
|
+
{ command: "node -e \"fs.readFileSync('.env')\"", tool: 'Bash' },
|
|
922
|
+
{ command: "bash -c 'cat .e''nv'", tool: 'exec_command' },
|
|
923
|
+
{ command: "echo ok; cat .env", tool: 'Bash' },
|
|
924
|
+
];
|
|
925
|
+
for (const { command, tool } of denied) {
|
|
926
|
+
const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
|
|
927
|
+
assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', command);
|
|
928
|
+
}
|
|
929
|
+
const allowed = [
|
|
930
|
+
{ command: "echo hello", tool: 'exec_command' },
|
|
931
|
+
{ command: "cat README.md", tool: 'exec_command' },
|
|
932
|
+
{ command: "cat src/app.js", tool: 'Bash' },
|
|
933
|
+
];
|
|
934
|
+
for (const { command, tool } of allowed) {
|
|
935
|
+
const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
|
|
936
|
+
assert.equal(res.stdout, '', command);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test('Codex privacy denies active read expansion, preserves literal dollars and safe controls', () => {
|
|
942
|
+
inHookFixture((root, hooks) => {
|
|
943
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
944
|
+
const denied = [
|
|
945
|
+
{ command: "cat $FILE", tool: 'Bash' },
|
|
946
|
+
{ command: 'cat "$FILE"', tool: 'exec_command' },
|
|
947
|
+
{ command: "cat ${FILE}", tool: 'Bash' },
|
|
948
|
+
{ command: "cat $1", tool: 'exec_command' },
|
|
949
|
+
{ command: "cat $(echo .env)", tool: 'Bash' },
|
|
950
|
+
{ command: "cat `echo .env`", tool: 'Bash' },
|
|
951
|
+
{ command: "head $SECRET", tool: 'exec_command' },
|
|
952
|
+
{ command: "tail ${MY_FILE}", tool: 'Bash' },
|
|
953
|
+
{ command: "cat README.md $EXTRA", tool: 'Bash' },
|
|
954
|
+
{ command: "head README.md $UNRELATED", tool: 'exec_command' },
|
|
955
|
+
{ command: "echo ok; cat README.md $FILE", tool: 'Bash' },
|
|
956
|
+
{ command: "cat README.md | head $FILE", tool: 'exec_command' },
|
|
957
|
+
{ command: "bash -c 'cat $FILE'", tool: 'Bash' },
|
|
958
|
+
{ command: "echo $(cat $FILE)", tool: 'exec_command' },
|
|
959
|
+
{ command: 'cat README.md "$(printf safe)"', tool: 'Bash' },
|
|
960
|
+
{ command: 'cat README.md *', tool: 'Bash' },
|
|
961
|
+
{ command: 'cat README.md ~/README.md', tool: 'exec_command' },
|
|
962
|
+
{ command: 'cat README.md {README.md,LICENSE}', tool: 'Bash' },
|
|
963
|
+
{ command: 'cat .e\\\nnv', tool: 'Bash' },
|
|
964
|
+
{ command: "cat $'\\x2e\\x65\\x6e\\x76'", tool: 'exec_command' },
|
|
965
|
+
{ command: 'env -u UNUSED cat "$FILE"', tool: 'Bash' },
|
|
966
|
+
{ command: 'timeout 1 cat "$FILE"', tool: 'exec_command' },
|
|
967
|
+
{ command: 'nice -n 10 cat "$FILE"', tool: 'Bash' },
|
|
968
|
+
{ command: "eval 'cat \"$FILE\"'", tool: 'exec_command' },
|
|
969
|
+
{ command: "find . -exec sh -c 'cat \"$FILE\"' \\;", tool: 'Bash' },
|
|
970
|
+
{ command: 'source "$FILE"', tool: 'exec_command' },
|
|
971
|
+
{ command: 'xargs -a "$LIST" cat', tool: 'Bash' },
|
|
972
|
+
{ command: 'python3 -c \'open(os.environ["FILE"]).read()\'', tool: 'exec_command' },
|
|
973
|
+
{ command: 'node -e \'fs.readFileSync(process.env.FILE)\'', tool: 'Bash' },
|
|
974
|
+
{ command: 'cat < "$FILE"', tool: 'exec_command' },
|
|
975
|
+
];
|
|
976
|
+
for (const { command, tool } of denied) {
|
|
977
|
+
const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
|
|
978
|
+
assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', `should deny ${command}`);
|
|
979
|
+
}
|
|
980
|
+
const allowed = [
|
|
981
|
+
{ command: "echo $VAR", tool: 'exec_command' },
|
|
982
|
+
{ command: 'echo "$HOME"', tool: 'Bash' },
|
|
983
|
+
{ command: "git status", tool: 'exec_command' },
|
|
984
|
+
{ command: "git status $VAR", tool: 'Bash' },
|
|
985
|
+
{ command: "cat README.md", tool: 'exec_command' },
|
|
986
|
+
{ command: "cat .env.example", tool: 'Bash' },
|
|
987
|
+
{ command: "cat .env.sample", tool: 'Bash' },
|
|
988
|
+
{ command: "cat .env.template", tool: 'exec_command' },
|
|
989
|
+
{ command: "cat .env.test", tool: 'Bash' },
|
|
990
|
+
{ command: "cat '$FILE'", tool: 'exec_command' },
|
|
991
|
+
{ command: "cat '$HOME/README.md'", tool: 'Bash' },
|
|
992
|
+
{ command: "cat README.md '$EXTRA'", tool: 'exec_command' },
|
|
993
|
+
{ command: "echo '$(cat $FILE)'", tool: 'Bash' },
|
|
994
|
+
{ command: 'echo .envoy', tool: 'exec_command' },
|
|
995
|
+
{ command: 'echo not.env', tool: 'Bash' },
|
|
996
|
+
{ command: 'echo .env', tool: 'exec_command' },
|
|
997
|
+
{ command: "printf '%s\\n' .env", tool: 'Bash' },
|
|
998
|
+
{ command: 'git status sessions.md', tool: 'exec_command' },
|
|
999
|
+
{ command: 'python3 -c "print(os.environ[\'HOME\'])"', tool: 'Bash' },
|
|
1000
|
+
{ command: 'head -n "$COUNT" README.md', tool: 'exec_command' },
|
|
1001
|
+
{ command: 'grep .env README.md', tool: 'Bash' },
|
|
1002
|
+
{ command: "sed -n '/.env/p' README.md", tool: 'exec_command' },
|
|
1003
|
+
];
|
|
1004
|
+
for (const { command, tool } of allowed) {
|
|
1005
|
+
const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
|
|
1006
|
+
assert.equal(res.stdout, '', `should allow ${command}`);
|
|
1007
|
+
}
|
|
1008
|
+
const assignmentOnly = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat README.md' } });
|
|
1009
|
+
assert.equal(assignmentOnly.stdout, '');
|
|
1010
|
+
const assignmentRead = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat "$FILE"' } });
|
|
1011
|
+
assert.equal(JSON.parse(assignmentRead.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
test('Claude and Codex privacy lexers agree on literals, delimiters, and quote expansion', () => {
|
|
1016
|
+
inHookFixture((root, hooks) => {
|
|
1017
|
+
const claude = path.join(PACKAGE_ROOT, 'src/claude/hooks/privacy-block.cjs');
|
|
1018
|
+
const codex = path.join(hooks, 'privacy-block.cjs');
|
|
1019
|
+
const vectors = [
|
|
1020
|
+
["cat .e''nv", true],
|
|
1021
|
+
["python3 -c \"open('.env').read()\"", true],
|
|
1022
|
+
['cat README.md $FILE', true],
|
|
1023
|
+
['echo ok; cat README.md $FILE', true],
|
|
1024
|
+
["cat '$FILE'", false],
|
|
1025
|
+
["echo '$(cat $FILE)'", false],
|
|
1026
|
+
["cat README.md '*'", false],
|
|
1027
|
+
["cat README.md '~'", false],
|
|
1028
|
+
["cat README.md '{README.md,LICENSE}'", false],
|
|
1029
|
+
['cat .env.example', false],
|
|
1030
|
+
['echo .envoy', false],
|
|
1031
|
+
['cat .e\\\nnv', true],
|
|
1032
|
+
["cat $'\\x2e\\x65\\x6e\\x76'", true],
|
|
1033
|
+
['env -u UNUSED cat "$FILE"', true],
|
|
1034
|
+
['timeout 1 cat "$FILE"', true],
|
|
1035
|
+
["eval 'cat \"$FILE\"'", true],
|
|
1036
|
+
["find . -exec sh -c 'cat \"$FILE\"' \\;", true],
|
|
1037
|
+
['source "$FILE"', true],
|
|
1038
|
+
['xargs -a "$LIST" cat', true],
|
|
1039
|
+
['python3 -c \'open(os.environ["FILE"]).read()\'', true],
|
|
1040
|
+
['echo .env', false],
|
|
1041
|
+
["printf '%s\\n' .env", false],
|
|
1042
|
+
['git status sessions.md', false],
|
|
1043
|
+
['python3 -c "print(os.environ[\'HOME\'])"', false],
|
|
1044
|
+
];
|
|
1045
|
+
for (const [command, blocked] of vectors) {
|
|
1046
|
+
const payload = { cwd: root, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command } };
|
|
1047
|
+
const stateDir = path.join(hooks, '.privacy');
|
|
1048
|
+
const pendingCount = () => fs.existsSync(stateDir)
|
|
1049
|
+
? fs.readdirSync(stateDir).filter((name) => name.startsWith('pending-')).length
|
|
1050
|
+
: 0;
|
|
1051
|
+
const beforePending = pendingCount();
|
|
1052
|
+
const claudeResult = runHook(claude, root, payload);
|
|
1053
|
+
const codexResult = runHook(codex, root, { ...payload, session_id: `parity-${command}` });
|
|
1054
|
+
const claudeOutput = claudeResult.stdout.trim() ? JSON.parse(claudeResult.stdout) : null;
|
|
1055
|
+
const codexOutput = codexResult.stdout.trim() ? JSON.parse(codexResult.stdout) : null;
|
|
1056
|
+
assert.equal(claudeOutput?.hookSpecificOutput?.permissionDecision || null, blocked ? 'ask' : null, `Claude parity vector: ${command}`);
|
|
1057
|
+
assert.equal(codexOutput?.hookSpecificOutput?.permissionDecision || null, blocked ? 'deny' : null, `Codex parity vector: ${command}`);
|
|
1058
|
+
if (blocked) {
|
|
1059
|
+
assert.equal(claudeOutput.hookSpecificOutput.hookEventName, 'PreToolUse');
|
|
1060
|
+
assert.equal(codexOutput.hookSpecificOutput.hookEventName, 'PreToolUse');
|
|
1061
|
+
assert.ok(claudeOutput.hookSpecificOutput.permissionDecisionReason);
|
|
1062
|
+
assert.ok(codexOutput.hookSpecificOutput.permissionDecisionReason);
|
|
1063
|
+
}
|
|
1064
|
+
assert.equal(pendingCount() - beforePending, blocked ? 1 : 0, `Codex approval-state side effect: ${command}`);
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
test('Codex privacy handles dangling symlink to protected target (and allows benign/example)', () => {
|
|
1070
|
+
inHookFixture((root, hooks) => {
|
|
1071
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
1072
|
+
// Create .env and dangling link
|
|
1073
|
+
const env = path.join(root, '.env');
|
|
1074
|
+
fs.writeFileSync(env, 'SECRET=1');
|
|
1075
|
+
const link = path.join(root, 'innocent.txt');
|
|
1076
|
+
fs.symlinkSync(env, link);
|
|
1077
|
+
fs.unlinkSync(env);
|
|
1078
|
+
const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: link } });
|
|
1079
|
+
assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny', 'dangling to .env must deny');
|
|
1080
|
+
|
|
1081
|
+
// Dangling benign
|
|
1082
|
+
const benign = path.join(root, 'benign.txt');
|
|
1083
|
+
fs.writeFileSync(benign, 'hi');
|
|
1084
|
+
const dangling = path.join(root, 'dangling.txt');
|
|
1085
|
+
fs.symlinkSync(benign, dangling);
|
|
1086
|
+
fs.unlinkSync(benign);
|
|
1087
|
+
const allowed1 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: dangling } });
|
|
1088
|
+
assert.equal(allowed1.stdout, '', 'dangling benign should allow');
|
|
1089
|
+
|
|
1090
|
+
// Dangling to .env.example
|
|
1091
|
+
const example = path.join(root, '.env.example');
|
|
1092
|
+
fs.writeFileSync(example, 'EXAMPLE=1');
|
|
1093
|
+
const linkExample = path.join(root, 'link-example.txt');
|
|
1094
|
+
fs.symlinkSync(example, linkExample);
|
|
1095
|
+
fs.unlinkSync(example);
|
|
1096
|
+
const allowed2 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: linkExample } });
|
|
1097
|
+
assert.equal(allowed2.stdout, '', 'dangling to .env.example should allow');
|
|
1098
|
+
|
|
1099
|
+
// Real link to allowed example remains allowed
|
|
1100
|
+
fs.writeFileSync(example, 'EXAMPLE=1');
|
|
1101
|
+
const realLink = path.join(root, 'real-example.txt');
|
|
1102
|
+
if (fs.existsSync(realLink)) fs.unlinkSync(realLink);
|
|
1103
|
+
// remove previous dangling if exists
|
|
1104
|
+
try { fs.unlinkSync(linkExample); } catch {}
|
|
1105
|
+
fs.symlinkSync(example, realLink);
|
|
1106
|
+
const allowed3 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: realLink } });
|
|
1107
|
+
assert.equal(allowed3.stdout, '', 'real link to .env.example should allow');
|
|
1108
|
+
|
|
1109
|
+
// Looping symlink fails closed
|
|
1110
|
+
const a = path.join(root, 'loop-a');
|
|
1111
|
+
const b = path.join(root, 'loop-b');
|
|
1112
|
+
try { fs.unlinkSync(a); } catch {}
|
|
1113
|
+
try { fs.unlinkSync(b); } catch {}
|
|
1114
|
+
fs.symlinkSync(b, a);
|
|
1115
|
+
fs.symlinkSync(a, b);
|
|
1116
|
+
const looped = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: a } });
|
|
1117
|
+
assert.equal(JSON.parse(looped.stdout).hookSpecificOutput.permissionDecision, 'deny', 'looping symlink should deny');
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test('Codex privacy apply_patch with temp fixtures respects sensitive vs safe', () => {
|
|
1122
|
+
inHookFixture((root, hooks) => {
|
|
1123
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
1124
|
+
const safePatch = '*** Begin Patch\n*** Update File: README.md\n+hi\n*** End Patch\n';
|
|
1125
|
+
const safeRes = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'apply_patch', tool_input: { patch: safePatch } });
|
|
1126
|
+
assert.equal(safeRes.stdout, '', 'safe patch should allow');
|
|
1127
|
+
const sensitivePatch = '*** Begin Patch\n*** Update File: .env\n+SECRET=x\n*** End Patch\n';
|
|
1128
|
+
const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'apply_patch', tool_input: { patch: sensitivePatch } });
|
|
1129
|
+
assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
test('Codex hook-context canonicalizes symlink project root (regression)', () => {
|
|
1134
|
+
inHookFixture((root, hooks) => {
|
|
1135
|
+
const hookContextPath = path.join(hooks, 'lib', 'hook-context.cjs');
|
|
1136
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-symroot-'));
|
|
1137
|
+
try {
|
|
1138
|
+
const linkRoot = path.join(outside, 'link-project');
|
|
1139
|
+
fs.symlinkSync(root, linkRoot);
|
|
1140
|
+
// Require the installed hook-context via the symlink path to see PROJECT_ROOT canonicalization
|
|
1141
|
+
const linkedContext = require(path.join(linkRoot, '.codex', 'hooks', 'lib', 'hook-context.cjs'));
|
|
1142
|
+
const realRoot = fs.realpathSync(root);
|
|
1143
|
+
assert.equal(linkedContext.PROJECT_ROOT, realRoot, 'PROJECT_ROOT should be canonicalized via realpath');
|
|
1144
|
+
// getHookContext should still work with cwd inside symlink root
|
|
1145
|
+
const payload = { cwd: linkRoot, session_id: 's1' };
|
|
1146
|
+
const ctx = linkedContext.getHookContext(payload);
|
|
1147
|
+
assert.equal(ctx.projectRoot, realRoot);
|
|
1148
|
+
assert.ok(ctx.sessionCwd, 'sessionCwd should be resolved');
|
|
1149
|
+
} finally {
|
|
1150
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
1151
|
+
// Clear require cache for the symlink context to avoid polluting other tests
|
|
1152
|
+
Object.keys(require.cache).forEach((k) => { if (k.includes('link-project')) delete require.cache[k]; });
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test('Codex resolver and state reject symlink escape', () => {
|
|
1158
|
+
inHookFixture((root, hooks) => {
|
|
1159
|
+
const specUtils = require(path.join(hooks, 'lib', 'spec-utils.cjs'));
|
|
1160
|
+
const resolverPath = path.join(root, '.codex', 'scripts', 'spec-resolver.cjs');
|
|
1161
|
+
const linkTarget = path.join(root, 'external.js');
|
|
1162
|
+
fs.writeFileSync(linkTarget, 'module.exports = {}');
|
|
1163
|
+
const original = fs.readFileSync(resolverPath);
|
|
1164
|
+
fs.unlinkSync(resolverPath);
|
|
1165
|
+
fs.symlinkSync(linkTarget, resolverPath);
|
|
1166
|
+
assert.throws(() => specUtils.specsDirectory(root, {}), /symlink|rejected/i);
|
|
1167
|
+
fs.unlinkSync(resolverPath);
|
|
1168
|
+
fs.writeFileSync(resolverPath, original);
|
|
1169
|
+
const scripts = path.dirname(resolverPath);
|
|
1170
|
+
const outsideScripts = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-scripts-outside-'));
|
|
1171
|
+
try {
|
|
1172
|
+
fs.cpSync(scripts, outsideScripts, { recursive: true });
|
|
1173
|
+
fs.rmSync(scripts, { recursive: true, force: true });
|
|
1174
|
+
fs.symlinkSync(outsideScripts, scripts);
|
|
1175
|
+
assert.throws(() => specUtils.specsDirectory(root, {}), /symlink|trusted root|rejected/i);
|
|
1176
|
+
} finally {
|
|
1177
|
+
try { fs.unlinkSync(scripts); } catch {}
|
|
1178
|
+
fs.mkdirSync(scripts, { recursive: true });
|
|
1179
|
+
fs.copyFileSync(
|
|
1180
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
|
|
1181
|
+
path.join(scripts, 'spec-resolver.cjs'),
|
|
1182
|
+
);
|
|
1183
|
+
fs.rmSync(outsideScripts, { recursive: true, force: true });
|
|
1184
|
+
}
|
|
1185
|
+
// State dir symlink escape: create symlink for session-state dir
|
|
1186
|
+
const stateStore = require(path.join(hooks, 'lib', 'state-store.cjs'));
|
|
1187
|
+
const sessionId = 'sess-symlink';
|
|
1188
|
+
const dir = stateStore.stateDir(root, sessionId);
|
|
1189
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
1190
|
+
// Create a symlink that points outside project
|
|
1191
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'));
|
|
1192
|
+
try {
|
|
1193
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
1194
|
+
fs.symlinkSync(outside, dir);
|
|
1195
|
+
// withStateLock should handle symlink safely (no crash, no write outside)
|
|
1196
|
+
const ok = stateStore.withStateLock(root, sessionId, () => {});
|
|
1197
|
+
assert.equal(typeof ok, 'boolean');
|
|
1198
|
+
} finally {
|
|
1199
|
+
try { fs.unlinkSync(dir); } catch {}
|
|
1200
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test('Codex privacy state rejects a symlinked state directory without external writes', () => {
|
|
1206
|
+
inHookFixture((root, hooks) => {
|
|
1207
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
1208
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-privacy-outside-'));
|
|
1209
|
+
const state = path.join(hooks, '.privacy');
|
|
1210
|
+
try {
|
|
1211
|
+
fs.symlinkSync(outside, state);
|
|
1212
|
+
const result = runHook(block, root, {
|
|
1213
|
+
cwd: root,
|
|
1214
|
+
session_id: 'state-symlink',
|
|
1215
|
+
hook_event_name: 'PreToolUse',
|
|
1216
|
+
tool_name: 'Read',
|
|
1217
|
+
tool_input: { file_path: path.join(root, '.env') },
|
|
1218
|
+
});
|
|
1219
|
+
const output = JSON.parse(result.stdout);
|
|
1220
|
+
assert.equal(output.hookSpecificOutput.permissionDecision, 'deny');
|
|
1221
|
+
assert.match(output.hookSpecificOutput.permissionDecisionReason, /could not be evaluated safely/i);
|
|
1222
|
+
assert.deepEqual(fs.readdirSync(outside), []);
|
|
1223
|
+
} finally {
|
|
1224
|
+
try { fs.unlinkSync(state); } catch {}
|
|
1225
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
test('Claude and Codex fail closed consistently for malformed cwd payloads', () => {
|
|
1231
|
+
inHookFixture((root, hooks) => {
|
|
1232
|
+
const payload = {
|
|
1233
|
+
cwd: 123,
|
|
1234
|
+
session_id: 'malformed-cwd',
|
|
1235
|
+
hook_event_name: 'PreToolUse',
|
|
1236
|
+
tool_name: 'Read',
|
|
1237
|
+
tool_input: { file_path: path.join(root, '.env') },
|
|
1238
|
+
};
|
|
1239
|
+
const claude = runHook(path.join(PACKAGE_ROOT, 'src/claude/hooks/privacy-block.cjs'), root, payload);
|
|
1240
|
+
const codex = runHook(path.join(hooks, 'privacy-block.cjs'), root, payload);
|
|
1241
|
+
assert.equal(JSON.parse(claude.stdout).hookSpecificOutput.permissionDecision, 'ask');
|
|
1242
|
+
assert.equal(JSON.parse(codex.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
1243
|
+
});
|
|
1244
|
+
});
|