@haposoft/cafekit 0.18.0 → 0.18.2
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/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +938 -237
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +222 -149
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +102 -1362
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/package-inventory.test.js +1 -395
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/spec-narrowing.test.js +30 -134
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
- package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/__tests__/validator-grounding.test.js +17 -2929
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/lib/managed-writer.js +2 -3
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -49
- package/bin/phases/copy-payload.js +11 -28
- package/bin/phases/omp-runtime.js +6 -3
- package/package.json +2 -3
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/agents/code-auditor.md +9 -6
- package/src/claude/agents/debugger.md +3 -9
- package/src/claude/agents/docs-keeper.md +4 -7
- package/src/claude/agents/git-ops.md +2 -4
- package/src/claude/agents/implementer.md +6 -7
- package/src/claude/agents/inspector.md +2 -0
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/spec-maker.md +4 -4
- package/src/claude/agents/test-runner.md +8 -11
- package/src/claude/agents/ui-ux-designer.md +0 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +17 -89
- package/src/claude/hooks/spec-state.cjs +89 -96
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +20 -8
- package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
- package/src/claude/output-styles/coding-level-1-junior.md +3 -3
- package/src/claude/output-styles/coding-level-2-mid.md +3 -3
- package/src/claude/output-styles/coding-level-3-senior.md +2 -2
- package/src/claude/output-styles/coding-level-4-lead.md +5 -5
- package/src/claude/output-styles/coding-level-5-god.md +6 -6
- package/src/claude/rules/ai-dev-rules.md +1 -2
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/scripts/spec-receipt.cjs +0 -114
- package/src/claude/scripts/spec-resolver.cjs +41 -457
- package/src/claude/scripts/workflow-policy.cjs +3 -1801
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/agent-browser/SKILL.md +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
- package/src/claude/skills/code-review/SKILL.md +25 -108
- package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
- package/src/claude/skills/develop/SKILL.md +22 -70
- package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/research/SKILL.md +24 -55
- package/src/claude/skills/sync/SKILL.md +12 -34
- package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
- package/src/claude/skills/test/SKILL.md +39 -123
- package/src/claude/skills/test/references/execution-strategy.md +11 -22
- package/src/claude/skills/ui-ux/SKILL.md +1 -1
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
- package/src/claude/skills/web-testing/SKILL.md +2 -20
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/spec-utils.cjs +6 -45
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +32 -74
- package/src/codex/hooks/spec-state.cjs +68 -46
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/common/AGENTS.md +4 -10
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
- package/src/claude/hooks/lib/context.cjs +0 -527
- package/src/claude/hooks/lib/detect.cjs +0 -474
- package/src/claude/scripts/change-firewall.cjs +0 -1748
- package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
- package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
- package/src/claude/scripts/spec-final-state.cjs +0 -290
- package/src/claude/scripts/spec-ground.cjs +0 -650
- package/src/claude/scripts/spec-readiness.cjs +0 -425
- package/src/claude/scripts/spec-scaffold.cjs +0 -2317
- package/src/claude/scripts/spec-semantic-model.cjs +0 -396
- package/src/claude/scripts/validate-spec-output.cjs +0 -3914
- package/src/claude/skills/code-review/references/verification-gate.md +0 -41
- package/src/claude/skills/specs/templates/design.md +0 -82
- package/src/claude/skills/specs/templates/requirements-init.md +0 -8
- package/src/claude/skills/specs/templates/requirements.md +0 -42
- package/src/claude/skills/specs/templates/spec-state.json +0 -56
- package/src/claude/skills/specs/templates/task.md +0 -47
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -5,9 +5,84 @@ const crypto = require('node:crypto');
|
|
|
5
5
|
const fs = require('node:fs');
|
|
6
6
|
const os = require('node:os');
|
|
7
7
|
const path = require('node:path');
|
|
8
|
-
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { spawnSync, spawn } = require('node:child_process');
|
|
9
9
|
const { test } = require('node:test');
|
|
10
10
|
|
|
11
|
+
const OLD_INSPECTION_MATCHER = 'Bash|exec_command|apply_patch|Edit|Write|Read|Glob|Grep|mcp__.*|shell';
|
|
12
|
+
const NEW_INSPECTION_MATCHER = 'Bash|exec_command|shell|apply_patch|mcp__.*|collaborationspawn_agent|collaborationwait_agent';
|
|
13
|
+
function matcherFixture(run) {
|
|
14
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-matcher-'));
|
|
15
|
+
const previous = process.cwd();
|
|
16
|
+
const config = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../src/codex/hooks.json'), 'utf8'));
|
|
17
|
+
const messages = [], warnings = [];
|
|
18
|
+
const ctx = { dryRun: false, results: { updated: 0, unchanged: 0, errors: 0 },
|
|
19
|
+
manifest: require('../../src/claude/migration-manifest.json'),
|
|
20
|
+
ui: { detail: value => messages.push(value), warn: value => warnings.push(value) } };
|
|
21
|
+
const file = path.join(root, '.codex/hooks.json');
|
|
22
|
+
try {
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true }); process.chdir(root);
|
|
24
|
+
const merge = require('../phases/codex-hooks.js').mergeCodexHooks;
|
|
25
|
+
run({ root, config, ctx, file, messages, warnings, merge: () => merge(ctx, 'codex', root),
|
|
26
|
+
save: () => fs.writeFileSync(file, JSON.stringify(config)), read: () => JSON.parse(fs.readFileSync(file, 'utf8')) });
|
|
27
|
+
} finally { process.chdir(previous); fs.rmSync(root, { recursive: true, force: true }); }
|
|
28
|
+
}
|
|
29
|
+
test('upgrade refreshes a former managed inspection matcher and reports it', () => matcherFixture(f => {
|
|
30
|
+
f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER; f.save();
|
|
31
|
+
const before = fs.readFileSync(f.file, 'utf8');
|
|
32
|
+
f.ctx.dryRun = true; f.merge();
|
|
33
|
+
assert.equal(fs.readFileSync(f.file, 'utf8'), before);
|
|
34
|
+
assert.ok(f.messages.some(line => /↻.*matcher updated/.test(line)));
|
|
35
|
+
f.ctx.dryRun = false; f.messages.length = 0; f.merge();
|
|
36
|
+
assert.equal(f.read().hooks.PreToolUse[0].matcher, NEW_INSPECTION_MATCHER);
|
|
37
|
+
assert.ok(f.messages.some(line => /↻.*matcher updated/.test(line)));
|
|
38
|
+
assert.ok(!f.messages.some(line => /already registered/.test(line)));
|
|
39
|
+
}));
|
|
40
|
+
test('upgrade preserves a mixed user group and warns', () => matcherFixture(f => {
|
|
41
|
+
f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER;
|
|
42
|
+
f.config.hooks.PreToolUse[0].hooks.push({ type: 'command', command: 'echo user-owned' }); f.save(); f.merge();
|
|
43
|
+
assert.equal(f.read().hooks.PreToolUse[0].matcher, OLD_INSPECTION_MATCHER);
|
|
44
|
+
assert.ok(f.read().hooks.PreToolUse[0].hooks.some(h => h.command === 'echo user-owned'));
|
|
45
|
+
assert.ok(f.warnings.some(line => /matcher.*preserv/i.test(line)));
|
|
46
|
+
}));
|
|
47
|
+
test('reinstall keeps purely user groups unchanged without matcher warnings', () => matcherFixture(f => {
|
|
48
|
+
const userPre = { matcher: 'Bash', hooks: [{ command: 'my-linter' }] };
|
|
49
|
+
const userPost = { hooks: [{ command: 'my-logger' }] };
|
|
50
|
+
f.config.hooks = { PreToolUse: [userPre], PostToolUse: [userPost] }; f.save();
|
|
51
|
+
const installs = Array.from({ length: 2 }, () => {
|
|
52
|
+
const result = spawnSync(process.execPath, [path.resolve(__dirname, '../install.js'), '--platform', 'codex', '-y'], {
|
|
53
|
+
cwd: f.root, encoding: 'utf8',
|
|
54
|
+
});
|
|
55
|
+
return { result, config: f.read() };
|
|
56
|
+
});
|
|
57
|
+
for (const { result, config } of installs) {
|
|
58
|
+
assert.equal(result.status, 0, result.stderr);
|
|
59
|
+
for (const [event, original] of [['PreToolUse', userPre], ['PostToolUse', userPost]]) {
|
|
60
|
+
const group = config.hooks[event].find(g => g.hooks.some(h => h.command === original.hooks[0].command));
|
|
61
|
+
assert.equal(JSON.stringify(group), JSON.stringify(original));
|
|
62
|
+
}
|
|
63
|
+
assert.doesNotMatch(result.stdout + result.stderr, /matcher preserved/);
|
|
64
|
+
}
|
|
65
|
+
}));
|
|
66
|
+
test('fresh hook registration uses every template matcher', () => matcherFixture(f => {
|
|
67
|
+
f.merge();
|
|
68
|
+
for (const [event, groups] of Object.entries(f.config.hooks)) {
|
|
69
|
+
assert.deepEqual(f.read().hooks[event].map(g => g.matcher), groups.map(g => g.matcher));
|
|
70
|
+
}
|
|
71
|
+
}));
|
|
72
|
+
test('upgrade preserves a user-selected managed matcher and warns', () => matcherFixture(f => {
|
|
73
|
+
f.config.hooks.PreToolUse[0].matcher = 'Read|Grep'; f.save(); f.merge();
|
|
74
|
+
assert.equal(f.read().hooks.PreToolUse[0].matcher, 'Read|Grep');
|
|
75
|
+
assert.ok(f.warnings.some(line => /matcher.*preserv/i.test(line)));
|
|
76
|
+
}));
|
|
77
|
+
test('upgrade refreshes the surviving group after retired hooks are pruned', () => matcherFixture(f => {
|
|
78
|
+
f.config.hooks.PreToolUse[0].matcher = OLD_INSPECTION_MATCHER;
|
|
79
|
+
f.config.hooks.PreToolUse[0].hooks.push({ type: 'command', command: 'node ".codex/hooks/task-scaffold-guard.cjs"' });
|
|
80
|
+
f.save(); f.merge();
|
|
81
|
+
assert.equal(f.read().hooks.PreToolUse[0].matcher, NEW_INSPECTION_MATCHER);
|
|
82
|
+
assert.ok(!JSON.stringify(f.read()).includes('task-scaffold-guard'));
|
|
83
|
+
assert.ok(f.messages.some(line => /matcher updated/.test(line)));
|
|
84
|
+
}));
|
|
85
|
+
|
|
11
86
|
const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
12
87
|
const CODEX_HOOKS = path.join(PACKAGE_ROOT, 'src/codex/hooks');
|
|
13
88
|
const PROVENANCE = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'));
|
|
@@ -19,10 +94,6 @@ const { stateDir: codexStateDir } = require(
|
|
|
19
94
|
);
|
|
20
95
|
|
|
21
96
|
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
|
-
}
|
|
26
97
|
return spawnSync(process.execPath, [file], {
|
|
27
98
|
cwd,
|
|
28
99
|
input: JSON.stringify(payload),
|
|
@@ -34,7 +105,7 @@ function runtimeContext(root, feature = 'auth', session = 'session-a') {
|
|
|
34
105
|
return PROVENANCE.deriveRuntimeContext({
|
|
35
106
|
projectRoot: root,
|
|
36
107
|
specsRoot: path.join(root, 'specs'),
|
|
37
|
-
specFile: path.join(root, 'specs', feature, '
|
|
108
|
+
specFile: path.join(root, 'specs', feature, 'plan.md'),
|
|
38
109
|
featureName: feature,
|
|
39
110
|
runtimeSession: session,
|
|
40
111
|
});
|
|
@@ -55,45 +126,41 @@ function workflowRuntimeContext(root, feature = 'auth', session = 'session-a') {
|
|
|
55
126
|
});
|
|
56
127
|
}
|
|
57
128
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (!explicitCloseout) continue;
|
|
71
|
-
const tasks = Object.values(spec.task_registry || {});
|
|
72
|
-
if (tasks.length === 0 || tasks.some((task) => task.status !== 'done')) continue;
|
|
73
|
-
const context = runtimeContext(root, feature, session);
|
|
74
|
-
fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), [
|
|
75
|
-
`Feature: ${feature}`,
|
|
76
|
-
'Expected: final integration verification passes',
|
|
77
|
-
'Observed: final integration verification passed',
|
|
78
|
-
'Verification: PASS',
|
|
79
|
-
'Command: node --test',
|
|
80
|
-
'Exit: 0',
|
|
81
|
-
`Base: ${context.base}`,
|
|
82
|
-
`Head: ${context.head}`,
|
|
83
|
-
'',
|
|
84
|
-
].join('\n'));
|
|
85
|
-
}
|
|
129
|
+
/** A process-first packet with one flat task whose Verification Plan runs `command`. */
|
|
130
|
+
function writeProcessTask(featureDir, status, receiptLines = null, command = 'pnpm test', taskName = 'task-01-auth.md') {
|
|
131
|
+
fs.mkdirSync(featureDir, { recursive: true });
|
|
132
|
+
fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
|
|
133
|
+
const lines = [
|
|
134
|
+
'# Task 01', '', `Status: ${status}`, '',
|
|
135
|
+
'## Dependencies', '', '- none', '',
|
|
136
|
+
'## Verification Plan', '', `- Command: ${command}`, '',
|
|
137
|
+
];
|
|
138
|
+
if (receiptLines) lines.push('## Receipt', '', ...receiptLines, '');
|
|
139
|
+
fs.writeFileSync(path.join(featureDir, taskName), lines.join('\n'));
|
|
140
|
+
return path.join(featureDir, taskName);
|
|
86
141
|
}
|
|
87
142
|
|
|
88
143
|
function inHookFixture(run, options = {}) {
|
|
89
144
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
|
|
90
145
|
const hooks = path.join(root, '.codex', 'hooks');
|
|
146
|
+
let deferred = false;
|
|
91
147
|
try {
|
|
92
148
|
fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
|
|
149
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
|
|
150
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
|
|
151
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
|
|
152
|
+
fs.copyFileSync(
|
|
153
|
+
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/inspect-paths.cjs'),
|
|
154
|
+
path.join(hooks, 'lib/inspect-paths.cjs'),
|
|
155
|
+
);
|
|
93
156
|
fs.copyFileSync(
|
|
94
157
|
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-command-analysis.cjs'),
|
|
95
158
|
path.join(hooks, 'lib/privacy-command-analysis.cjs'),
|
|
96
159
|
);
|
|
160
|
+
fs.copyFileSync(
|
|
161
|
+
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-paths.cjs'),
|
|
162
|
+
path.join(hooks, 'lib/privacy-paths.cjs'),
|
|
163
|
+
);
|
|
97
164
|
fs.copyFileSync(
|
|
98
165
|
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
|
|
99
166
|
path.join(hooks, 'lib/runtime-path-safety.cjs'),
|
|
@@ -121,20 +188,19 @@ function inHookFixture(run, options = {}) {
|
|
|
121
188
|
path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
|
|
122
189
|
path.join(root, '.codex', 'scripts', 'spec-resolver.cjs'),
|
|
123
190
|
);
|
|
124
|
-
for (const file of ['validate-spec-output.cjs', 'spec-ground.cjs', 'spec-semantic-model.cjs', 'spec-final-state.cjs']) {
|
|
125
|
-
fs.copyFileSync(
|
|
126
|
-
path.join(PACKAGE_ROOT, 'src/claude/scripts', file),
|
|
127
|
-
path.join(root, '.codex', 'scripts', file),
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
191
|
fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
|
|
131
192
|
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
132
193
|
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
133
194
|
assert.equal(result.status, 0, result.stderr);
|
|
134
195
|
}
|
|
135
|
-
|
|
196
|
+
const result = run(root, hooks);
|
|
197
|
+
if (result && typeof result.then === 'function') {
|
|
198
|
+
deferred = true;
|
|
199
|
+
return result.finally(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
136
202
|
} finally {
|
|
137
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
203
|
+
if (!deferred) fs.rmSync(root, { recursive: true, force: true });
|
|
138
204
|
}
|
|
139
205
|
}
|
|
140
206
|
|
|
@@ -146,6 +212,116 @@ function processTaskContent(title, status, dependency = 'none') {
|
|
|
146
212
|
].join('\n');
|
|
147
213
|
}
|
|
148
214
|
|
|
215
|
+
function inNoCommitCodexFixture(run) {
|
|
216
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-no-commit-'));
|
|
217
|
+
const hooks = path.join(root, '.codex/hooks');
|
|
218
|
+
try {
|
|
219
|
+
fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
|
|
220
|
+
for (const name of ['privacy-paths.cjs', 'privacy-command-analysis.cjs']) {
|
|
221
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib', name), path.join(hooks, 'lib', name));
|
|
222
|
+
}
|
|
223
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
|
|
224
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
|
|
225
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
|
|
226
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
|
|
227
|
+
path.join(hooks, 'lib/runtime-path-safety.cjs'));
|
|
228
|
+
const scripts = path.join(root, '.codex/scripts');
|
|
229
|
+
fs.mkdirSync(scripts, { recursive: true });
|
|
230
|
+
for (const name of ['workflow-policy.cjs', 'provenance.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs']) {
|
|
231
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts', name), path.join(scripts, name));
|
|
232
|
+
}
|
|
233
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test']]) {
|
|
234
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
235
|
+
assert.equal(result.status, 0, result.stderr);
|
|
236
|
+
}
|
|
237
|
+
const writePacket = (feature, status) => {
|
|
238
|
+
const dir = path.join(root, 'specs', feature);
|
|
239
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
240
|
+
fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
|
|
241
|
+
fs.writeFileSync(path.join(dir, 'task-01-work.md'), processTaskContent('Task 01: work', status)
|
|
242
|
+
+ (status === 'done' ? '\n## Receipt\n\nVerification: PASS\n' : ''));
|
|
243
|
+
};
|
|
244
|
+
const invoke = (hook, event) => runHook(path.join(hooks, `${hook}.cjs`), root, {
|
|
245
|
+
cwd: root, session_id: 'no-commit-codex', hook_event_name: event, prompt: 'Continue specs/auth',
|
|
246
|
+
});
|
|
247
|
+
run({ writePacket, gate: () => invoke('spec-gate', 'Stop'), state: () => invoke('spec-state', 'UserPromptSubmit') });
|
|
248
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
test('Codex collaborationspawn_agent appears as the latest tracked tool on resume', () => {
|
|
252
|
+
inHookFixture((root, hooks) => {
|
|
253
|
+
const state = path.join(hooks, 'state.cjs');
|
|
254
|
+
const session = { cwd: root, session_id: 'spawn-tracking' };
|
|
255
|
+
const tracked = runHook(state, root, {
|
|
256
|
+
...session, hook_event_name: 'PostToolUse', tool_name: 'collaborationspawn_agent',
|
|
257
|
+
tool_input: { message: 'Inspect the parser behavior' },
|
|
258
|
+
});
|
|
259
|
+
assert.equal(tracked.status, 0, tracked.stderr);
|
|
260
|
+
const resumed = runHook(state, root, { ...session, hook_event_name: 'SessionStart', source: 'resume' });
|
|
261
|
+
assert.equal(resumed.status, 0, resumed.stderr);
|
|
262
|
+
assert.match(resumed.stdout, /Latest Tracked Tool/);
|
|
263
|
+
assert.match(resumed.stdout, /collaborationspawn_agent/);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('Codex collaborationspawn_agent input does not create a pending todo', () => {
|
|
268
|
+
inHookFixture((root, hooks) => {
|
|
269
|
+
const state = path.join(hooks, 'state.cjs');
|
|
270
|
+
const session = { cwd: root, session_id: 'spawn-no-todo' };
|
|
271
|
+
const title = 'Inspect parser without creating a todo';
|
|
272
|
+
const tracked = runHook(state, root, {
|
|
273
|
+
...session, hook_event_name: 'PostToolUse', tool_name: 'collaborationspawn_agent',
|
|
274
|
+
tool_input: { id: 'spawn-child', title },
|
|
275
|
+
});
|
|
276
|
+
assert.equal(tracked.status, 0, tracked.stderr);
|
|
277
|
+
const resumed = runHook(state, root, { ...session, hook_event_name: 'SessionStart', source: 'resume' });
|
|
278
|
+
assert.equal(resumed.status, 0, resumed.stderr);
|
|
279
|
+
assert.match(resumed.stdout, /Latest Tracked Tool/);
|
|
280
|
+
assert.match(resumed.stdout, /collaborationspawn_agent/);
|
|
281
|
+
const pending = resumed.stdout.match(/## What's Left\n([\s\S]*?)(?=\n## |$)/);
|
|
282
|
+
assert.ok(pending, 'the pending task section must be present');
|
|
283
|
+
assert.ok(!pending[1].includes(title), 'spawn metadata must not become a pending task');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('Codex no commits allows Stop without done tasks and prints one reminder', () => {
|
|
288
|
+
inNoCommitCodexFixture(({ writePacket, gate, state }) => {
|
|
289
|
+
writePacket('auth', 'in_progress');
|
|
290
|
+
const stopped = gate();
|
|
291
|
+
assert.equal(stopped.status, 0, stopped.stderr);
|
|
292
|
+
assert.equal(stopped.stdout.trim(), '');
|
|
293
|
+
const prompted = state();
|
|
294
|
+
assert.equal(prompted.status, 0, prompted.stderr);
|
|
295
|
+
assert.match(prompted.stdout, /no commits/);
|
|
296
|
+
assert.equal(prompted.stdout.trim().split('\n').length, 1);
|
|
297
|
+
assert.doesNotMatch(prompted.stdout, /Spec tollgate unavailable/);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('Codex no commits blocks a done task with first commit guidance', () => {
|
|
302
|
+
inNoCommitCodexFixture(({ writePacket, gate }) => {
|
|
303
|
+
writePacket('auth', 'done');
|
|
304
|
+
const result = gate();
|
|
305
|
+
assert.equal(result.status, 0, result.stderr);
|
|
306
|
+
const body = JSON.parse(result.stdout);
|
|
307
|
+
assert.equal(body.decision, 'block');
|
|
308
|
+
assert.match(body.reason, /first commit/);
|
|
309
|
+
assert.doesNotMatch(body.reason, /controlled failure/);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('Codex no commits blocks two completed packets with first commit guidance', () => {
|
|
314
|
+
inNoCommitCodexFixture(({ writePacket, gate }) => {
|
|
315
|
+
for (const feature of ['auth', 'billing']) writePacket(feature, 'done');
|
|
316
|
+
const result = gate();
|
|
317
|
+
assert.equal(result.status, 0, result.stderr);
|
|
318
|
+
const body = JSON.parse(result.stdout);
|
|
319
|
+
assert.equal(body.decision, 'block');
|
|
320
|
+
assert.match(body.reason, /first commit/);
|
|
321
|
+
assert.doesNotMatch(body.reason, /controlled failure/);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
149
325
|
function canonicalProcessTask(root, title, dependency = 'none') {
|
|
150
326
|
const context = workflowRuntimeContext(root, 'auth', 'session-auth-state');
|
|
151
327
|
return [
|
|
@@ -245,6 +421,138 @@ function inInstalledProcessSpecStateFixture(run) {
|
|
|
245
421
|
});
|
|
246
422
|
}
|
|
247
423
|
|
|
424
|
+
function writeCodexLegacyPacket(root, name = 'old') {
|
|
425
|
+
const dir = path.join(root, 'specs', name);
|
|
426
|
+
fs.mkdirSync(path.join(dir, 'tasks'), { recursive: true });
|
|
427
|
+
fs.writeFileSync(path.join(dir, 'spec.json'), JSON.stringify({
|
|
428
|
+
feature_name: name,
|
|
429
|
+
status: 'in_progress',
|
|
430
|
+
task_registry: { 'tasks/task-R1-01-x.md': { status: 'done' } },
|
|
431
|
+
}));
|
|
432
|
+
fs.writeFileSync(path.join(dir, 'tasks', 'task-R1-01-x.md'), '# Task\n\n**Status:** done\n');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const CODEX_LEGACY_NOTICE = /`specs\/old` is a legacy Specs packet without plan\.md; CafeKit no longer reads it/g;
|
|
436
|
+
|
|
437
|
+
test('Codex spec-state notices a leftover legacy packet once per session, also when it is the only packet', () => {
|
|
438
|
+
inHookFixture((root, hooks) => {
|
|
439
|
+
writeCodexLegacyPacket(root);
|
|
440
|
+
const prompt = (session) => runHook(path.join(hooks, 'spec-state.cjs'), root, {
|
|
441
|
+
cwd: root, session_id: session, hook_event_name: 'UserPromptSubmit', prompt: 'Continue',
|
|
442
|
+
});
|
|
443
|
+
const first = prompt('session-a');
|
|
444
|
+
assert.equal(first.status, 0, first.stderr);
|
|
445
|
+
assert.equal((first.stdout.match(CODEX_LEGACY_NOTICE) || []).length, 1);
|
|
446
|
+
assert.equal((prompt('session-a').stdout.match(CODEX_LEGACY_NOTICE) || []).length, 0, 'the same session is not told twice');
|
|
447
|
+
assert.equal((prompt('session-b').stdout.match(CODEX_LEGACY_NOTICE) || []).length, 1, 'a new session is told once');
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('Codex Stop ignores a leftover legacy packet and still blocks a malformed process-first packet', () => {
|
|
452
|
+
inHookFixture((root, hooks) => {
|
|
453
|
+
const gate = path.join(hooks, 'spec-gate.cjs');
|
|
454
|
+
const payload = { cwd: root, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
455
|
+
writeCodexLegacyPacket(root);
|
|
456
|
+
const legacyOnly = runHook(gate, root, payload);
|
|
457
|
+
assert.equal(legacyOnly.status, 0, legacyOnly.stderr);
|
|
458
|
+
assert.equal(legacyOnly.stdout, '', 'a legacy packet alone never blocks');
|
|
459
|
+
|
|
460
|
+
const shared = path.join(root, 'specs', '_shared');
|
|
461
|
+
fs.mkdirSync(shared, { recursive: true });
|
|
462
|
+
fs.writeFileSync(path.join(shared, 'active-feature.json'), '{"featureName": "old"}\n');
|
|
463
|
+
assert.equal(runHook(gate, root, payload).stdout, '', 'a recorded legacy packet never blocks');
|
|
464
|
+
fs.rmSync(shared, { recursive: true, force: true });
|
|
465
|
+
|
|
466
|
+
const feature = path.join(root, 'specs', 'cycle');
|
|
467
|
+
writeProcessTask(feature, 'done', null, 'pnpm test', 'task-01-a.md');
|
|
468
|
+
writeProcessTask(feature, 'pending', null, 'pnpm test', 'task-02-b.md');
|
|
469
|
+
for (const [name, dependency] of [['task-01-a.md', 'task-02-b.md'], ['task-02-b.md', 'task-01-a.md']]) {
|
|
470
|
+
const file = path.join(feature, name);
|
|
471
|
+
fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('- none', `- ${dependency}`));
|
|
472
|
+
}
|
|
473
|
+
const cycle = runHook(gate, root, payload);
|
|
474
|
+
assert.equal(cycle.status, 0, cycle.stderr);
|
|
475
|
+
const block = JSON.parse(cycle.stdout);
|
|
476
|
+
assert.equal(block.decision, 'block');
|
|
477
|
+
assert.match(block.reason, /invalid workflow packet/);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('Codex Stop still blocks a recorded missing packet and reads a plan.md + spec.json packet as process-first', () => {
|
|
482
|
+
inHookFixture((root, hooks) => {
|
|
483
|
+
const gate = path.join(hooks, 'spec-gate.cjs');
|
|
484
|
+
const payload = { cwd: root, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
485
|
+
const feature = path.join(root, 'specs', 'auth');
|
|
486
|
+
writeProcessTask(feature, 'done');
|
|
487
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ feature_name: 'auth' }));
|
|
488
|
+
const hybrid = runHook(gate, root, payload);
|
|
489
|
+
assert.equal(hybrid.status, 0, hybrid.stderr);
|
|
490
|
+
const hybridBlock = JSON.parse(hybrid.stdout);
|
|
491
|
+
assert.equal(hybridBlock.decision, 'block');
|
|
492
|
+
assert.match(hybridBlock.reason, /task-01-auth\.md/);
|
|
493
|
+
|
|
494
|
+
const shared = path.join(root, 'specs', '_shared');
|
|
495
|
+
fs.mkdirSync(shared, { recursive: true });
|
|
496
|
+
fs.writeFileSync(path.join(shared, 'active-feature.json'), '{"featureName": "nope"}\n');
|
|
497
|
+
const missing = runHook(gate, root, payload);
|
|
498
|
+
assert.equal(missing.status, 0, missing.stderr);
|
|
499
|
+
const missingBlock = JSON.parse(missing.stdout);
|
|
500
|
+
assert.equal(missingBlock.decision, 'block');
|
|
501
|
+
assert.match(missingBlock.reason, /explicit_not_found/);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('Codex installed spec-state uses one snapshot for done task receipts', (t) => {
|
|
506
|
+
inInstalledProcessSpecStateFixture(({ feature, hooks, root, writeTask }) => {
|
|
507
|
+
const trace = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-snapshot-count-'));
|
|
508
|
+
try {
|
|
509
|
+
const tasks = ['task-01-work.md', 'task-02-work.md'];
|
|
510
|
+
for (const name of tasks) writeTask(name, name, 'done');
|
|
511
|
+
writeTask('task-03-work.md', 'Task 03: work', 'in_progress');
|
|
512
|
+
const scripts = path.join(root, '.codex/scripts');
|
|
513
|
+
const provenance = require(path.join(scripts, 'provenance.cjs'));
|
|
514
|
+
const policy = require(path.join(scripts, 'workflow-policy.cjs'));
|
|
515
|
+
const receipt = require(path.join(scripts, 'spec-receipt.cjs'));
|
|
516
|
+
const context = provenance.deriveRuntimeContext({ projectRoot: root,
|
|
517
|
+
specsRoot: path.join(root, 'specs'), specFile: path.join(feature, 'plan.md'),
|
|
518
|
+
featureName: 'auth', runtimeSession: 'one-snapshot' });
|
|
519
|
+
for (const name of tasks) {
|
|
520
|
+
fs.appendFileSync(path.join(feature, name), [
|
|
521
|
+
'\n## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
|
|
522
|
+
`Base: ${context.base}`, `Head: ${context.head}`, '```text', '1 test passed', '```', '',
|
|
523
|
+
].join('\n'));
|
|
524
|
+
assert.deepEqual(receipt.checkWorkflowTaskReceipt(feature, name, context, policy).failures, []);
|
|
525
|
+
}
|
|
526
|
+
const countFile = path.join(trace, 'git-status.txt');
|
|
527
|
+
const preload = path.join(trace, 'count.cjs');
|
|
528
|
+
fs.writeFileSync(preload, `
|
|
529
|
+
const cp = require('node:child_process');
|
|
530
|
+
const fs = require('node:fs');
|
|
531
|
+
const path = require('node:path');
|
|
532
|
+
for (const method of ['spawnSync', 'execFileSync']) {
|
|
533
|
+
const original = cp[method];
|
|
534
|
+
cp[method] = function(command, args, ...rest) {
|
|
535
|
+
if (path.basename(String(command)) === 'git' && Array.isArray(args) && args.includes('status'))
|
|
536
|
+
fs.appendFileSync(${JSON.stringify(countFile)}, 'status\\n');
|
|
537
|
+
return original.call(this, command, args, ...rest);
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
`);
|
|
541
|
+
const result = spawnSync(process.execPath, [path.join(hooks, 'spec-state.cjs')], {
|
|
542
|
+
cwd: root, encoding: 'utf8',
|
|
543
|
+
env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --require ${JSON.stringify(preload)}` },
|
|
544
|
+
input: JSON.stringify({ cwd: root, featureName: 'auth', hook_event_name: 'UserPromptSubmit',
|
|
545
|
+
session_id: 'one-snapshot', prompt: 'Continue specs/auth' }),
|
|
546
|
+
});
|
|
547
|
+
assert.equal(result.status, 0, result.stderr);
|
|
548
|
+
assert.match(result.stdout, /Spec state changed/);
|
|
549
|
+
const count = fs.readFileSync(countFile, 'utf8').trim().split('\n').length;
|
|
550
|
+
t.diagnostic(`Codex git status calls: ${count}`);
|
|
551
|
+
assert.ok(count > 0 && count <= 2, `git status calls: ${count}; expected at most 2`);
|
|
552
|
+
} finally { fs.rmSync(trace, { recursive: true, force: true }); }
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
248
556
|
test('Codex installed spec-state re-evaluates a standalone task when blocked becomes pending', () => {
|
|
249
557
|
inInstalledProcessSpecStateFixture(({ findCacheFile, hooks, runState, writeTask }) => {
|
|
250
558
|
writeTask('task-01-auth.md', 'Task 01: auth', 'blocked');
|
|
@@ -401,6 +709,503 @@ test('Codex resolver accepts unique legacy task numbers, rejects ambiguity, and
|
|
|
401
709
|
});
|
|
402
710
|
});
|
|
403
711
|
|
|
712
|
+
test('Codex inspect still denies blocked reads when the configured specs path escapes', () => {
|
|
713
|
+
inHookFixture((root, hooks) => {
|
|
714
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
|
|
715
|
+
const result = runHook(path.join(hooks, 'inspect-block.cjs'), root, {
|
|
716
|
+
cwd: root, hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: 'dist/index.html' },
|
|
717
|
+
});
|
|
718
|
+
assert.equal(result.status, 2);
|
|
719
|
+
assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test('Codex rules still inject when the configured specs path escapes', () => {
|
|
724
|
+
inHookFixture((root, hooks) => {
|
|
725
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
|
|
726
|
+
const result = runHook(path.join(hooks, 'rules.cjs'), root, {
|
|
727
|
+
cwd: root, hook_event_name: 'UserPromptSubmit', session_id: 'invalid-specs-rules',
|
|
728
|
+
});
|
|
729
|
+
assert.equal(result.status, 0);
|
|
730
|
+
assert.match(result.stdout, /## Rules/);
|
|
731
|
+
assert.ok(result.stdout.includes(`${fs.realpathSync(root)}/specs/`));
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test('Codex rules wait for runtime before reserving the same session', () => {
|
|
736
|
+
inHookFixture((root, hooks) => {
|
|
737
|
+
const file = path.join(root, '.codex/runtime.json');
|
|
738
|
+
const payload = { cwd: root, hook_event_name: 'UserPromptSubmit', session_id: 'waiting-for-runtime' };
|
|
739
|
+
const hook = path.join(hooks, 'rules.cjs');
|
|
740
|
+
assert.equal(fs.existsSync(file), false);
|
|
741
|
+
const absent = runHook(hook, root, payload);
|
|
742
|
+
assert.equal(absent.status, 0);
|
|
743
|
+
assert.equal(absent.stdout, '');
|
|
744
|
+
fs.writeFileSync(file, JSON.stringify({ paths: { specs: 'specs' } }));
|
|
745
|
+
const present = runHook(hook, root, payload);
|
|
746
|
+
assert.equal(present.status, 0);
|
|
747
|
+
assert.match(present.stdout, /## Rules/);
|
|
748
|
+
assert.equal(runHook(hook, root, payload).stdout, '');
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test('Codex secret guardrail runs from source with its shared keyword shim', () => {
|
|
753
|
+
const env = { ...process.env };
|
|
754
|
+
delete env.CLAUDE_PROJECT_DIR;
|
|
755
|
+
delete env.PROJECT_ROOT;
|
|
756
|
+
const result = spawnSync(process.execPath, [path.join(CODEX_HOOKS, 'secret-output-guardrail.cjs')], {
|
|
757
|
+
cwd: PACKAGE_ROOT, env, encoding: 'utf8', input: JSON.stringify({ prompt: 'Explain secret handling.' }),
|
|
758
|
+
});
|
|
759
|
+
assert.equal(result.status, 0);
|
|
760
|
+
assert.equal(result.stderr, '');
|
|
761
|
+
assert.match(result.stdout, /## Secret handling/);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('Codex secret guardrail fails open without a keyword library or a stack', () => {
|
|
765
|
+
inHookFixture((root, hooks) => {
|
|
766
|
+
fs.unlinkSync(path.join(hooks, 'lib/secret-keywords.cjs'));
|
|
767
|
+
const result = runHook(path.join(hooks, 'secret-output-guardrail.cjs'), root, { prompt: 'Explain secret handling.' });
|
|
768
|
+
assert.equal(result.status, 0);
|
|
769
|
+
assert.equal(result.stdout, '');
|
|
770
|
+
assert.equal(result.stderr, '');
|
|
771
|
+
const rows = fs.readFileSync(path.join(hooks, '.logs/hook-log.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
|
772
|
+
assert.equal(rows.length, 1);
|
|
773
|
+
assert.equal(rows[0].hook, 'secret-output-guardrail');
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
for (const invalidSpecs of [false, true]) {
|
|
778
|
+
test(`Codex compact warns about pending confirmations${invalidSpecs ? ' with an invalid specs path' : ''}`, () => {
|
|
779
|
+
inHookFixture((root, hooks) => {
|
|
780
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: invalidSpecs ? '../outside' : 'specs' } }));
|
|
781
|
+
const result = runHook(path.join(hooks, 'session.cjs'), root, {
|
|
782
|
+
cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session',
|
|
783
|
+
});
|
|
784
|
+
assert.equal(result.status, 0);
|
|
785
|
+
assert.match(result.stdout, /Pending.*(?:confirmation|approval)/i);
|
|
786
|
+
assert.match(result.stdout, /(?:request|ask).*?(?:fresh|again|user)/i);
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
test('Codex compact clears its pending privacy request even with an invalid specs path', () => {
|
|
792
|
+
inHookFixture((root, hooks) => {
|
|
793
|
+
const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
|
|
794
|
+
privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'compact-session', filePaths: ['.env'] });
|
|
795
|
+
const dir = path.join(hooks, '.privacy');
|
|
796
|
+
const pending = () => fs.readdirSync(dir).filter(name => name.startsWith('pending-'));
|
|
797
|
+
assert.equal(pending().length, 1);
|
|
798
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
|
|
799
|
+
const result = runHook(path.join(hooks, 'session.cjs'), root, {
|
|
800
|
+
cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'compact-session',
|
|
801
|
+
});
|
|
802
|
+
assert.equal(result.status, 0);
|
|
803
|
+
assert.equal(pending().length, 0);
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test('Codex compact releases only its own rules reservation', () => {
|
|
808
|
+
inHookFixture((root, hooks) => {
|
|
809
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), '{}');
|
|
810
|
+
const prompt = session_id => runHook(path.join(hooks, 'rules.cjs'), root, { cwd: root, hook_event_name: 'UserPromptSubmit', session_id });
|
|
811
|
+
for (const id of ['session-s', 'session-t']) {
|
|
812
|
+
assert.match(prompt(id).stdout, /## Rules/);
|
|
813
|
+
assert.equal(prompt(id).stdout, '');
|
|
814
|
+
}
|
|
815
|
+
const compact = runHook(path.join(hooks, 'session.cjs'), root, {
|
|
816
|
+
cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id: 'session-s',
|
|
817
|
+
});
|
|
818
|
+
assert.equal(compact.status, 0);
|
|
819
|
+
assert.match(prompt('session-s').stdout, /## Rules/);
|
|
820
|
+
assert.equal(prompt('session-t').stdout, '');
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test('Codex precompact ignores empty stdin without recording a crash', () => {
|
|
825
|
+
inHookFixture((root, hooks) => {
|
|
826
|
+
const result = spawnSync(process.execPath, [path.join(hooks, 'precompact.cjs')], {
|
|
827
|
+
cwd: root, input: '', encoding: 'utf8',
|
|
828
|
+
});
|
|
829
|
+
assert.equal(result.status, 0);
|
|
830
|
+
assert.equal(result.stderr, '');
|
|
831
|
+
const file = path.join(hooks, '.logs/hook-log.jsonl');
|
|
832
|
+
const rows = fs.existsSync(file)
|
|
833
|
+
? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
|
|
834
|
+
: [];
|
|
835
|
+
assert.equal(rows.filter(row => row.hook === 'precompact' && row.status === 'crash').length, 0);
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test('Codex precompact anchors are restored only to the matching session', () => {
|
|
840
|
+
inHookFixture((root, hooks) => {
|
|
841
|
+
const head = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim();
|
|
842
|
+
const status = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
|
|
843
|
+
assert.equal(status.status, 0);
|
|
844
|
+
const dirty = status.stdout.trim().split('\n').filter(Boolean).length;
|
|
845
|
+
const capture = runHook(path.join(hooks, 'precompact.cjs'), root, {
|
|
846
|
+
cwd: root, hook_event_name: 'PreCompact', trigger: 'auto', session_id: 'session-s',
|
|
847
|
+
});
|
|
848
|
+
assert.equal(capture.status, 0, capture.stderr);
|
|
849
|
+
const restart = session_id => runHook(path.join(hooks, 'session.cjs'), root, {
|
|
850
|
+
cwd: root, hook_event_name: 'SessionStart', source: 'compact', session_id,
|
|
851
|
+
});
|
|
852
|
+
const own = restart('session-s');
|
|
853
|
+
assert.equal(own.status, 0);
|
|
854
|
+
assert.match(own.stdout, /State before compaction/);
|
|
855
|
+
assert.ok(own.stdout.includes(`HEAD ${head}`));
|
|
856
|
+
assert.ok(own.stdout.includes(`${dirty} uncommitted path(s)`));
|
|
857
|
+
const other = restart('session-t');
|
|
858
|
+
assert.equal(other.status, 0);
|
|
859
|
+
assert.match(other.stdout, /Pending/i);
|
|
860
|
+
assert.doesNotMatch(other.stdout, /State before compaction/);
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
function sessionWithoutUpdateNetwork(root, hooks, session_id) {
|
|
865
|
+
const marker = path.join(root, 'update-network-attempted');
|
|
866
|
+
const preload = path.join(root, 'block-update-network.cjs');
|
|
867
|
+
fs.writeFileSync(preload, `require('https').get = () => {
|
|
868
|
+
require('fs').writeFileSync(${JSON.stringify(marker)}, 'attempted');
|
|
869
|
+
throw new Error('network disabled in update test');
|
|
870
|
+
};\n`);
|
|
871
|
+
const result = spawnSync(process.execPath, ['--require', preload, path.join(hooks, 'session.cjs')], {
|
|
872
|
+
cwd: root, encoding: 'utf8', input: JSON.stringify({ cwd: root, hook_event_name: 'SessionStart', source: 'startup', session_id }),
|
|
873
|
+
});
|
|
874
|
+
assert.equal(fs.existsSync(marker), false, 'the cached update check must not attempt a network request');
|
|
875
|
+
return result;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
test('Codex SessionStart prints a newer cached CafeKit version', () => {
|
|
879
|
+
inHookFixture((root, hooks) => {
|
|
880
|
+
fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
|
|
881
|
+
fs.writeFileSync(path.join(root, '.codex/.cafekit-update-cache.json'), JSON.stringify({ ts: Date.now(), latest: '1.3.0' }));
|
|
882
|
+
const result = sessionWithoutUpdateNetwork(root, hooks, 'cached-update');
|
|
883
|
+
assert.equal(result.status, 0);
|
|
884
|
+
assert.ok(result.stdout.includes('\n⚡ CafeKit update available: 1.2.3 → 1.3.0\n Run: npx @haposoft/cafekit\n'));
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test('Codex SessionStart stays silent about an equal cached CafeKit version', () => {
|
|
889
|
+
inHookFixture((root, hooks) => {
|
|
890
|
+
fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
|
|
891
|
+
fs.writeFileSync(path.join(root, '.codex/.cafekit-update-cache.json'), JSON.stringify({ ts: Date.now(), latest: '1.2.3' }));
|
|
892
|
+
const result = sessionWithoutUpdateNetwork(root, hooks, 'equal-update');
|
|
893
|
+
assert.equal(result.status, 0);
|
|
894
|
+
assert.doesNotMatch(result.stdout, /CafeKit update available/);
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test('Codex update failure still clears pending privacy for the session', () => {
|
|
899
|
+
inHookFixture((root, hooks) => {
|
|
900
|
+
const privacy = require(path.join(hooks, 'lib/privacy-state.cjs'));
|
|
901
|
+
privacy.createPending({ projectRoot: root, sessionCwd: root, sessionId: 'failed-update', filePaths: ['.env'] });
|
|
902
|
+
fs.writeFileSync(path.join(root, '.codex/cafekit.json'), JSON.stringify({ version: '1.2.3' }));
|
|
903
|
+
fs.writeFileSync(path.join(hooks, 'lib/update-check.cjs'), "module.exports = { checkCafeKitUpdate() { throw new Error('synthetic update failure'); } };\n");
|
|
904
|
+
const result = sessionWithoutUpdateNetwork(root, hooks, 'failed-update');
|
|
905
|
+
assert.equal(result.status, 0);
|
|
906
|
+
assert.equal(result.stderr, '');
|
|
907
|
+
const pending = fs.readdirSync(path.join(hooks, '.privacy')).filter(name => name.startsWith('pending-'));
|
|
908
|
+
assert.equal(pending.length, 0);
|
|
909
|
+
});
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
test('Codex Stop describes an escaping specs root as an invalid workflow packet', () => {
|
|
913
|
+
inHookFixture((root, hooks) => {
|
|
914
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs: '../outside' } }));
|
|
915
|
+
const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
|
|
916
|
+
cwd: root, session_id: 'invalid-root-message', hook_event_name: 'Stop', stop_hook_active: false,
|
|
917
|
+
});
|
|
918
|
+
assert.equal(result.status, 0);
|
|
919
|
+
const output = JSON.parse(result.stdout);
|
|
920
|
+
assert.equal(output.decision, 'block');
|
|
921
|
+
assert.match(output.reason, /^Completion gate: invalid workflow packet detected \(<specs>\):/);
|
|
922
|
+
assert.doesNotMatch(output.reason, /controlled failure/);
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test('Codex spec-state shows detailed task status counts on its Phase line', () => {
|
|
927
|
+
inInstalledProcessSpecStateFixture(({ writeTask, runState }) => {
|
|
928
|
+
for (const [name, status] of [['task-01-active.md', 'in_progress'], ['task-02-next.md', 'pending'],
|
|
929
|
+
['task-03-blocked.md', 'blocked'], ['task-04-later.md', 'pending']]) {
|
|
930
|
+
writeTask(name, name, status);
|
|
931
|
+
}
|
|
932
|
+
const result = runState();
|
|
933
|
+
assert.equal(result.status, 0, result.stderr);
|
|
934
|
+
assert.match(result.stdout, /^- Phase: .* \| Tasks: 0 done \/ 4 total \(1 in_progress, 2 pending, 1 blocked\)$/m);
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
function shortPrivacyFixture(check) {
|
|
939
|
+
return inHookFixture((root, hooks) => {
|
|
940
|
+
fs.writeFileSync(path.join(root, '.env'), 'fixture\n');
|
|
941
|
+
fs.writeFileSync(path.join(root, 'credentials.json'), '{}\n');
|
|
942
|
+
const dir = path.join(hooks, '.privacy');
|
|
943
|
+
const session = 'short-approval-session';
|
|
944
|
+
const payload = (file = '.env', session_id = session) => ({ cwd: root, session_id,
|
|
945
|
+
hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: `cat ${file}` } });
|
|
946
|
+
const block = (file, id) => runHook(path.join(hooks, 'privacy-block.cjs'), root, payload(file, id));
|
|
947
|
+
const submit = (prompt, session_id = session, env = process.env) => spawnSync(process.execPath,
|
|
948
|
+
[path.join(hooks, 'privacy-approval.cjs')], { cwd: root, encoding: 'utf8', env,
|
|
949
|
+
input: JSON.stringify({ cwd: root, session_id, hook_event_name: 'UserPromptSubmit', prompt }) });
|
|
950
|
+
const files = prefix => fs.existsSync(dir) ? fs.readdirSync(dir).filter(name => name.startsWith(prefix)) : [];
|
|
951
|
+
const id = result => {
|
|
952
|
+
assert.equal(result.status, 0, result.stderr);
|
|
953
|
+
const reason = JSON.parse(result.stdout).hookSpecificOutput.permissionDecisionReason;
|
|
954
|
+
const match = reason.match(/APPROVE CAFEKIT PRIVACY ([a-f0-9]{24})/);
|
|
955
|
+
assert.ok(match, reason);
|
|
956
|
+
return match[1];
|
|
957
|
+
};
|
|
958
|
+
return check({ root, hooks, dir, session, payload, block, submit, files, id });
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function asyncPrivacyBlock(fixture, file) {
|
|
963
|
+
return new Promise((resolve, reject) => {
|
|
964
|
+
const child = spawn(process.execPath, [path.join(fixture.hooks, 'privacy-block.cjs')], { cwd: fixture.root });
|
|
965
|
+
let stdout = '', stderr = '';
|
|
966
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
967
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
968
|
+
child.on('error', reject);
|
|
969
|
+
child.on('close', status => resolve({ status, stdout, stderr }));
|
|
970
|
+
child.stdin.end(JSON.stringify(fixture.payload(file)));
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
for (const [label, prompt] of [['y', 'y'], ['yes', 'yes'], ['title-case Vietnamese', 'Có'],
|
|
975
|
+
['padded y', ' y '], ['decomposed Vietnamese', 'co\u0301'], ['uppercase Vietnamese', 'CÓ'], ['terminal newline', 'Y\r\n']]) {
|
|
976
|
+
test(`Codex short privacy approval accepts ${label} and reports granted paths`, () => shortPrivacyFixture(f => {
|
|
977
|
+
const denied = f.block();
|
|
978
|
+
const requestId = f.id(denied);
|
|
979
|
+
const sessionHash = crypto.createHash('sha256').update(f.session).digest('hex');
|
|
980
|
+
assert.deepEqual(f.files('short-'), [`short-${sessionHash}-${requestId}.json`]);
|
|
981
|
+
assert.match(denied.stdout, /y \(or yes \/ có\)/);
|
|
982
|
+
const result = f.submit(prompt);
|
|
983
|
+
assert.equal(result.status, 0, result.stderr);
|
|
984
|
+
assert.match(result.stdout, /User approved/);
|
|
985
|
+
assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
|
|
986
|
+
assert.equal(f.files('token-').length, 1);
|
|
987
|
+
assert.equal(f.files('short-').length, 0);
|
|
988
|
+
assert.equal(f.block().stdout, '');
|
|
989
|
+
assert.equal(f.block().stdout, '', 'the one-hour grant remains reusable');
|
|
990
|
+
}));
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
test('Codex short privacy approval is cancelled by an intervening prompt', () => shortPrivacyFixture(f => {
|
|
994
|
+
f.id(f.block());
|
|
995
|
+
assert.equal(f.submit('ok làm đi').status, 0);
|
|
996
|
+
const result = f.submit('y');
|
|
997
|
+
assert.equal(result.status, 0, result.stderr);
|
|
998
|
+
assert.doesNotMatch(result.stdout, /User approved/);
|
|
999
|
+
assert.equal(f.files('token-').length, 0);
|
|
1000
|
+
assert.equal(f.files('short-').length, 0);
|
|
1001
|
+
assert.match(f.block().stdout, /"permissionDecision":"deny"/);
|
|
1002
|
+
}));
|
|
1003
|
+
|
|
1004
|
+
test('Codex short privacy approval stays in its session and requires pending state', () => shortPrivacyFixture(f => {
|
|
1005
|
+
assert.equal(f.submit('y').status, 0);
|
|
1006
|
+
assert.equal(f.files('token-').length, 0);
|
|
1007
|
+
f.id(f.block());
|
|
1008
|
+
assert.doesNotMatch(f.submit('y', 'another-session').stdout, /User approved/);
|
|
1009
|
+
assert.equal(f.files('token-').length, 0);
|
|
1010
|
+
assert.match(f.submit('y').stdout, /User approved/);
|
|
1011
|
+
}));
|
|
1012
|
+
|
|
1013
|
+
for (const [label, prompt] of [['extra Vietnamese text', 'y nhé'], ['extra English text', 'yes please'],
|
|
1014
|
+
['repeated letter', 'yy'], ['fullwidth letter', 'y'], ['zero-width suffix', 'y\u200b']]) {
|
|
1015
|
+
test(`Codex short privacy approval rejects ${label}`, () => shortPrivacyFixture(f => {
|
|
1016
|
+
f.id(f.block());
|
|
1017
|
+
const result = f.submit(prompt);
|
|
1018
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1019
|
+
assert.doesNotMatch(result.stdout, /User approved/);
|
|
1020
|
+
assert.equal(f.files('token-').length, 0);
|
|
1021
|
+
assert.equal(f.files('short-').length, 0);
|
|
1022
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1023
|
+
}));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
test('Codex short privacy approval refuses two requests while the long phrase selects one', () => shortPrivacyFixture(f => {
|
|
1027
|
+
const first = f.id(f.block());
|
|
1028
|
+
f.id(f.block('credentials.json'));
|
|
1029
|
+
const result = f.submit('y');
|
|
1030
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1031
|
+
assert.doesNotMatch(result.stdout, /User approved/);
|
|
1032
|
+
assert.equal(f.files('token-').length, 0);
|
|
1033
|
+
assert.equal(f.files('short-').length, 0);
|
|
1034
|
+
const exact = f.submit(`APPROVE CAFEKIT PRIVACY ${first}`);
|
|
1035
|
+
assert.match(exact.stdout, /User approved/);
|
|
1036
|
+
assert.match(JSON.parse(exact.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
|
|
1037
|
+
assert.equal(f.block().stdout, '');
|
|
1038
|
+
assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
|
|
1039
|
+
}));
|
|
1040
|
+
|
|
1041
|
+
test('parallel Codex privacy blocks preserve both markers and cannot be approved by y', () => shortPrivacyFixture(async f => {
|
|
1042
|
+
const results = await Promise.all([asyncPrivacyBlock(f, '.env'), asyncPrivacyBlock(f, 'credentials.json')]);
|
|
1043
|
+
assert.notEqual(f.id(results[0]), f.id(results[1]));
|
|
1044
|
+
assert.equal(f.files('short-').length, 2);
|
|
1045
|
+
assert.equal(f.submit('y').status, 0);
|
|
1046
|
+
assert.equal(f.files('token-').length, 0);
|
|
1047
|
+
assert.equal(f.files('short-').length, 0);
|
|
1048
|
+
assert.match(f.block().stdout, /"permissionDecision":"deny"/);
|
|
1049
|
+
assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
|
|
1050
|
+
}));
|
|
1051
|
+
|
|
1052
|
+
test('repeated Codex blocks for the same path set reuse the request and short marker', () => shortPrivacyFixture(f => {
|
|
1053
|
+
const first = f.id(f.block('.env credentials.json'));
|
|
1054
|
+
assert.equal(f.id(f.block('credentials.json .env')), first);
|
|
1055
|
+
assert.equal(f.files('pending-').length, 1);
|
|
1056
|
+
assert.equal(f.files('short-').length, 1);
|
|
1057
|
+
assert.match(f.submit('y').stdout, /User approved/);
|
|
1058
|
+
assert.equal(f.block('.env credentials.json').stdout, '');
|
|
1059
|
+
assert.match(f.block().stdout, /"permissionDecision":"deny"/);
|
|
1060
|
+
}));
|
|
1061
|
+
|
|
1062
|
+
test('a later Codex block restores the short marker for the same pending request', () => shortPrivacyFixture(f => {
|
|
1063
|
+
const requestId = f.id(f.block());
|
|
1064
|
+
assert.equal(f.submit('ok làm đi').status, 0);
|
|
1065
|
+
assert.equal(f.files('short-').length, 0);
|
|
1066
|
+
assert.equal(f.id(f.block()), requestId);
|
|
1067
|
+
assert.equal(f.files('pending-').length, 1);
|
|
1068
|
+
assert.equal(f.files('short-').length, 1);
|
|
1069
|
+
assert.match(f.submit('y').stdout, /User approved/);
|
|
1070
|
+
assert.equal(f.block().stdout, '');
|
|
1071
|
+
assert.match(f.block('credentials.json').stdout, /"permissionDecision":"deny"/);
|
|
1072
|
+
}));
|
|
1073
|
+
|
|
1074
|
+
test('an immediate repeated Codex block keeps one short marker without an exclusive-write error', () => shortPrivacyFixture(f => {
|
|
1075
|
+
const requestId = f.id(f.block());
|
|
1076
|
+
assert.equal(f.id(f.block()), requestId);
|
|
1077
|
+
assert.equal(f.files('pending-').length, 1);
|
|
1078
|
+
assert.equal(f.files('short-').length, 1);
|
|
1079
|
+
assert.match(f.submit('y').stdout, /User approved/);
|
|
1080
|
+
assert.equal(f.block().stdout, '');
|
|
1081
|
+
}));
|
|
1082
|
+
|
|
1083
|
+
test('a Codex prompt error after claiming markers cancels the short approval', () => shortPrivacyFixture(f => {
|
|
1084
|
+
f.id(f.block());
|
|
1085
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-fault-'));
|
|
1086
|
+
try {
|
|
1087
|
+
const preload = path.join(outside, 'fault.cjs');
|
|
1088
|
+
fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.readdirSync; let calls = 0;\nfs.readdirSync = function(dir, ...args) { if (String(dir) === ${JSON.stringify(fs.realpathSync(f.dir))} && ++calls === 2) throw new Error('injected state read error'); return original.call(this, dir, ...args); };\n`);
|
|
1089
|
+
const failed = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
|
|
1090
|
+
assert.equal(failed.status, 0, failed.stderr);
|
|
1091
|
+
assert.match(failed.stdout, /failed safely/);
|
|
1092
|
+
assert.equal(f.files('short-').length, 0);
|
|
1093
|
+
assert.equal(f.files('token-').length, 0);
|
|
1094
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1095
|
+
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
|
|
1096
|
+
}));
|
|
1097
|
+
|
|
1098
|
+
test('a Codex post-claim error cancels a marker created during prompt processing', () => shortPrivacyFixture(f => {
|
|
1099
|
+
f.id(f.block());
|
|
1100
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-post-claim-fault-'));
|
|
1101
|
+
try {
|
|
1102
|
+
const preload = path.join(outside, 'fault.cjs');
|
|
1103
|
+
fs.writeFileSync(preload, `const assert = require('node:assert/strict');
|
|
1104
|
+
const state = require(${JSON.stringify(path.join(f.hooks, 'lib/privacy-state.cjs'))});
|
|
1105
|
+
const claim = state.claimShortMarkers;
|
|
1106
|
+
let claimed = false;
|
|
1107
|
+
state.claimShortMarkers = (...args) => {
|
|
1108
|
+
const result = claim(...args);
|
|
1109
|
+
if (!claimed) { assert.equal(result.ok, true); assert.equal(result.requestIds.length, 1); claimed = true; }
|
|
1110
|
+
return result;
|
|
1111
|
+
};
|
|
1112
|
+
state.cleanup = () => {
|
|
1113
|
+
assert.equal(claimed, true);
|
|
1114
|
+
state.createPending(${JSON.stringify({ projectRoot: f.root, sessionCwd: f.root, sessionId: f.session, filePath: 'credentials.json' })});
|
|
1115
|
+
throw new Error('injected processing error after marker claim');
|
|
1116
|
+
};\n`);
|
|
1117
|
+
const failed = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
|
|
1118
|
+
assert.equal(failed.status, 0, failed.stderr);
|
|
1119
|
+
assert.match(failed.stdout, /failed safely/);
|
|
1120
|
+
assert.equal(f.files('pending-').length, 2);
|
|
1121
|
+
assert.equal(f.files('short-').length, 0);
|
|
1122
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1123
|
+
assert.equal(f.files('token-').length, 0);
|
|
1124
|
+
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
|
|
1125
|
+
}));
|
|
1126
|
+
|
|
1127
|
+
test('a corrupt Codex short marker cannot grant permission even with valid pending state', () => shortPrivacyFixture(f => {
|
|
1128
|
+
f.id(f.block());
|
|
1129
|
+
const markers = f.files('short-');
|
|
1130
|
+
assert.equal(markers.length, 1);
|
|
1131
|
+
fs.writeFileSync(path.join(f.dir, markers[0]), '{');
|
|
1132
|
+
const result = f.submit('y');
|
|
1133
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1134
|
+
assert.doesNotMatch(result.stdout, /User approved/);
|
|
1135
|
+
assert.equal(f.files('token-').length, 0);
|
|
1136
|
+
assert.equal(f.files('short-').length, 0);
|
|
1137
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1138
|
+
}));
|
|
1139
|
+
|
|
1140
|
+
for (const unlinkFailure of [false, true]) {
|
|
1141
|
+
test(`an unclaimable Codex short marker is cancelled after ${unlinkFailure ? 'rename and unlink errors' : 'a rename error'}`, () => shortPrivacyFixture(f => {
|
|
1142
|
+
f.id(f.block());
|
|
1143
|
+
assert.equal(f.files('short-').length, 1);
|
|
1144
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-privacy-rename-fault-'));
|
|
1145
|
+
try {
|
|
1146
|
+
const preload = path.join(outside, 'fault.cjs');
|
|
1147
|
+
const prefix = JSON.stringify(path.join(fs.realpathSync(f.dir), 'short-'));
|
|
1148
|
+
fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.renameSync;\nfs.renameSync = function(from, to) { if (String(from).startsWith(${prefix})) throw new Error('injected rename failure'); return original.call(this, from, to); };\n`
|
|
1149
|
+
+ (unlinkFailure ? `const unlink = fs.unlinkSync; fs.unlinkSync = function(file) { if (String(file).startsWith(${prefix})) throw new Error('injected unlink failure'); return unlink.call(this, file); };\n` : ''));
|
|
1150
|
+
const result = f.submit('ok làm đi', f.session, { ...process.env, NODE_OPTIONS: `--require=${preload}` });
|
|
1151
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1152
|
+
assert.doesNotMatch(result.stdout, /User approved/);
|
|
1153
|
+
assert.equal(f.files('token-').length, 0);
|
|
1154
|
+
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
|
|
1155
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1156
|
+
assert.equal(f.files('token-').length, 0);
|
|
1157
|
+
assert.equal(f.files('short-').length, 0);
|
|
1158
|
+
}));
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
test('Codex compact clears pending requests and short markers only for its session', () => shortPrivacyFixture(f => {
|
|
1162
|
+
f.id(f.block());
|
|
1163
|
+
f.id(f.block('credentials.json', 'other-session'));
|
|
1164
|
+
const result = runHook(path.join(f.hooks, 'session.cjs'), f.root,
|
|
1165
|
+
{ source: 'compact', session_id: f.session, cwd: f.root, hook_event_name: 'SessionStart' });
|
|
1166
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1167
|
+
const prefix = `short-${crypto.createHash('sha256').update(f.session).digest('hex')}-`;
|
|
1168
|
+
assert.equal(f.files(prefix).length, 0);
|
|
1169
|
+
assert.equal(f.files('short-').length, 1);
|
|
1170
|
+
assert.doesNotMatch(f.submit('y').stdout, /User approved/);
|
|
1171
|
+
assert.equal(f.files('token-').length, 0);
|
|
1172
|
+
assert.match(f.submit('y', 'other-session').stdout, /User approved/);
|
|
1173
|
+
}));
|
|
1174
|
+
|
|
1175
|
+
test('Codex short approval cannot grant an expired pending request', () => shortPrivacyFixture(f => {
|
|
1176
|
+
const id = f.id(f.block());
|
|
1177
|
+
const file = path.join(f.dir, `pending-${id}.json`);
|
|
1178
|
+
const pending = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1179
|
+
pending.expiresAt = Date.now() - 1;
|
|
1180
|
+
fs.writeFileSync(file, JSON.stringify(pending));
|
|
1181
|
+
assert.equal(f.submit('y').status, 0);
|
|
1182
|
+
assert.equal(f.files('token-').length, 0);
|
|
1183
|
+
assert.equal(f.files('short-').length, 0);
|
|
1184
|
+
}));
|
|
1185
|
+
|
|
1186
|
+
test('Codex cleanup removes expired short markers', () => shortPrivacyFixture(f => {
|
|
1187
|
+
f.id(f.block());
|
|
1188
|
+
const markers = f.files('short-');
|
|
1189
|
+
assert.equal(markers.length, 1);
|
|
1190
|
+
const file = path.join(f.dir, markers[0]);
|
|
1191
|
+
const marker = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1192
|
+
marker.expiresAt = Date.now() - 1;
|
|
1193
|
+
fs.writeFileSync(file, JSON.stringify(marker));
|
|
1194
|
+
require(path.join(f.hooks, 'lib/privacy-state.cjs')).cleanup(f.root);
|
|
1195
|
+
assert.equal(f.files('short-').length, 0);
|
|
1196
|
+
}));
|
|
1197
|
+
|
|
1198
|
+
test('Codex long privacy approval still works after an intervening prompt and reports paths', () => shortPrivacyFixture(f => {
|
|
1199
|
+
const id = f.id(f.block());
|
|
1200
|
+
assert.equal(f.submit('ok làm đi').status, 0);
|
|
1201
|
+
assert.equal(f.files('short-').length, 0);
|
|
1202
|
+
const result = f.submit(`APPROVE CAFEKIT PRIVACY ${id}`);
|
|
1203
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1204
|
+
assert.match(result.stdout, /User approved/);
|
|
1205
|
+
assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /Tell the user.*\.env.*before reading/i);
|
|
1206
|
+
assert.equal(f.block().stdout, '');
|
|
1207
|
+
}));
|
|
1208
|
+
|
|
404
1209
|
test('Codex privacy approval is exact, session-bound, and reusable within its window', () => {
|
|
405
1210
|
inHookFixture((root, hooks) => {
|
|
406
1211
|
const nested = path.join(root, 'packages', 'app');
|
|
@@ -592,15 +1397,17 @@ test('Codex inspect hook scans native shell commands and structured paths', () =
|
|
|
592
1397
|
for (const command of [
|
|
593
1398
|
'rg secret node_modules',
|
|
594
1399
|
"sed -n '1,20p' dist/bundle.js",
|
|
595
|
-
"rg --glob '**/*' symbol
|
|
1400
|
+
"rg --glob '**/*' symbol ."
|
|
596
1401
|
]) {
|
|
597
1402
|
const result = runHook(inspect, root, payload(command));
|
|
598
1403
|
assert.equal(result.status, 2, command);
|
|
599
1404
|
assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
|
|
600
1405
|
}
|
|
601
1406
|
|
|
602
|
-
const
|
|
603
|
-
|
|
1407
|
+
for (const command of ['rg symbol src packages', "rg --glob '**/*' symbol src"]) {
|
|
1408
|
+
const normal = runHook(inspect, root, payload(command));
|
|
1409
|
+
assert.equal(normal.status, 0);
|
|
1410
|
+
}
|
|
604
1411
|
|
|
605
1412
|
const structured = runHook(inspect, root, {
|
|
606
1413
|
...payload(''),
|
|
@@ -615,18 +1422,8 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
|
|
|
615
1422
|
inHookFixture((root, hooks) => {
|
|
616
1423
|
const nested = path.join(root, 'packages', 'app');
|
|
617
1424
|
const feature = path.join(root, 'specs', 'auth');
|
|
618
|
-
const taskPath = 'tasks/task-R0-01-auth.md';
|
|
619
|
-
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
620
1425
|
fs.mkdirSync(nested, { recursive: true });
|
|
621
|
-
|
|
622
|
-
status: 'in_progress',
|
|
623
|
-
feature_name: 'auth',
|
|
624
|
-
task_registry: {
|
|
625
|
-
[taskPath]: { status: 'pending', completed_at: null }
|
|
626
|
-
}
|
|
627
|
-
};
|
|
628
|
-
fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
|
|
629
|
-
fs.writeFileSync(path.join(feature, taskPath), 'Status: pending\n');
|
|
1426
|
+
writeProcessTask(feature, 'pending', null, 'node --test');
|
|
630
1427
|
const gate = path.join(hooks, 'spec-gate.cjs');
|
|
631
1428
|
const payload = {
|
|
632
1429
|
cwd: nested,
|
|
@@ -640,32 +1437,19 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
|
|
|
640
1437
|
assert.equal(seeded.status, 0);
|
|
641
1438
|
assert.equal(seeded.stdout, '');
|
|
642
1439
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
'Status: done', '', '## Evidence', '', 'Verification: PASS',
|
|
657
|
-
'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
658
|
-
].join('\n'), 'auth'));
|
|
659
|
-
const validBeforeCloseout = runHook(gate, nested, payload);
|
|
660
|
-
assert.equal(validBeforeCloseout.status, 0);
|
|
661
|
-
assert.equal(validBeforeCloseout.stdout, '');
|
|
662
|
-
assert.equal(fs.existsSync(path.join(feature, 'feature-receipt.md')), false);
|
|
663
|
-
|
|
664
|
-
spec.current_phase = 'closeout';
|
|
665
|
-
fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
|
|
666
|
-
const closeout = runHook(gate, nested, payload);
|
|
667
|
-
assert.equal(closeout.status, 0);
|
|
668
|
-
assert.equal(closeout.stdout, '');
|
|
1440
|
+
writeProcessTask(feature, 'done', null, 'node --test');
|
|
1441
|
+
const blocked = runHook(gate, nested, payload);
|
|
1442
|
+
assert.equal(blocked.status, 0);
|
|
1443
|
+
assert.equal(JSON.parse(blocked.stdout).decision, 'block');
|
|
1444
|
+
assert.match(JSON.parse(blocked.stdout).reason, /verification receipt/);
|
|
1445
|
+
|
|
1446
|
+
writeProcessTask(feature, 'done', bindReceipt(root, [
|
|
1447
|
+
'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
1448
|
+
'```', 'node --test', 'ok 1', '```',
|
|
1449
|
+
].join('\n')).split('\n'), 'node --test');
|
|
1450
|
+
const valid = runHook(gate, nested, payload);
|
|
1451
|
+
assert.equal(valid.status, 0);
|
|
1452
|
+
assert.equal(valid.stdout, '');
|
|
669
1453
|
});
|
|
670
1454
|
});
|
|
671
1455
|
|
|
@@ -765,20 +1549,15 @@ test('Codex process-v3 Receipt command must match the exact Verification Plan co
|
|
|
765
1549
|
});
|
|
766
1550
|
|
|
767
1551
|
test('Codex recorded active feature turns the identity block into receipt validation', () => {
|
|
768
|
-
// Issue #79 end to end on this runtime. Two
|
|
769
|
-
//
|
|
770
|
-
//
|
|
1552
|
+
// Issue #79 end to end on this runtime. Two unfinished packets make the gate answer a
|
|
1553
|
+
// missing-receipt violation with an identity complaint; naming the feature resolves it
|
|
1554
|
+
// and the violation surfaces.
|
|
771
1555
|
inHookFixture((root, hooks) => {
|
|
772
1556
|
for (const name of ['alpha', 'beta']) {
|
|
773
1557
|
const feature = path.join(root, 'specs', name);
|
|
774
|
-
|
|
775
|
-
fs.writeFileSync(path.join(feature, '
|
|
776
|
-
|
|
777
|
-
status: 'done',
|
|
778
|
-
current_phase: 'closeout',
|
|
779
|
-
task_registry: { 'tasks/task-R0-01-x.md': { status: 'done', completed_at: '2026-01-01T00:00:00.000Z' } },
|
|
780
|
-
}, null, 2)}\n`);
|
|
781
|
-
fs.writeFileSync(path.join(feature, 'tasks', 'task-R0-01-x.md'), '# Task\n\nStatus: done\n');
|
|
1558
|
+
writeProcessTask(feature, 'done', null, 'node --test', 'task-01-x.md');
|
|
1559
|
+
fs.writeFileSync(path.join(feature, 'task-02-y.md'),
|
|
1560
|
+
'# Task 02\n\nStatus: pending\n\n## Dependencies\n\n- none\n\n## Verification Plan\n\n- Command: node --test\n');
|
|
782
1561
|
}
|
|
783
1562
|
const payload = { cwd: root, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
784
1563
|
|
|
@@ -880,16 +1659,7 @@ test('empty or forged Codex hook authority fails closed', () => {
|
|
|
880
1659
|
assert.match(block.reason, /hook payload (is empty|must be a JSON object)/i);
|
|
881
1660
|
}
|
|
882
1661
|
|
|
883
|
-
|
|
884
|
-
const taskPath = 'tasks/task.md';
|
|
885
|
-
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
886
|
-
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
887
|
-
status: 'in_progress',
|
|
888
|
-
current_phase: 'closeout',
|
|
889
|
-
feature_name: 'auth',
|
|
890
|
-
task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
|
|
891
|
-
}));
|
|
892
|
-
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
|
|
1662
|
+
writeProcessTask(path.join(root, 'specs', 'auth'), 'done');
|
|
893
1663
|
fs.writeFileSync(path.join(root, '.codex', 'runtime.json'), JSON.stringify({
|
|
894
1664
|
spec: { completion_gate: false },
|
|
895
1665
|
}));
|
|
@@ -911,37 +1681,19 @@ test('empty or forged Codex hook authority fails closed', () => {
|
|
|
911
1681
|
});
|
|
912
1682
|
});
|
|
913
1683
|
|
|
914
|
-
test('
|
|
1684
|
+
test('Codex Stop omits secret-related assistant messages from saved state', () => {
|
|
915
1685
|
inHookFixture((root, hooks) => {
|
|
916
|
-
const
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
920
|
-
status: 'in_progress',
|
|
921
|
-
current_phase: 'closeout',
|
|
922
|
-
feature_name: 'auth',
|
|
923
|
-
workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' }),
|
|
924
|
-
proofs: {
|
|
925
|
-
needsInspection: 'inspection completed',
|
|
926
|
-
needsIndependentAudit: 'PASS',
|
|
927
|
-
needsResearchGrounding: 'research completed',
|
|
928
|
-
},
|
|
929
|
-
task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
|
|
930
|
-
}));
|
|
931
|
-
fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
|
|
932
|
-
'Status: done', '', '## Evidence', '', 'Verification: PASS',
|
|
933
|
-
'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
934
|
-
].join('\n'), 'auth'));
|
|
935
|
-
const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
|
|
936
|
-
cwd: root,
|
|
937
|
-
session_id: 'session-a',
|
|
938
|
-
hook_event_name: 'Stop',
|
|
939
|
-
stop_hook_active: false,
|
|
1686
|
+
const message = 'API key handling was discussed in this synthetic test.';
|
|
1687
|
+
const result = runHook(path.join(hooks, 'state.cjs'), root, {
|
|
1688
|
+
hook_event_name: 'Stop', session_id: 'secret-summary', last_assistant_message: message,
|
|
940
1689
|
});
|
|
941
|
-
assert.equal(result.status, 0
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
1690
|
+
assert.equal(result.status, 0);
|
|
1691
|
+
const dir = codexStateDir(root, 'secret-summary');
|
|
1692
|
+
for (const name of ['data.json', 'latest.md']) {
|
|
1693
|
+
const content = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
1694
|
+
assert.ok(content.includes('[omitted: possible secret]'));
|
|
1695
|
+
assert.ok(!content.includes(message));
|
|
1696
|
+
}
|
|
945
1697
|
});
|
|
946
1698
|
});
|
|
947
1699
|
|
|
@@ -1053,61 +1805,55 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
|
1053
1805
|
// Integration via spec-gate hook: only Base blocks, both passes
|
|
1054
1806
|
inHookFixture((root, hooks) => {
|
|
1055
1807
|
const feature = path.join(root, 'specs', 'auth');
|
|
1056
|
-
const taskPath = 'tasks/task-R0-01-auth.md';
|
|
1057
|
-
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
1058
1808
|
const gate = path.join(hooks, 'spec-gate.cjs');
|
|
1059
1809
|
const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
1810
|
+
const output = ['```', 'pnpm test', 'ok 1', '```'];
|
|
1811
|
+
fs.mkdirSync(payload.cwd, { recursive: true });
|
|
1060
1812
|
|
|
1061
1813
|
// only Base -> block
|
|
1062
|
-
|
|
1063
|
-
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
|
|
1064
|
-
fs.mkdirSync(payload.cwd, { recursive: true });
|
|
1814
|
+
writeProcessTask(feature, 'done', ['Verification: PASS', 'Command: pnpm test', 'Exit: 0', 'Base: abc', ...output]);
|
|
1065
1815
|
const blocked = runHook(gate, payload.cwd, payload);
|
|
1066
1816
|
assert.equal(JSON.parse(blocked.stdout).decision, 'block');
|
|
1067
1817
|
assert.match(JSON.parse(blocked.stdout).reason, /\bprovenance\b/);
|
|
1068
1818
|
|
|
1069
1819
|
// both -> no block
|
|
1070
|
-
|
|
1820
|
+
writeProcessTask(feature, 'done', bindReceipt(root, ['Verification: PASS', 'Command: pnpm test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, ...output].join('\n')).split('\n'));
|
|
1071
1821
|
const passed = runHook(gate, payload.cwd, payload);
|
|
1072
1822
|
assert.equal(passed.stdout, '');
|
|
1073
1823
|
|
|
1074
1824
|
// empty Base: should block provenance — reset gate cache so re-checked as newly-done
|
|
1075
1825
|
try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
|
|
1076
|
-
|
|
1826
|
+
writeProcessTask(feature, 'done', ['Verification: PASS', 'Command: pnpm test', 'Exit: 0', 'Base:', 'Head: def', ...output]);
|
|
1077
1827
|
const blockedEmpty = runHook(gate, payload.cwd, payload);
|
|
1078
1828
|
assert.equal(JSON.parse(blockedEmpty.stdout).decision, 'block');
|
|
1079
1829
|
assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bprovenance\b/);
|
|
1080
1830
|
|
|
1081
1831
|
// bare base_sha without colon should block — reset cache again
|
|
1082
1832
|
try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
|
|
1083
|
-
|
|
1833
|
+
writeProcessTask(feature, 'done', ['Verification: PASS', 'Command: pnpm test', 'Exit: 0', 'base_sha head_sha', ...output]);
|
|
1084
1834
|
const blockedBare = runHook(gate, payload.cwd, payload);
|
|
1085
1835
|
assert.equal(JSON.parse(blockedBare.stdout).decision, 'block');
|
|
1086
1836
|
assert.match(JSON.parse(blockedBare.stdout).reason, /\bprovenance\b/);
|
|
1087
1837
|
});
|
|
1088
1838
|
});
|
|
1089
1839
|
|
|
1090
|
-
test('Codex cache hardening: every done re-validated — mutation,
|
|
1840
|
+
test('Codex cache hardening: every done re-validated — mutation, malformed cache, unchanged valid', () => {
|
|
1091
1841
|
inHookFixture((root, hooks) => {
|
|
1092
1842
|
const feature = path.join(root, 'specs', 'auth');
|
|
1093
|
-
const taskPath = '
|
|
1094
|
-
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
1843
|
+
const taskPath = 'task-01-auth.md';
|
|
1095
1844
|
const gate = path.join(hooks, 'spec-gate.cjs');
|
|
1096
1845
|
const appCwd = path.join(root, 'packages', 'app');
|
|
1097
1846
|
fs.mkdirSync(appCwd, { recursive: true });
|
|
1098
1847
|
const payload = { cwd: appCwd, session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
1099
1848
|
const cacheFile = path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json');
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
'Command: pnpm test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
1103
|
-
|
|
1104
|
-
].join('\n');
|
|
1849
|
+
const output = ['```', 'pass', '```'];
|
|
1850
|
+
const writeValid = () => writeProcessTask(feature, 'done', (writeProcessTask(feature, 'done'), bindReceipt(root, [
|
|
1851
|
+
'Verification: PASS', 'Command: pnpm test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`, ...output,
|
|
1852
|
+
].join('\n'))).split('\n'));
|
|
1105
1853
|
|
|
1106
1854
|
// first-run (no cache) with valid receipt → no block, cache seeded
|
|
1107
1855
|
try { fs.rmSync(cacheFile, { force: true }); } catch {}
|
|
1108
|
-
|
|
1109
|
-
const validBody = bindReceipt(root, validBodyTemplate);
|
|
1110
|
-
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
1856
|
+
writeValid();
|
|
1111
1857
|
let res = runHook(gate, appCwd, payload);
|
|
1112
1858
|
assert.equal(res.stdout, '', 'first-run valid should not block');
|
|
1113
1859
|
assert.ok(fs.existsSync(cacheFile), 'cache file must be created on first valid run');
|
|
@@ -1119,7 +1865,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
|
|
|
1119
1865
|
assert.equal(res.stdout, '', 'cache-hit unchanged valid must not block');
|
|
1120
1866
|
|
|
1121
1867
|
// mutation: remove Command/Verification → must block even though cache says done
|
|
1122
|
-
|
|
1868
|
+
writeProcessTask(feature, 'done', ['Exit: 0', 'Base: abc123', 'Head: def456', ...output]);
|
|
1123
1869
|
res = runHook(gate, appCwd, payload);
|
|
1124
1870
|
assert.equal(JSON.parse(res.stdout).decision, 'block', 'mutated receipt missing Verification/Command should block on cache-hit');
|
|
1125
1871
|
assert.match(JSON.parse(res.stdout).reason, /\bverification_state\b|\bcommand\b/);
|
|
@@ -1128,33 +1874,21 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
|
|
|
1128
1874
|
assert.equal(JSON.parse(res.stdout).decision, 'block', 'second hit after mutation still blocks');
|
|
1129
1875
|
|
|
1130
1876
|
// restore valid, then mutate provenance (remove Head) → block
|
|
1131
|
-
|
|
1877
|
+
writeValid();
|
|
1132
1878
|
assert.equal(runHook(gate, appCwd, payload).stdout, '', 'restored valid should pass again');
|
|
1133
|
-
|
|
1879
|
+
writeProcessTask(feature, 'done', ['Verification: PASS', 'Command: pnpm test', 'Exit: 0', 'Base: abc123', ...output]);
|
|
1134
1880
|
res = runHook(gate, appCwd, payload);
|
|
1135
1881
|
assert.equal(JSON.parse(res.stdout).decision, 'block');
|
|
1136
1882
|
assert.match(JSON.parse(res.stdout).reason, /\bprovenance\b/);
|
|
1137
1883
|
|
|
1138
|
-
// deletion → block with check a
|
|
1139
|
-
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
1140
|
-
assert.equal(runHook(gate, appCwd, payload).stdout, '');
|
|
1141
|
-
fs.unlinkSync(path.join(feature, taskPath));
|
|
1142
|
-
res = runHook(gate, appCwd, payload);
|
|
1143
|
-
assert.equal(JSON.parse(res.stdout).decision, 'block');
|
|
1144
|
-
assert.match(JSON.parse(res.stdout).reason, /\ba\b/);
|
|
1145
|
-
// restore for next sub-test
|
|
1146
|
-
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
1147
|
-
try { fs.rmSync(cacheFile, { force: true }); } catch {}
|
|
1148
|
-
assert.equal(runHook(gate, appCwd, payload).stdout, '');
|
|
1149
|
-
|
|
1150
1884
|
// malformed cache with valid receipt → must still pass (cache parse fail-open)
|
|
1885
|
+
writeValid();
|
|
1151
1886
|
fs.writeFileSync(cacheFile, '{ malformed');
|
|
1152
|
-
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
1153
1887
|
res = runHook(gate, appCwd, payload);
|
|
1154
1888
|
assert.equal(res.stdout, '', 'malformed cache with valid receipt must not block');
|
|
1155
1889
|
// malformed cache with invalid receipt → must still block
|
|
1156
1890
|
fs.writeFileSync(cacheFile, '{ malformed again');
|
|
1157
|
-
|
|
1891
|
+
writeProcessTask(feature, 'done');
|
|
1158
1892
|
res = runHook(gate, appCwd, payload);
|
|
1159
1893
|
assert.equal(JSON.parse(res.stdout).decision, 'block', 'malformed cache with invalid receipt must block');
|
|
1160
1894
|
});
|
|
@@ -1164,18 +1898,11 @@ test('Codex cache identity separates same-feature receipts from different roots'
|
|
|
1164
1898
|
const contexts = [];
|
|
1165
1899
|
for (let index = 0; index < 2; index += 1) {
|
|
1166
1900
|
const context = inHookFixture((root, hooks) => {
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
feature_name: 'auth',
|
|
1173
|
-
task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
|
|
1174
|
-
}));
|
|
1175
|
-
fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
|
|
1176
|
-
'Status: done', '', '## Evidence', '', 'Verification: PASS',
|
|
1177
|
-
'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
1178
|
-
].join('\n')));
|
|
1901
|
+
writeProcessTask(path.join(root, 'specs', 'auth'), 'done', null, 'node --test');
|
|
1902
|
+
writeProcessTask(path.join(root, 'specs', 'auth'), 'done', bindReceipt(root, [
|
|
1903
|
+
'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
1904
|
+
'```', 'node --test', 'ok 1', '```',
|
|
1905
|
+
].join('\n')).split('\n'), 'node --test');
|
|
1179
1906
|
const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
|
|
1180
1907
|
cwd: root,
|
|
1181
1908
|
session_id: 'session-a',
|
|
@@ -1212,33 +1939,32 @@ test('Codex P0 regression: placeholder, explicit failure, artifact, symlink and
|
|
|
1212
1939
|
assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: TBD\n').includes('artifact_hash'));
|
|
1213
1940
|
assert.ok(specReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: \n').includes('artifact_hash'));
|
|
1214
1941
|
assert.deepEqual(specReceipt.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\nArtifact: bundle\nsha256: ${'a'.repeat(64)}\n`), []);
|
|
1215
|
-
// Probe D/E: symlink
|
|
1942
|
+
// Probe D/E: symlink packet and task
|
|
1216
1943
|
const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-codex-reg-'));
|
|
1217
1944
|
try {
|
|
1218
1945
|
const specsDir = path2.join(tmp, 'specs');
|
|
1219
|
-
|
|
1220
|
-
fs2.mkdirSync(path2.join(specsDir, 'valid'), { recursive: true });
|
|
1221
|
-
fs2.writeFileSync(path2.join(specsDir, 'valid', 'spec.json'), JSON.stringify({ status: 'in_progress' }));
|
|
1946
|
+
writeProcessTask(path2.join(specsDir, 'valid'), 'pending');
|
|
1222
1947
|
const outside = path2.join(tmp, 'outside');
|
|
1223
|
-
|
|
1224
|
-
fs2.
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
// task symlink
|
|
1948
|
+
writeProcessTask(outside, 'pending');
|
|
1949
|
+
fs2.symlinkSync(outside, path2.join(specsDir, 'linked'));
|
|
1950
|
+
assert.equal(specUtils.resolveWorkflowCandidate(tmp, {}, 'linked', null).error, 'explicit_malformed');
|
|
1951
|
+
assert.equal(specUtils.resolveWorkflowCandidate(tmp, {}, null, path2.join(specsDir, 'linked', 'plan.md')).error, 'explicit_malformed');
|
|
1952
|
+
fs2.rmSync(path2.join(specsDir, 'linked'));
|
|
1953
|
+
// task symlink to a file outside the packet
|
|
1230
1954
|
const featDir = path2.join(specsDir, 'valid');
|
|
1231
|
-
fs2.mkdirSync(path2.join(featDir, 'tasks'), { recursive: true });
|
|
1232
1955
|
const outsideTask = path2.join(tmp, 'outside-task.md');
|
|
1233
|
-
fs2.
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
assert.
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1956
|
+
fs2.copyFileSync(path2.join(featDir, 'task-01-auth.md'), outsideTask);
|
|
1957
|
+
fs2.symlinkSync(outsideTask, path2.join(featDir, 'task-02-link.md'));
|
|
1958
|
+
const proof = specReceipt.checkWorkflowReceiptDetails(featDir, 'task-02-link.md', null);
|
|
1959
|
+
assert.ok(proof.failures.includes('unsafe_path'), `task symlink must be unsafe, got ${proof.failures}`);
|
|
1960
|
+
assert.equal(specUtils.resolveWorkflowCandidate(tmp, {}, null, null).error, 'invalid_specs');
|
|
1961
|
+
fs2.rmSync(path2.join(featDir, 'task-02-link.md'));
|
|
1962
|
+
// malformed packet: dependency on a missing task
|
|
1963
|
+
const bad = path2.join(specsDir, 'bad');
|
|
1964
|
+
writeProcessTask(bad, 'pending');
|
|
1965
|
+
fs2.writeFileSync(path2.join(bad, 'task-01-auth.md'),
|
|
1966
|
+
fs2.readFileSync(path2.join(bad, 'task-01-auth.md'), 'utf8').replace('- none', '- task-09-missing.md'));
|
|
1967
|
+
const mal = specUtils.resolveWorkflowCandidate(tmp, {}, null, null);
|
|
1242
1968
|
assert.equal(mal.error, 'invalid_specs');
|
|
1243
1969
|
} finally {
|
|
1244
1970
|
fs2.rmSync(tmp, { recursive: true, force: true });
|
|
@@ -1289,17 +2015,10 @@ test('Codex adapter parity - phantom vectors via shared validator (r3)', () => {
|
|
|
1289
2015
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-tap-heading-'));
|
|
1290
2016
|
try {
|
|
1291
2017
|
const featureDir = path.join(tmp, 'feature');
|
|
1292
|
-
const
|
|
1293
|
-
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1296
|
-
const tapFailure = receipt.checkReceipt(featureDir, taskPath, { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
|
|
1297
|
-
assert.ok(tapFailure.includes('c'), 'unindented TAP # fail 1 must reach the shared validator');
|
|
1298
|
-
|
|
1299
|
-
fs.writeFileSync(path.join(featureDir, taskPath), [...taskPrefix, '# Notes', 'FAILED tests/test_demo.py', ''].join('\n'));
|
|
1300
|
-
const markdownBody = receipt.evidenceBody(fs.readFileSync(path.join(featureDir, taskPath), 'utf8'));
|
|
1301
|
-
assert.doesNotMatch(markdownBody, /FAILED tests\/test_demo\.py/);
|
|
1302
|
-
assert.deepEqual(receipt.validateCanonicalReceipt(markdownBody), [], 'a real Markdown heading must still end Evidence');
|
|
2018
|
+
const receiptFields = base.trim().split('\n');
|
|
2019
|
+
writeProcessTask(featureDir, 'done', [...receiptFields, '```', 'pnpm test', '# fail 1', '```']);
|
|
2020
|
+
const tapFailure = receipt.checkWorkflowReceiptDetails(featureDir, 'task-01-auth.md', null);
|
|
2021
|
+
assert.ok(tapFailure.failures.includes('verification_state'), `unindented TAP # fail 1 must reach the shared validator, got ${tapFailure.failures}`);
|
|
1303
2022
|
} finally {
|
|
1304
2023
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1305
2024
|
}
|
|
@@ -1329,38 +2048,20 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
|
|
|
1329
2048
|
const digest = crypto.createHash('sha256').update(artifact).digest('hex');
|
|
1330
2049
|
const cases = [
|
|
1331
2050
|
['', true],
|
|
1332
|
-
['sha256: abc123
|
|
1333
|
-
[`sha256: ${'0'.repeat(64)}
|
|
1334
|
-
[`sha256: ${digest}
|
|
2051
|
+
['sha256: abc123', true],
|
|
2052
|
+
[`sha256: ${'0'.repeat(64)}`, true],
|
|
2053
|
+
[`sha256: ${digest}`, false],
|
|
1335
2054
|
];
|
|
1336
2055
|
for (const [hashLine, shouldBlock] of cases) {
|
|
1337
2056
|
inHookFixture((root, hooks) => {
|
|
1338
2057
|
const feature = path.join(root, 'specs', 'artifact-demo');
|
|
1339
|
-
const taskPath = 'tasks/task.md';
|
|
1340
|
-
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
1341
2058
|
fs.mkdirSync(path.join(root, 'output'), { recursive: true });
|
|
1342
2059
|
fs.writeFileSync(path.join(root, 'output', 'bundle.js'), artifact);
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
current_phase: 'closeout',
|
|
1346
|
-
feature_name: 'artifact-demo',
|
|
1347
|
-
task_registry: {
|
|
1348
|
-
[taskPath]: {
|
|
1349
|
-
status: 'done',
|
|
1350
|
-
completed_at: '2026-08-11T00:00:00.000Z',
|
|
1351
|
-
artifacts: ['output/bundle.js'],
|
|
1352
|
-
},
|
|
1353
|
-
},
|
|
1354
|
-
}));
|
|
1355
|
-
fs.writeFileSync(path.join(feature, taskPath), [
|
|
1356
|
-
'# Task', '', '**Status:** done', '', '## Evidence', '',
|
|
2060
|
+
writeProcessTask(feature, 'done', null, 'node --test');
|
|
2061
|
+
writeProcessTask(feature, 'done', bindReceipt(root, [
|
|
1357
2062
|
'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
1358
|
-
'Artifact: output/bundle.js', hashLine,
|
|
1359
|
-
].join('\n'));
|
|
1360
|
-
fs.writeFileSync(
|
|
1361
|
-
path.join(feature, taskPath),
|
|
1362
|
-
bindReceipt(root, fs.readFileSync(path.join(feature, taskPath), 'utf8'), 'artifact-demo'),
|
|
1363
|
-
);
|
|
2063
|
+
'Artifact: output/bundle.js', hashLine, '```', 'node --test', 'ok 1', '```',
|
|
2064
|
+
].join('\n'), 'artifact-demo').split('\n'), 'node --test');
|
|
1364
2065
|
const result = runHook(path.join(hooks, 'spec-gate.cjs'), root, {
|
|
1365
2066
|
cwd: root,
|
|
1366
2067
|
session_id: 'session-a',
|