@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
- package/bin/__tests__/codex-hooks.test.js +531 -29
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +653 -185
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
- package/bin/__tests__/installer-safety.test.js +39 -0
- package/bin/__tests__/opencode-spec-gate.test.js +24 -16
- package/bin/__tests__/package-inventory.test.js +705 -13
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
- package/bin/__tests__/specs-v2-execution-closeout.test.js +468 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +665 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +1132 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2013 -221
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +5 -2
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +143 -99
- package/src/claude/hooks/__tests__/completion-authority.test.js +321 -58
- package/src/claude/hooks/__tests__/privacy-block.test.js +254 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
- package/src/claude/hooks/completion-authority-check.cjs +37 -288
- package/src/claude/hooks/completion-authority-state.cjs +10 -8
- package/src/claude/hooks/completion-authority.cjs +13 -7
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +99 -28
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/spec-gate.cjs +47 -117
- package/src/claude/hooks/spec-state.cjs +8 -2
- package/src/claude/migration-manifest.json +9 -1
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/change-firewall.cjs +1748 -0
- package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
- package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
- package/src/claude/scripts/spec-final-state.cjs +290 -0
- package/src/claude/scripts/spec-ground.cjs +571 -173
- package/src/claude/scripts/spec-readiness.cjs +425 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +67 -16
- package/src/claude/scripts/spec-scaffold.cjs +1594 -139
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3110 -218
- package/src/claude/scripts/workflow-policy.cjs +501 -183
- package/src/claude/settings/settings.json +4 -0
- package/src/claude/skills/code-review/SKILL.md +12 -3
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +47 -60
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +19 -12
- package/src/claude/skills/specs/SKILL.md +183 -177
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +214 -262
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +35 -85
- package/src/claude/skills/specs/templates/task.md +30 -102
- package/src/claude/skills/sync/SKILL.md +33 -10
- package/src/claude/skills/sync/references/sync-protocols.md +31 -13
- package/src/claude/skills/test/SKILL.md +17 -7
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +43 -240
- package/src/codex/hooks/completion-authority-state.cjs +10 -8
- package/src/codex/hooks/completion-authority.cjs +13 -7
- package/src/codex/hooks/lib/hook-context.cjs +16 -2
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +61 -38
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
- package/src/codex/hooks/lib/spec-utils.cjs +56 -5
- package/src/codex/hooks/privacy-approval.cjs +6 -2
- package/src/codex/hooks/privacy-block.cjs +94 -15
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/spec-gate.cjs +49 -34
- package/src/codex/hooks/spec-state.cjs +10 -3
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +6 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
|
@@ -19,6 +19,10 @@ const { stateDir: codexStateDir } = require(
|
|
|
19
19
|
);
|
|
20
20
|
|
|
21
21
|
function runHook(file, cwd, payload) {
|
|
22
|
+
if (path.basename(file) === 'spec-gate.cjs') {
|
|
23
|
+
const rootResult = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
24
|
+
if (rootResult.status === 0) installFeatureReceipts(rootResult.stdout.trim(), payload.session_id || 'session-a');
|
|
25
|
+
}
|
|
22
26
|
return spawnSync(process.execPath, [file], {
|
|
23
27
|
cwd,
|
|
24
28
|
input: JSON.stringify(payload),
|
|
@@ -41,11 +45,49 @@ function bindReceipt(root, value, feature = 'auth', session = 'session-a') {
|
|
|
41
45
|
return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
function installFeatureReceipts(root, session = 'session-a') {
|
|
49
|
+
const specsRoot = path.join(root, 'specs');
|
|
50
|
+
if (!fs.existsSync(specsRoot)) return;
|
|
51
|
+
for (const feature of fs.readdirSync(specsRoot)) {
|
|
52
|
+
const featureDir = path.join(specsRoot, feature);
|
|
53
|
+
const specFile = path.join(featureDir, 'spec.json');
|
|
54
|
+
if (!fs.existsSync(specFile)) continue;
|
|
55
|
+
let spec;
|
|
56
|
+
try { spec = JSON.parse(fs.readFileSync(specFile, 'utf8')); } catch { continue; }
|
|
57
|
+
const lifecyclePhase = spec.current_phase || spec.phase;
|
|
58
|
+
const explicitCloseout = ['completed', 'complete'].includes(spec.status)
|
|
59
|
+
|| ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
|
|
60
|
+
if (!explicitCloseout) continue;
|
|
61
|
+
const tasks = Object.values(spec.task_registry || {});
|
|
62
|
+
if (tasks.length === 0 || tasks.some((task) => task.status !== 'done')) continue;
|
|
63
|
+
const context = runtimeContext(root, feature, session);
|
|
64
|
+
fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), [
|
|
65
|
+
`Feature: ${feature}`,
|
|
66
|
+
'Expected: final integration verification passes',
|
|
67
|
+
'Observed: final integration verification passed',
|
|
68
|
+
'Verification: PASS',
|
|
69
|
+
'Command: node --test',
|
|
70
|
+
'Exit: 0',
|
|
71
|
+
`Base: ${context.base}`,
|
|
72
|
+
`Head: ${context.head}`,
|
|
73
|
+
'',
|
|
74
|
+
].join('\n'));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
44
78
|
function inHookFixture(run, options = {}) {
|
|
45
79
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
|
|
46
80
|
const hooks = path.join(root, '.codex', 'hooks');
|
|
47
81
|
try {
|
|
48
82
|
fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
|
|
83
|
+
fs.copyFileSync(
|
|
84
|
+
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/privacy-command-analysis.cjs'),
|
|
85
|
+
path.join(hooks, 'lib/privacy-command-analysis.cjs'),
|
|
86
|
+
);
|
|
87
|
+
fs.copyFileSync(
|
|
88
|
+
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
|
|
89
|
+
path.join(hooks, 'lib/runtime-path-safety.cjs'),
|
|
90
|
+
);
|
|
49
91
|
fs.mkdirSync(path.join(root, '.codex', 'scripts'), { recursive: true });
|
|
50
92
|
if (options.policy !== 'missing') {
|
|
51
93
|
if (options.policy === 'malformed') {
|
|
@@ -59,12 +101,22 @@ function inHookFixture(run, options = {}) {
|
|
|
59
101
|
path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'),
|
|
60
102
|
path.join(root, '.codex', 'scripts', 'provenance.cjs'),
|
|
61
103
|
);
|
|
104
|
+
fs.copyFileSync(
|
|
105
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-receipt.cjs'),
|
|
106
|
+
path.join(root, '.codex', 'scripts', 'spec-receipt.cjs'),
|
|
107
|
+
);
|
|
62
108
|
}
|
|
63
109
|
}
|
|
64
110
|
fs.copyFileSync(
|
|
65
111
|
path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
|
|
66
112
|
path.join(root, '.codex', 'scripts', 'spec-resolver.cjs'),
|
|
67
113
|
);
|
|
114
|
+
for (const file of ['validate-spec-output.cjs', 'spec-ground.cjs', 'spec-semantic-model.cjs', 'spec-final-state.cjs']) {
|
|
115
|
+
fs.copyFileSync(
|
|
116
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts', file),
|
|
117
|
+
path.join(root, '.codex', 'scripts', file),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
68
120
|
fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
|
|
69
121
|
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
70
122
|
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
@@ -76,7 +128,7 @@ function inHookFixture(run, options = {}) {
|
|
|
76
128
|
}
|
|
77
129
|
}
|
|
78
130
|
|
|
79
|
-
test('Codex privacy approval is exact, session-bound, and
|
|
131
|
+
test('Codex privacy approval is exact, session-bound, and reusable within its window', () => {
|
|
80
132
|
inHookFixture((root, hooks) => {
|
|
81
133
|
const nested = path.join(root, 'packages', 'app');
|
|
82
134
|
fs.mkdirSync(nested, { recursive: true });
|
|
@@ -121,23 +173,36 @@ test('Codex privacy approval is exact, session-bound, and one-time', () => {
|
|
|
121
173
|
});
|
|
122
174
|
assert.match(wrongSession.stdout, /rejected/);
|
|
123
175
|
|
|
124
|
-
|
|
176
|
+
// A pasted prompt carries the terminal's surrounding whitespace. The
|
|
177
|
+
// exactness that matters is the request id, not the padding.
|
|
178
|
+
const padded = runHook(approve, nested, {
|
|
125
179
|
cwd: nested,
|
|
126
180
|
session_id: 'session-a',
|
|
127
181
|
hook_event_name: 'UserPromptSubmit',
|
|
128
|
-
prompt
|
|
182
|
+
prompt: ` ${prompt}\n`
|
|
129
183
|
});
|
|
130
|
-
assert.match(
|
|
184
|
+
assert.match(padded.stdout, /User approved/, 'whitespace padding must not void the approval');
|
|
131
185
|
|
|
186
|
+
// The grant now covers repeated access to the same paths, and is not bound
|
|
187
|
+
// to the tool that first tripped the gate.
|
|
132
188
|
const allowedOnce = runHook(block, nested, request);
|
|
133
189
|
assert.equal(allowedOnce.stdout, '');
|
|
134
190
|
|
|
135
|
-
const
|
|
136
|
-
assert.equal(
|
|
191
|
+
const allowedAgain = runHook(block, nested, request);
|
|
192
|
+
assert.equal(allowedAgain.stdout, '', 'one approval covers a re-read');
|
|
193
|
+
|
|
194
|
+
const allowedOtherTool = runHook(block, nested, {
|
|
195
|
+
...request,
|
|
196
|
+
tool_name: 'exec_command'
|
|
197
|
+
});
|
|
198
|
+
assert.equal(allowedOtherTool.stdout, '', 'grant is not bound to a tool name');
|
|
137
199
|
|
|
200
|
+
// A fresh session, so the single-path grant above cannot mask the exact-set
|
|
201
|
+
// matching under test.
|
|
138
202
|
fs.writeFileSync(path.join(nested, 'credentials.json'), '{"token":"test"}\n');
|
|
139
203
|
const multiRequest = {
|
|
140
204
|
...request,
|
|
205
|
+
session_id: 'session-c',
|
|
141
206
|
tool_input: { command: 'cat .env credentials.json' }
|
|
142
207
|
};
|
|
143
208
|
const multiDenied = runHook(block, nested, multiRequest);
|
|
@@ -150,31 +215,65 @@ test('Codex privacy approval is exact, session-bound, and one-time', () => {
|
|
|
150
215
|
|
|
151
216
|
const multiApproved = runHook(approve, nested, {
|
|
152
217
|
cwd: nested,
|
|
153
|
-
session_id: 'session-
|
|
218
|
+
session_id: 'session-c',
|
|
154
219
|
hook_event_name: 'UserPromptSubmit',
|
|
155
220
|
prompt: multiPrompt
|
|
156
221
|
});
|
|
157
|
-
assert.match(multiApproved.stdout, /approved
|
|
222
|
+
assert.match(multiApproved.stdout, /User approved/);
|
|
158
223
|
|
|
159
|
-
// A
|
|
160
|
-
//
|
|
161
|
-
const subsetDenied = runHook(block, nested, request);
|
|
224
|
+
// A grant for two paths authorizes exactly that set: neither a subset nor a
|
|
225
|
+
// superset, in either order.
|
|
226
|
+
const subsetDenied = runHook(block, nested, { ...request, session_id: 'session-c' });
|
|
162
227
|
assert.equal(JSON.parse(subsetDenied.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
163
228
|
fs.writeFileSync(path.join(nested, 'tokens.json'), '{"token":"test"}\n');
|
|
164
229
|
const supersetDenied = runHook(block, nested, {
|
|
165
230
|
...request,
|
|
231
|
+
session_id: 'session-c',
|
|
166
232
|
tool_input: { command: 'cat .env credentials.json tokens.json' }
|
|
167
233
|
});
|
|
168
234
|
assert.equal(JSON.parse(supersetDenied.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
169
235
|
const reordered = {
|
|
170
236
|
...request,
|
|
237
|
+
session_id: 'session-c',
|
|
171
238
|
tool_input: { command: 'cat credentials.json .env' }
|
|
172
239
|
};
|
|
173
240
|
assert.equal(runHook(block, nested, reordered).stdout, '');
|
|
174
|
-
assert.equal(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
);
|
|
241
|
+
assert.equal(runHook(block, nested, multiRequest).stdout, '', 'the set stays granted');
|
|
242
|
+
|
|
243
|
+
// Session binding survives the change: another session sees no grant.
|
|
244
|
+
const otherSession = runHook(block, nested, { ...request, session_id: 'session-d' });
|
|
245
|
+
assert.equal(JSON.parse(otherSession.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('Codex privacy grant window is one hour and restarts at approval', () => {
|
|
250
|
+
inHookFixture((root, hooks) => {
|
|
251
|
+
const state = require(path.join(hooks, 'lib', 'privacy-state.cjs'));
|
|
252
|
+
const HOUR = 60 * 60 * 1000;
|
|
253
|
+
assert.equal(state.TTL_MS, HOUR, 'a five-minute window expired mid-conversation');
|
|
254
|
+
|
|
255
|
+
fs.writeFileSync(path.join(root, '.env'), 'SECRET=1\n');
|
|
256
|
+
const pending = state.createPending({
|
|
257
|
+
projectRoot: root,
|
|
258
|
+
sessionCwd: root,
|
|
259
|
+
sessionId: 'session-ttl',
|
|
260
|
+
filePaths: ['.env'],
|
|
261
|
+
});
|
|
262
|
+
assert.ok(pending.expiresAt - Date.now() > HOUR - 5000, 'pending must live an hour');
|
|
263
|
+
|
|
264
|
+
const approved = state.approvePending({
|
|
265
|
+
projectRoot: root,
|
|
266
|
+
sessionId: 'session-ttl',
|
|
267
|
+
requestId: pending.requestId,
|
|
268
|
+
});
|
|
269
|
+
assert.equal(approved.ok, true);
|
|
270
|
+
|
|
271
|
+
const stateDir = path.join(root, '.codex', 'hooks', '.privacy');
|
|
272
|
+
const tokenFile = fs.readdirSync(stateDir).find((name) => name.startsWith('token-'));
|
|
273
|
+
assert.ok(tokenFile, 'approval must write a grant');
|
|
274
|
+
const grant = JSON.parse(fs.readFileSync(path.join(stateDir, tokenFile), 'utf8'));
|
|
275
|
+
assert.ok(grant.expiresAt - Date.now() > HOUR - 5000, 'the grant window starts at approval');
|
|
276
|
+
assert.equal(grant.toolName, undefined, 'a grant is not bound to a tool name');
|
|
178
277
|
});
|
|
179
278
|
});
|
|
180
279
|
|
|
@@ -293,10 +392,25 @@ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
|
|
|
293
392
|
fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
|
|
294
393
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
|
|
295
394
|
|
|
296
|
-
const
|
|
297
|
-
assert.equal(
|
|
298
|
-
assert.equal(JSON.parse(
|
|
299
|
-
assert.match(JSON.parse(
|
|
395
|
+
const blockedBeforeCloseout = runHook(gate, nested, payload);
|
|
396
|
+
assert.equal(blockedBeforeCloseout.status, 0);
|
|
397
|
+
assert.equal(JSON.parse(blockedBeforeCloseout.stdout).decision, 'block');
|
|
398
|
+
assert.match(JSON.parse(blockedBeforeCloseout.stdout).reason, /verification receipt/);
|
|
399
|
+
|
|
400
|
+
fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, [
|
|
401
|
+
'Status: done', '', '## Evidence', '', 'Verification: PASS',
|
|
402
|
+
'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
403
|
+
].join('\n'), 'auth'));
|
|
404
|
+
const validBeforeCloseout = runHook(gate, nested, payload);
|
|
405
|
+
assert.equal(validBeforeCloseout.status, 0);
|
|
406
|
+
assert.equal(validBeforeCloseout.stdout, '');
|
|
407
|
+
assert.equal(fs.existsSync(path.join(feature, 'feature-receipt.md')), false);
|
|
408
|
+
|
|
409
|
+
spec.current_phase = 'closeout';
|
|
410
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
|
|
411
|
+
const closeout = runHook(gate, nested, payload);
|
|
412
|
+
assert.equal(closeout.status, 0);
|
|
413
|
+
assert.equal(closeout.stdout, '');
|
|
300
414
|
});
|
|
301
415
|
});
|
|
302
416
|
|
|
@@ -322,6 +436,7 @@ test('empty or forged Codex hook authority fails closed', () => {
|
|
|
322
436
|
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
323
437
|
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
324
438
|
status: 'in_progress',
|
|
439
|
+
current_phase: 'closeout',
|
|
325
440
|
feature_name: 'auth',
|
|
326
441
|
task_registry: { [taskPath]: { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' } },
|
|
327
442
|
}));
|
|
@@ -347,15 +462,16 @@ test('empty or forged Codex hook authority fails closed', () => {
|
|
|
347
462
|
});
|
|
348
463
|
});
|
|
349
464
|
|
|
350
|
-
test('
|
|
465
|
+
test('explicit Strict Codex completion ignores worker-writable proof strings and remains blocked', () => {
|
|
351
466
|
inHookFixture((root, hooks) => {
|
|
352
467
|
const feature = path.join(root, 'specs', 'auth');
|
|
353
468
|
const taskPath = 'tasks/task.md';
|
|
354
469
|
fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
|
|
355
470
|
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
356
471
|
status: 'in_progress',
|
|
472
|
+
current_phase: 'closeout',
|
|
357
473
|
feature_name: 'auth',
|
|
358
|
-
workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } }),
|
|
474
|
+
workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' }),
|
|
359
475
|
proofs: {
|
|
360
476
|
needsInspection: 'inspection completed',
|
|
361
477
|
needsIndependentAudit: 'PASS',
|
|
@@ -453,6 +569,24 @@ test('Codex state hook persists direct native event fields at project root', ()
|
|
|
453
569
|
});
|
|
454
570
|
});
|
|
455
571
|
|
|
572
|
+
test('semantic review authority hook is registered only for SubagentStop on Claude and Codex', () => {
|
|
573
|
+
const configurations = [
|
|
574
|
+
JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/settings/settings.json'), 'utf8')).hooks,
|
|
575
|
+
JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/codex/hooks.json'), 'utf8')).hooks,
|
|
576
|
+
];
|
|
577
|
+
for (const hooks of configurations) {
|
|
578
|
+
const registrations = [];
|
|
579
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
580
|
+
for (const group of groups) {
|
|
581
|
+
for (const hook of group.hooks || []) {
|
|
582
|
+
if (String(hook.command || '').includes('semantic-review-authority.cjs')) registrations.push(event);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
assert.deepEqual(registrations, ['SubagentStop']);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
456
590
|
test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
457
591
|
const receipt = require(path.join(CODEX_HOOKS, 'lib', 'spec-receipt.cjs'));
|
|
458
592
|
const onlyBase = receipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
|
|
@@ -494,12 +628,12 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
|
494
628
|
const payload = { cwd: path.join(root, 'packages', 'app'), session_id: 'session-a', hook_event_name: 'Stop', stop_hook_active: false };
|
|
495
629
|
|
|
496
630
|
// only Base -> block
|
|
497
|
-
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
631
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', current_phase: 'closeout', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
498
632
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc\n');
|
|
499
633
|
fs.mkdirSync(payload.cwd, { recursive: true });
|
|
500
634
|
const blocked = runHook(gate, payload.cwd, payload);
|
|
501
635
|
assert.equal(JSON.parse(blocked.stdout).decision, 'block');
|
|
502
|
-
assert.match(JSON.parse(blocked.stdout).reason, /\
|
|
636
|
+
assert.match(JSON.parse(blocked.stdout).reason, /\bprovenance\b/);
|
|
503
637
|
|
|
504
638
|
// both -> no block
|
|
505
639
|
fs.writeFileSync(path.join(feature, taskPath), bindReceipt(root, `Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\n`));
|
|
@@ -511,14 +645,14 @@ test('Codex canonical receipt provenance requires both Base and Head', () => {
|
|
|
511
645
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
|
|
512
646
|
const blockedEmpty = runHook(gate, payload.cwd, payload);
|
|
513
647
|
assert.equal(JSON.parse(blockedEmpty.stdout).decision, 'block');
|
|
514
|
-
assert.match(JSON.parse(blockedEmpty.stdout).reason, /\
|
|
648
|
+
assert.match(JSON.parse(blockedEmpty.stdout).reason, /\bprovenance\b/);
|
|
515
649
|
|
|
516
650
|
// bare base_sha without colon should block — reset cache again
|
|
517
651
|
try { fs.rmSync(path.join(root, '.codex', 'hooks', '.logs', 'spec-gate-last.json'), { force: true }); } catch {}
|
|
518
652
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha head_sha\n');
|
|
519
653
|
const blockedBare = runHook(gate, payload.cwd, payload);
|
|
520
654
|
assert.equal(JSON.parse(blockedBare.stdout).decision, 'block');
|
|
521
|
-
assert.match(JSON.parse(blockedBare.stdout).reason, /\
|
|
655
|
+
assert.match(JSON.parse(blockedBare.stdout).reason, /\bprovenance\b/);
|
|
522
656
|
});
|
|
523
657
|
});
|
|
524
658
|
|
|
@@ -540,7 +674,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
|
|
|
540
674
|
|
|
541
675
|
// first-run (no cache) with valid receipt → no block, cache seeded
|
|
542
676
|
try { fs.rmSync(cacheFile, { force: true }); } catch {}
|
|
543
|
-
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
677
|
+
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({ status: 'in_progress', current_phase: 'closeout', feature_name: 'auth', task_registry: { [taskPath]: { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } } }));
|
|
544
678
|
const validBody = bindReceipt(root, validBodyTemplate);
|
|
545
679
|
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
546
680
|
let res = runHook(gate, appCwd, payload);
|
|
@@ -557,7 +691,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
|
|
|
557
691
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nExit: 0\nBase: abc123\nHead: def456\n```\npass\n```\n');
|
|
558
692
|
res = runHook(gate, appCwd, payload);
|
|
559
693
|
assert.equal(JSON.parse(res.stdout).decision, 'block', 'mutated receipt missing Verification/Command should block on cache-hit');
|
|
560
|
-
assert.match(JSON.parse(res.stdout).reason, /\
|
|
694
|
+
assert.match(JSON.parse(res.stdout).reason, /\bverification_state\b|\bcommand\b/);
|
|
561
695
|
// second hit still blocks
|
|
562
696
|
res = runHook(gate, appCwd, payload);
|
|
563
697
|
assert.equal(JSON.parse(res.stdout).decision, 'block', 'second hit after mutation still blocks');
|
|
@@ -568,7 +702,7 @@ test('Codex cache hardening: every done re-validated — mutation, deletion, mal
|
|
|
568
702
|
fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: abc123\n```\npass\n```\n');
|
|
569
703
|
res = runHook(gate, appCwd, payload);
|
|
570
704
|
assert.equal(JSON.parse(res.stdout).decision, 'block');
|
|
571
|
-
assert.match(JSON.parse(res.stdout).reason, /\
|
|
705
|
+
assert.match(JSON.parse(res.stdout).reason, /\bprovenance\b/);
|
|
572
706
|
|
|
573
707
|
// deletion → block with check a
|
|
574
708
|
fs.writeFileSync(path.join(feature, taskPath), validBody);
|
|
@@ -777,6 +911,7 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
|
|
|
777
911
|
fs.writeFileSync(path.join(root, 'output', 'bundle.js'), artifact);
|
|
778
912
|
fs.writeFileSync(path.join(feature, 'spec.json'), JSON.stringify({
|
|
779
913
|
status: 'in_progress',
|
|
914
|
+
current_phase: 'closeout',
|
|
780
915
|
feature_name: 'artifact-demo',
|
|
781
916
|
task_registry: {
|
|
782
917
|
[taskPath]: {
|
|
@@ -805,10 +940,377 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
|
|
|
805
940
|
if (shouldBlock) {
|
|
806
941
|
const block = JSON.parse(result.stdout);
|
|
807
942
|
assert.equal(block.decision, 'block');
|
|
808
|
-
assert.match(block.reason, /\
|
|
943
|
+
assert.match(block.reason, /\bartifact_hash\b/);
|
|
809
944
|
} else {
|
|
810
945
|
assert.equal(result.stdout, '');
|
|
811
946
|
}
|
|
812
947
|
});
|
|
813
948
|
}
|
|
814
949
|
});
|
|
950
|
+
|
|
951
|
+
test('Codex privacy denies obfuscated secret reads, allows benign and runtime state', () => {
|
|
952
|
+
inHookFixture((root, hooks) => {
|
|
953
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
954
|
+
const denied = [
|
|
955
|
+
{ command: "cat .e''nv", tool: 'exec_command' },
|
|
956
|
+
{ command: 'cat ~/.cafekit/completion-authority/hmac.key', tool: 'exec_command' },
|
|
957
|
+
{ command: 'cat ~/.ssh/id_rsa', tool: 'Bash' },
|
|
958
|
+
{ command: 'docker compose --env-file .env up', tool: 'exec_command' },
|
|
959
|
+
{ command: "python3 -c \"open('.env').read()\"", tool: 'exec_command' },
|
|
960
|
+
{ command: "node -e \"fs.readFileSync('.env')\"", tool: 'Bash' },
|
|
961
|
+
{ command: "bash -c 'cat .e''nv'", tool: 'exec_command' },
|
|
962
|
+
{ command: "echo ok; cat .env", tool: 'Bash' },
|
|
963
|
+
];
|
|
964
|
+
for (const { command, tool } of denied) {
|
|
965
|
+
const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
|
|
966
|
+
assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', command);
|
|
967
|
+
}
|
|
968
|
+
// Runtime state and transcripts are CafeKit's own bookkeeping, not secrets.
|
|
969
|
+
// Guarding them by substring also matched every source file and doc that
|
|
970
|
+
// merely spelled `sessions` or `session-state`, so the gate fired on the
|
|
971
|
+
// repo it was installed in. The one real secret there, hmac.key, is still
|
|
972
|
+
// covered above by the `.key` rule.
|
|
973
|
+
const allowed = [
|
|
974
|
+
{ command: "echo hello", tool: 'exec_command' },
|
|
975
|
+
{ command: "cat README.md", tool: 'exec_command' },
|
|
976
|
+
{ command: "cat src/app.js", tool: 'Bash' },
|
|
977
|
+
{ command: 'cat .codex/sessions/test.json', tool: 'exec_command' },
|
|
978
|
+
{ command: 'cat .codex/session-state/latest.md', tool: 'Bash' },
|
|
979
|
+
{ command: 'cat docs/sessions-guide.md', tool: 'exec_command' },
|
|
980
|
+
];
|
|
981
|
+
for (const { command, tool } of allowed) {
|
|
982
|
+
const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
|
|
983
|
+
assert.equal(res.stdout, '', command);
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
test('Codex privacy denies sensitive names it can resolve, allows pure indirection', () => {
|
|
989
|
+
inHookFixture((root, hooks) => {
|
|
990
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
991
|
+
const denied = [
|
|
992
|
+
{ command: 'cat .e\\\nnv', tool: 'Bash' },
|
|
993
|
+
{ command: "cat $'\\x2e\\x65\\x6e\\x76'", tool: 'exec_command' },
|
|
994
|
+
{ command: 'cat .env*', tool: 'Bash' },
|
|
995
|
+
{ command: 'cat certs/*.pem', tool: 'exec_command' },
|
|
996
|
+
{ command: 'cat ~/.aws/credentials', tool: 'Bash' },
|
|
997
|
+
{ command: 'ssh -i ~/.ssh/id_ed25519 host', tool: 'exec_command' },
|
|
998
|
+
{ command: 'curl --config ~/.netrc https://example.com', tool: 'Bash' },
|
|
999
|
+
{ command: 'kubectl --kubeconfig ~/.kube/kubeconfig get pods', tool: 'exec_command' },
|
|
1000
|
+
{ command: 'openssl rsa -in server.key', tool: 'Bash' },
|
|
1001
|
+
];
|
|
1002
|
+
for (const { command, tool } of denied) {
|
|
1003
|
+
const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
|
|
1004
|
+
assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', `should deny ${command}`);
|
|
1005
|
+
}
|
|
1006
|
+
// Accepted gap: the target of `$FILE` is unknowable without running the
|
|
1007
|
+
// shell, and every denial here costs a round trip through a one-shot
|
|
1008
|
+
// approval prompt. `less "$HOME/.env"` is the edge of that decision.
|
|
1009
|
+
const allowed = [
|
|
1010
|
+
{ command: "echo $VAR", tool: 'exec_command' },
|
|
1011
|
+
{ command: 'echo "$HOME"', tool: 'Bash' },
|
|
1012
|
+
{ command: "git status", tool: 'exec_command' },
|
|
1013
|
+
{ command: "git status $VAR", tool: 'Bash' },
|
|
1014
|
+
{ command: "cat README.md", tool: 'exec_command' },
|
|
1015
|
+
{ command: "cat .env.example", tool: 'Bash' },
|
|
1016
|
+
{ command: "cat .env.sample", tool: 'Bash' },
|
|
1017
|
+
{ command: "cat .env.template", tool: 'exec_command' },
|
|
1018
|
+
{ command: "cat .env.test", tool: 'Bash' },
|
|
1019
|
+
{ command: "cat '$FILE'", tool: 'exec_command' },
|
|
1020
|
+
{ command: "cat '$HOME/README.md'", tool: 'Bash' },
|
|
1021
|
+
{ command: "cat README.md '$EXTRA'", tool: 'exec_command' },
|
|
1022
|
+
{ command: "echo '$(cat $FILE)'", tool: 'Bash' },
|
|
1023
|
+
{ command: 'echo .envoy', tool: 'exec_command' },
|
|
1024
|
+
{ command: 'echo not.env', tool: 'Bash' },
|
|
1025
|
+
{ command: 'echo .env', tool: 'exec_command' },
|
|
1026
|
+
{ command: "printf '%s\\n' .env", tool: 'Bash' },
|
|
1027
|
+
{ command: 'git log -- .env', tool: 'Bash' },
|
|
1028
|
+
{ command: 'git status sessions.md', tool: 'exec_command' },
|
|
1029
|
+
{ command: 'python3 -c "print(os.environ[\'HOME\'])"', tool: 'Bash' },
|
|
1030
|
+
{ command: 'head -n "$COUNT" README.md', tool: 'exec_command' },
|
|
1031
|
+
{ command: 'grep .env README.md', tool: 'Bash' },
|
|
1032
|
+
{ command: "sed -n '/.env/p' README.md", tool: 'exec_command' },
|
|
1033
|
+
{ command: 'cat $FILE', tool: 'Bash' },
|
|
1034
|
+
{ command: 'cat "$FILE"', tool: 'exec_command' },
|
|
1035
|
+
{ command: 'cat ${FILE}', tool: 'Bash' },
|
|
1036
|
+
{ command: 'cat $1', tool: 'exec_command' },
|
|
1037
|
+
{ command: 'cat $(echo .env)', tool: 'Bash' },
|
|
1038
|
+
{ command: 'cat `echo .env`', tool: 'Bash' },
|
|
1039
|
+
{ command: 'head $SECRET', tool: 'exec_command' },
|
|
1040
|
+
{ command: 'tail ${MY_FILE}', tool: 'Bash' },
|
|
1041
|
+
{ command: 'less "$HOME/.env"', tool: 'Bash' },
|
|
1042
|
+
{ command: 'cat README.md $EXTRA', tool: 'Bash' },
|
|
1043
|
+
{ command: 'echo ok; cat README.md $FILE', tool: 'Bash' },
|
|
1044
|
+
{ command: 'cat README.md | head $FILE', tool: 'exec_command' },
|
|
1045
|
+
{ command: "bash -c 'cat $FILE'", tool: 'Bash' },
|
|
1046
|
+
{ command: 'cat README.md *', tool: 'Bash' },
|
|
1047
|
+
{ command: 'cat ~/.zshrc', tool: 'exec_command' },
|
|
1048
|
+
{ command: 'du -sh */', tool: 'Bash' },
|
|
1049
|
+
{ command: 'env -u UNUSED cat "$FILE"', tool: 'Bash' },
|
|
1050
|
+
{ command: 'timeout 1 cat "$FILE"', tool: 'exec_command' },
|
|
1051
|
+
{ command: "eval 'cat \"$FILE\"'", tool: 'exec_command' },
|
|
1052
|
+
{ command: "find . -exec sh -c 'cat \"$FILE\"' \\;", tool: 'Bash' },
|
|
1053
|
+
{ command: 'source "$FILE"', tool: 'exec_command' },
|
|
1054
|
+
{ command: 'xargs -a "$LIST" cat', tool: 'Bash' },
|
|
1055
|
+
{ command: 'python3 -c \'open(os.environ["FILE"]).read()\'', tool: 'exec_command' },
|
|
1056
|
+
{ command: 'node -e \'fs.readFileSync(process.env.FILE)\'', tool: 'Bash' },
|
|
1057
|
+
{ command: 'cat < "$FILE"', tool: 'exec_command' },
|
|
1058
|
+
];
|
|
1059
|
+
for (const { command, tool } of allowed) {
|
|
1060
|
+
const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
|
|
1061
|
+
assert.equal(res.stdout, '', `should allow ${command}`);
|
|
1062
|
+
}
|
|
1063
|
+
const assignmentOnly = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat README.md' } });
|
|
1064
|
+
assert.equal(assignmentOnly.stdout, '');
|
|
1065
|
+
const assignmentRead = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat .env' } });
|
|
1066
|
+
assert.equal(JSON.parse(assignmentRead.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test('Codex privacy reads an inert heredoc body as prose, not shell', () => {
|
|
1071
|
+
inHookFixture((root, hooks) => {
|
|
1072
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
1073
|
+
const inert = "gh pr create --body \"$(cat <<'BODY'\nRun `cat .env` to inspect config\nBODY\n)\"";
|
|
1074
|
+
const allowed = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: inert } });
|
|
1075
|
+
assert.equal(allowed.stdout, '', 'quoted delimiter expands nothing');
|
|
1076
|
+
|
|
1077
|
+
const expanding = 'bash -c "cat <<BODY\n$(cat .env)\nBODY"';
|
|
1078
|
+
const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: expanding } });
|
|
1079
|
+
assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny', 'unquoted delimiter expands');
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
test('Claude and Codex privacy lexers agree on literals, delimiters, and quote expansion', () => {
|
|
1084
|
+
inHookFixture((root, hooks) => {
|
|
1085
|
+
const claude = path.join(PACKAGE_ROOT, 'src/claude/hooks/privacy-block.cjs');
|
|
1086
|
+
const codex = path.join(hooks, 'privacy-block.cjs');
|
|
1087
|
+
const vectors = [
|
|
1088
|
+
["cat .e''nv", true],
|
|
1089
|
+
["python3 -c \"open('.env').read()\"", true],
|
|
1090
|
+
['cat README.md $FILE', false],
|
|
1091
|
+
['echo ok; cat README.md $FILE', false],
|
|
1092
|
+
["cat '$FILE'", false],
|
|
1093
|
+
["echo '$(cat $FILE)'", false],
|
|
1094
|
+
["cat README.md '*'", false],
|
|
1095
|
+
["cat README.md '~'", false],
|
|
1096
|
+
["cat README.md '{README.md,LICENSE}'", false],
|
|
1097
|
+
['cat .env.example', false],
|
|
1098
|
+
['echo .envoy', false],
|
|
1099
|
+
['cat .e\\\nnv', true],
|
|
1100
|
+
["cat $'\\x2e\\x65\\x6e\\x76'", true],
|
|
1101
|
+
['env -u UNUSED cat "$FILE"', false],
|
|
1102
|
+
['timeout 1 cat "$FILE"', false],
|
|
1103
|
+
["eval 'cat \"$FILE\"'", false],
|
|
1104
|
+
["find . -exec sh -c 'cat \"$FILE\"' \\;", false],
|
|
1105
|
+
['source "$FILE"', false],
|
|
1106
|
+
['xargs -a "$LIST" cat', false],
|
|
1107
|
+
['python3 -c \'open(os.environ["FILE"]).read()\'', false],
|
|
1108
|
+
['cat ~/.ssh/id_rsa', true],
|
|
1109
|
+
['docker compose --env-file .env up', true],
|
|
1110
|
+
['cat ~/.zshrc', false],
|
|
1111
|
+
['git log -- .env', false],
|
|
1112
|
+
['echo .env', false],
|
|
1113
|
+
["printf '%s\\n' .env", false],
|
|
1114
|
+
['git status sessions.md', false],
|
|
1115
|
+
['python3 -c "print(os.environ[\'HOME\'])"', false],
|
|
1116
|
+
];
|
|
1117
|
+
for (const [command, blocked] of vectors) {
|
|
1118
|
+
const payload = { cwd: root, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command } };
|
|
1119
|
+
const stateDir = path.join(hooks, '.privacy');
|
|
1120
|
+
const pendingCount = () => fs.existsSync(stateDir)
|
|
1121
|
+
? fs.readdirSync(stateDir).filter((name) => name.startsWith('pending-')).length
|
|
1122
|
+
: 0;
|
|
1123
|
+
const beforePending = pendingCount();
|
|
1124
|
+
const claudeResult = runHook(claude, root, payload);
|
|
1125
|
+
const codexResult = runHook(codex, root, { ...payload, session_id: `parity-${command}` });
|
|
1126
|
+
const claudeOutput = claudeResult.stdout.trim() ? JSON.parse(claudeResult.stdout) : null;
|
|
1127
|
+
const codexOutput = codexResult.stdout.trim() ? JSON.parse(codexResult.stdout) : null;
|
|
1128
|
+
assert.equal(claudeOutput?.hookSpecificOutput?.permissionDecision || null, blocked ? 'ask' : null, `Claude parity vector: ${command}`);
|
|
1129
|
+
assert.equal(codexOutput?.hookSpecificOutput?.permissionDecision || null, blocked ? 'deny' : null, `Codex parity vector: ${command}`);
|
|
1130
|
+
if (blocked) {
|
|
1131
|
+
assert.equal(claudeOutput.hookSpecificOutput.hookEventName, 'PreToolUse');
|
|
1132
|
+
assert.equal(codexOutput.hookSpecificOutput.hookEventName, 'PreToolUse');
|
|
1133
|
+
assert.ok(claudeOutput.hookSpecificOutput.permissionDecisionReason);
|
|
1134
|
+
assert.ok(codexOutput.hookSpecificOutput.permissionDecisionReason);
|
|
1135
|
+
}
|
|
1136
|
+
assert.equal(pendingCount() - beforePending, blocked ? 1 : 0, `Codex approval-state side effect: ${command}`);
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
test('Codex privacy handles dangling symlink to protected target (and allows benign/example)', () => {
|
|
1142
|
+
inHookFixture((root, hooks) => {
|
|
1143
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
1144
|
+
// Create .env and dangling link
|
|
1145
|
+
const env = path.join(root, '.env');
|
|
1146
|
+
fs.writeFileSync(env, 'SECRET=1');
|
|
1147
|
+
const link = path.join(root, 'innocent.txt');
|
|
1148
|
+
fs.symlinkSync(env, link);
|
|
1149
|
+
fs.unlinkSync(env);
|
|
1150
|
+
const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: link } });
|
|
1151
|
+
assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny', 'dangling to .env must deny');
|
|
1152
|
+
|
|
1153
|
+
// Dangling benign
|
|
1154
|
+
const benign = path.join(root, 'benign.txt');
|
|
1155
|
+
fs.writeFileSync(benign, 'hi');
|
|
1156
|
+
const dangling = path.join(root, 'dangling.txt');
|
|
1157
|
+
fs.symlinkSync(benign, dangling);
|
|
1158
|
+
fs.unlinkSync(benign);
|
|
1159
|
+
const allowed1 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: dangling } });
|
|
1160
|
+
assert.equal(allowed1.stdout, '', 'dangling benign should allow');
|
|
1161
|
+
|
|
1162
|
+
// Dangling to .env.example
|
|
1163
|
+
const example = path.join(root, '.env.example');
|
|
1164
|
+
fs.writeFileSync(example, 'EXAMPLE=1');
|
|
1165
|
+
const linkExample = path.join(root, 'link-example.txt');
|
|
1166
|
+
fs.symlinkSync(example, linkExample);
|
|
1167
|
+
fs.unlinkSync(example);
|
|
1168
|
+
const allowed2 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: linkExample } });
|
|
1169
|
+
assert.equal(allowed2.stdout, '', 'dangling to .env.example should allow');
|
|
1170
|
+
|
|
1171
|
+
// Real link to allowed example remains allowed
|
|
1172
|
+
fs.writeFileSync(example, 'EXAMPLE=1');
|
|
1173
|
+
const realLink = path.join(root, 'real-example.txt');
|
|
1174
|
+
if (fs.existsSync(realLink)) fs.unlinkSync(realLink);
|
|
1175
|
+
// remove previous dangling if exists
|
|
1176
|
+
try { fs.unlinkSync(linkExample); } catch {}
|
|
1177
|
+
fs.symlinkSync(example, realLink);
|
|
1178
|
+
const allowed3 = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: realLink } });
|
|
1179
|
+
assert.equal(allowed3.stdout, '', 'real link to .env.example should allow');
|
|
1180
|
+
|
|
1181
|
+
// Looping symlink fails closed
|
|
1182
|
+
const a = path.join(root, 'loop-a');
|
|
1183
|
+
const b = path.join(root, 'loop-b');
|
|
1184
|
+
try { fs.unlinkSync(a); } catch {}
|
|
1185
|
+
try { fs.unlinkSync(b); } catch {}
|
|
1186
|
+
fs.symlinkSync(b, a);
|
|
1187
|
+
fs.symlinkSync(a, b);
|
|
1188
|
+
const looped = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: a } });
|
|
1189
|
+
assert.equal(JSON.parse(looped.stdout).hookSpecificOutput.permissionDecision, 'deny', 'looping symlink should deny');
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
test('Codex privacy apply_patch with temp fixtures respects sensitive vs safe', () => {
|
|
1194
|
+
inHookFixture((root, hooks) => {
|
|
1195
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
1196
|
+
const safePatch = '*** Begin Patch\n*** Update File: README.md\n+hi\n*** End Patch\n';
|
|
1197
|
+
const safeRes = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'apply_patch', tool_input: { patch: safePatch } });
|
|
1198
|
+
assert.equal(safeRes.stdout, '', 'safe patch should allow');
|
|
1199
|
+
const sensitivePatch = '*** Begin Patch\n*** Update File: .env\n+SECRET=x\n*** End Patch\n';
|
|
1200
|
+
const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'apply_patch', tool_input: { patch: sensitivePatch } });
|
|
1201
|
+
assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test('Codex hook-context canonicalizes symlink project root (regression)', () => {
|
|
1206
|
+
inHookFixture((root, hooks) => {
|
|
1207
|
+
const hookContextPath = path.join(hooks, 'lib', 'hook-context.cjs');
|
|
1208
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-symroot-'));
|
|
1209
|
+
try {
|
|
1210
|
+
const linkRoot = path.join(outside, 'link-project');
|
|
1211
|
+
fs.symlinkSync(root, linkRoot);
|
|
1212
|
+
// Require the installed hook-context via the symlink path to see PROJECT_ROOT canonicalization
|
|
1213
|
+
const linkedContext = require(path.join(linkRoot, '.codex', 'hooks', 'lib', 'hook-context.cjs'));
|
|
1214
|
+
const realRoot = fs.realpathSync(root);
|
|
1215
|
+
assert.equal(linkedContext.PROJECT_ROOT, realRoot, 'PROJECT_ROOT should be canonicalized via realpath');
|
|
1216
|
+
// getHookContext should still work with cwd inside symlink root
|
|
1217
|
+
const payload = { cwd: linkRoot, session_id: 's1' };
|
|
1218
|
+
const ctx = linkedContext.getHookContext(payload);
|
|
1219
|
+
assert.equal(ctx.projectRoot, realRoot);
|
|
1220
|
+
assert.ok(ctx.sessionCwd, 'sessionCwd should be resolved');
|
|
1221
|
+
} finally {
|
|
1222
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
1223
|
+
// Clear require cache for the symlink context to avoid polluting other tests
|
|
1224
|
+
Object.keys(require.cache).forEach((k) => { if (k.includes('link-project')) delete require.cache[k]; });
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
test('Codex resolver and state reject symlink escape', () => {
|
|
1230
|
+
inHookFixture((root, hooks) => {
|
|
1231
|
+
const specUtils = require(path.join(hooks, 'lib', 'spec-utils.cjs'));
|
|
1232
|
+
const resolverPath = path.join(root, '.codex', 'scripts', 'spec-resolver.cjs');
|
|
1233
|
+
const linkTarget = path.join(root, 'external.js');
|
|
1234
|
+
fs.writeFileSync(linkTarget, 'module.exports = {}');
|
|
1235
|
+
const original = fs.readFileSync(resolverPath);
|
|
1236
|
+
fs.unlinkSync(resolverPath);
|
|
1237
|
+
fs.symlinkSync(linkTarget, resolverPath);
|
|
1238
|
+
assert.throws(() => specUtils.specsDirectory(root, {}), /symlink|rejected/i);
|
|
1239
|
+
fs.unlinkSync(resolverPath);
|
|
1240
|
+
fs.writeFileSync(resolverPath, original);
|
|
1241
|
+
const scripts = path.dirname(resolverPath);
|
|
1242
|
+
const outsideScripts = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-scripts-outside-'));
|
|
1243
|
+
try {
|
|
1244
|
+
fs.cpSync(scripts, outsideScripts, { recursive: true });
|
|
1245
|
+
fs.rmSync(scripts, { recursive: true, force: true });
|
|
1246
|
+
fs.symlinkSync(outsideScripts, scripts);
|
|
1247
|
+
assert.throws(() => specUtils.specsDirectory(root, {}), /symlink|trusted root|rejected/i);
|
|
1248
|
+
} finally {
|
|
1249
|
+
try { fs.unlinkSync(scripts); } catch {}
|
|
1250
|
+
fs.mkdirSync(scripts, { recursive: true });
|
|
1251
|
+
fs.copyFileSync(
|
|
1252
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'),
|
|
1253
|
+
path.join(scripts, 'spec-resolver.cjs'),
|
|
1254
|
+
);
|
|
1255
|
+
fs.rmSync(outsideScripts, { recursive: true, force: true });
|
|
1256
|
+
}
|
|
1257
|
+
// State dir symlink escape: create symlink for session-state dir
|
|
1258
|
+
const stateStore = require(path.join(hooks, 'lib', 'state-store.cjs'));
|
|
1259
|
+
const sessionId = 'sess-symlink';
|
|
1260
|
+
const dir = stateStore.stateDir(root, sessionId);
|
|
1261
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
1262
|
+
// Create a symlink that points outside project
|
|
1263
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'));
|
|
1264
|
+
try {
|
|
1265
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
1266
|
+
fs.symlinkSync(outside, dir);
|
|
1267
|
+
// withStateLock should handle symlink safely (no crash, no write outside)
|
|
1268
|
+
const ok = stateStore.withStateLock(root, sessionId, () => {});
|
|
1269
|
+
assert.equal(typeof ok, 'boolean');
|
|
1270
|
+
} finally {
|
|
1271
|
+
try { fs.unlinkSync(dir); } catch {}
|
|
1272
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
test('Codex privacy state rejects a symlinked state directory without external writes', () => {
|
|
1278
|
+
inHookFixture((root, hooks) => {
|
|
1279
|
+
const block = path.join(hooks, 'privacy-block.cjs');
|
|
1280
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-privacy-outside-'));
|
|
1281
|
+
const state = path.join(hooks, '.privacy');
|
|
1282
|
+
try {
|
|
1283
|
+
fs.symlinkSync(outside, state);
|
|
1284
|
+
const result = runHook(block, root, {
|
|
1285
|
+
cwd: root,
|
|
1286
|
+
session_id: 'state-symlink',
|
|
1287
|
+
hook_event_name: 'PreToolUse',
|
|
1288
|
+
tool_name: 'Read',
|
|
1289
|
+
tool_input: { file_path: path.join(root, '.env') },
|
|
1290
|
+
});
|
|
1291
|
+
const output = JSON.parse(result.stdout);
|
|
1292
|
+
assert.equal(output.hookSpecificOutput.permissionDecision, 'deny');
|
|
1293
|
+
assert.match(output.hookSpecificOutput.permissionDecisionReason, /could not be evaluated safely/i);
|
|
1294
|
+
assert.deepEqual(fs.readdirSync(outside), []);
|
|
1295
|
+
} finally {
|
|
1296
|
+
try { fs.unlinkSync(state); } catch {}
|
|
1297
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
test('Claude and Codex fail closed consistently for malformed cwd payloads', () => {
|
|
1303
|
+
inHookFixture((root, hooks) => {
|
|
1304
|
+
const payload = {
|
|
1305
|
+
cwd: 123,
|
|
1306
|
+
session_id: 'malformed-cwd',
|
|
1307
|
+
hook_event_name: 'PreToolUse',
|
|
1308
|
+
tool_name: 'Read',
|
|
1309
|
+
tool_input: { file_path: path.join(root, '.env') },
|
|
1310
|
+
};
|
|
1311
|
+
const claude = runHook(path.join(PACKAGE_ROOT, 'src/claude/hooks/privacy-block.cjs'), root, payload);
|
|
1312
|
+
const codex = runHook(path.join(hooks, 'privacy-block.cjs'), root, payload);
|
|
1313
|
+
assert.equal(JSON.parse(claude.stdout).hookSpecificOutput.permissionDecision, 'ask');
|
|
1314
|
+
assert.equal(JSON.parse(codex.stdout).hookSpecificOutput.permissionDecision, 'deny');
|
|
1315
|
+
});
|
|
1316
|
+
});
|