@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
|
@@ -98,12 +98,20 @@ test('an exempt-looking symlink to .env still asks based on its target', () => {
|
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
// A secret named in a read operand the analyzer can resolve, however it is
|
|
102
|
+
// spelled: quoted, concatenated, line-continued, ANSI-C escaped, or reached
|
|
103
|
+
// through a nested shell or inline runtime.
|
|
101
104
|
for (const command of [
|
|
102
105
|
'cat ".env"',
|
|
103
106
|
'cat .env',
|
|
104
107
|
'cat .env*',
|
|
105
108
|
'sed -n 1p .env',
|
|
106
109
|
"bash -c 'sed -n 1p .env'",
|
|
110
|
+
"python3 -c \"open('.env').read()\"",
|
|
111
|
+
"node -e \"fs.readFileSync('.env')\"",
|
|
112
|
+
"bash -c 'cat .e''nv'",
|
|
113
|
+
'cat .e\\\nnv',
|
|
114
|
+
"cat $'\\x2e\\x65\\x6e\\x76'",
|
|
107
115
|
]) {
|
|
108
116
|
test(`asks natively for Bash sensitive reference: ${command}`, () => {
|
|
109
117
|
const dir = tmpDir();
|
|
@@ -117,3 +125,249 @@ for (const command of [
|
|
|
117
125
|
}
|
|
118
126
|
});
|
|
119
127
|
}
|
|
128
|
+
|
|
129
|
+
// --- Shapes the command analyzer cannot return ---
|
|
130
|
+
// It drops any word carrying an expansion, and it only walks readers it
|
|
131
|
+
// recognises. A sensitive name still has to be caught when it arrives with a
|
|
132
|
+
// glob or `~`, or when an unknown tool is handed it through a file-shaped
|
|
133
|
+
// option.
|
|
134
|
+
for (const command of [
|
|
135
|
+
'cat ~/.ssh/id_rsa',
|
|
136
|
+
'cat ~/.aws/credentials',
|
|
137
|
+
'cat certs/*.pem',
|
|
138
|
+
'ssh -i ~/.ssh/id_ed25519 host',
|
|
139
|
+
'docker compose --env-file .env up',
|
|
140
|
+
'curl --config ~/.netrc https://example.com',
|
|
141
|
+
'kubectl --kubeconfig ~/.kube/kubeconfig get pods',
|
|
142
|
+
'openssl rsa -in server.key',
|
|
143
|
+
'cat secrets.yaml',
|
|
144
|
+
]) {
|
|
145
|
+
test(`asks for sensitive name via expansion or file-shaped option: ${command}`, () => {
|
|
146
|
+
const dir = tmpDir();
|
|
147
|
+
try {
|
|
148
|
+
const { code, stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
149
|
+
assert.strictEqual(code, 0);
|
|
150
|
+
assert.strictEqual(permissionDecision(stdout), 'ask', `should ask for ${command}`);
|
|
151
|
+
} finally {
|
|
152
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Naming a secret is not reading one (fix: 740798c) ---
|
|
158
|
+
// The analyzer returns the operands of readers it recognises. A command that
|
|
159
|
+
// only spells a secret's name -- an echo, a grep pattern, a pathspec -- reads
|
|
160
|
+
// nothing, and asking there is the noise that trains people to click through
|
|
161
|
+
// the prompt that matters.
|
|
162
|
+
for (const command of [
|
|
163
|
+
'echo .env',
|
|
164
|
+
'echo "update .env"',
|
|
165
|
+
'echo .envoy',
|
|
166
|
+
'echo not.env',
|
|
167
|
+
"printf '%s\\n' .env",
|
|
168
|
+
'git log -- .env',
|
|
169
|
+
'git status sessions.md',
|
|
170
|
+
'grep .env README.md',
|
|
171
|
+
"sed -n '/.env/p' README.md",
|
|
172
|
+
]) {
|
|
173
|
+
test(`allows naming a secret without reading it: ${command}`, () => {
|
|
174
|
+
const dir = tmpDir();
|
|
175
|
+
try {
|
|
176
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hi');
|
|
177
|
+
const { stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
178
|
+
assert.strictEqual(permissionDecision(stdout), null, `should allow ${command}`);
|
|
179
|
+
} finally {
|
|
180
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- Accepted gap: pure indirection ---
|
|
186
|
+
// The target of `$FILE` is unknowable without running the shell. Treating every
|
|
187
|
+
// unresolved expansion as sensitive asked on `cat ~/.zshrc`, `wc -l *.cjs` and
|
|
188
|
+
// `du -sh */` too, so these are allowed by design. `less "$HOME/.env"` is the
|
|
189
|
+
// edge of that decision: a literal secret joined to a variable goes through.
|
|
190
|
+
for (const command of [
|
|
191
|
+
'cat $FILE',
|
|
192
|
+
'cat "$FILE"',
|
|
193
|
+
'cat ${FILE}',
|
|
194
|
+
'cat $1',
|
|
195
|
+
'cat $(echo .env)',
|
|
196
|
+
'cat `echo .env`',
|
|
197
|
+
'head $SECRET',
|
|
198
|
+
'tail ${MY_FILE}',
|
|
199
|
+
'less "$HOME/.env"',
|
|
200
|
+
'cat README.md $EXTRA',
|
|
201
|
+
'echo ok; cat README.md $FILE',
|
|
202
|
+
'cat README.md | head $FILE',
|
|
203
|
+
"bash -c 'cat $FILE'",
|
|
204
|
+
'echo $(cat $FILE)',
|
|
205
|
+
'env -u UNUSED cat "$FILE"',
|
|
206
|
+
'timeout 1 cat "$FILE"',
|
|
207
|
+
'nice -n 10 cat "$FILE"',
|
|
208
|
+
"eval 'cat \"$FILE\"'",
|
|
209
|
+
"find . -exec sh -c 'cat \"$FILE\"' \\;",
|
|
210
|
+
'source "$FILE"',
|
|
211
|
+
'xargs -a "$LIST" cat',
|
|
212
|
+
'python3 -c \'open(os.environ["FILE"]).read()\'',
|
|
213
|
+
'node -e \'fs.readFileSync(process.env.FILE)\'',
|
|
214
|
+
'cat < "$FILE"',
|
|
215
|
+
'cat -n "$FILE"',
|
|
216
|
+
]) {
|
|
217
|
+
test(`allows pure indirection with no sensitive literal: ${command}`, () => {
|
|
218
|
+
const dir = tmpDir();
|
|
219
|
+
try {
|
|
220
|
+
fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1');
|
|
221
|
+
const { code, stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
222
|
+
assert.strictEqual(code, 0);
|
|
223
|
+
assert.strictEqual(permissionDecision(stdout), null, `should allow ${command}`);
|
|
224
|
+
} finally {
|
|
225
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- An inert heredoc body is prose, not shell (fix: e867051) ---
|
|
231
|
+
test('allows a quoted-delimiter heredoc whose body quotes a read command', () => {
|
|
232
|
+
const dir = tmpDir();
|
|
233
|
+
try {
|
|
234
|
+
const command = "gh pr create --body \"$(cat <<'BODY'\nRun `cat .env` to inspect config\nBODY\n)\"";
|
|
235
|
+
const { stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
236
|
+
assert.strictEqual(permissionDecision(stdout), null, 'quoted delimiter expands nothing');
|
|
237
|
+
} finally {
|
|
238
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('still asks for an unquoted-delimiter heredoc that does expand a read', () => {
|
|
243
|
+
const dir = tmpDir();
|
|
244
|
+
try {
|
|
245
|
+
const command = 'bash -c "cat <<BODY\n$(cat .env)\nBODY"';
|
|
246
|
+
const { stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
247
|
+
assert.strictEqual(permissionDecision(stdout), 'ask', 'unquoted delimiter expands');
|
|
248
|
+
} finally {
|
|
249
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
for (const command of [
|
|
254
|
+
'echo $VAR',
|
|
255
|
+
'echo "$HOME"',
|
|
256
|
+
'git status',
|
|
257
|
+
'git status $VAR',
|
|
258
|
+
'cat README.md',
|
|
259
|
+
'cat .env.example',
|
|
260
|
+
'cat .env.sample',
|
|
261
|
+
'cat .env.template',
|
|
262
|
+
'cat .env.test',
|
|
263
|
+
"cat '$FILE'",
|
|
264
|
+
"cat '$HOME/README.md'",
|
|
265
|
+
"cat README.md '$EXTRA'",
|
|
266
|
+
"cat README.md '*'",
|
|
267
|
+
"cat README.md '~'",
|
|
268
|
+
"cat README.md '{README.md,LICENSE}'",
|
|
269
|
+
"echo '$(cat $FILE)'",
|
|
270
|
+
'cat ~/.zshrc',
|
|
271
|
+
'wc -l README.md',
|
|
272
|
+
'cat *.md',
|
|
273
|
+
'du -sh */',
|
|
274
|
+
'python3 -c "print(os.environ[\'HOME\'])"',
|
|
275
|
+
'head -n "$COUNT" README.md',
|
|
276
|
+
]) {
|
|
277
|
+
test(`allows harmless dynamic / safe static with extra var: ${command}`, () => {
|
|
278
|
+
const dir = tmpDir();
|
|
279
|
+
try {
|
|
280
|
+
// Create safe files so hook has cwd context, but path check is lexical
|
|
281
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hi');
|
|
282
|
+
fs.writeFileSync(path.join(dir, '.env.example'), 'EXAMPLE=1');
|
|
283
|
+
const { stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
284
|
+
assert.strictEqual(permissionDecision(stdout), null, `should allow ${command}`);
|
|
285
|
+
} finally {
|
|
286
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
test('an assignment naming a secret does not ask, with or without a later read', () => {
|
|
292
|
+
const dir = tmpDir();
|
|
293
|
+
try {
|
|
294
|
+
const assigned = runHook({ tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat README.md' } }, dir);
|
|
295
|
+
assert.strictEqual(permissionDecision(assigned.stdout), null);
|
|
296
|
+
// Pure indirection: the hook cannot resolve $FILE without running the shell.
|
|
297
|
+
const read = runHook({ tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat "$FILE"' } }, dir);
|
|
298
|
+
assert.strictEqual(permissionDecision(read.stdout), null);
|
|
299
|
+
// Spelling the target out again does ask.
|
|
300
|
+
const literal = runHook({ tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat .env' } }, dir);
|
|
301
|
+
assert.strictEqual(permissionDecision(literal.stdout), 'ask');
|
|
302
|
+
} finally {
|
|
303
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('asks for dangling symlink aimed at protected target (regression: broken-link fail-open)', () => {
|
|
308
|
+
const dir = tmpDir();
|
|
309
|
+
try {
|
|
310
|
+
const env = path.join(dir, '.env');
|
|
311
|
+
fs.writeFileSync(env, 'SECRET=1');
|
|
312
|
+
const link = path.join(dir, 'innocent.txt');
|
|
313
|
+
fs.symlinkSync(env, link);
|
|
314
|
+
fs.unlinkSync(env); // now dangling
|
|
315
|
+
const { stdout } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
|
|
316
|
+
assert.strictEqual(permissionDecision(stdout), 'ask', 'dangling link to .env must ask');
|
|
317
|
+
} finally {
|
|
318
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('allows dangling symlink to benign file and to allowed example', () => {
|
|
323
|
+
const dir = tmpDir();
|
|
324
|
+
try {
|
|
325
|
+
const benign = path.join(dir, 'benign.txt');
|
|
326
|
+
fs.writeFileSync(benign, 'hi');
|
|
327
|
+
const link = path.join(dir, 'dangling.txt');
|
|
328
|
+
fs.symlinkSync(benign, link);
|
|
329
|
+
fs.unlinkSync(benign);
|
|
330
|
+
const { stdout: s1 } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
|
|
331
|
+
assert.strictEqual(permissionDecision(s1), null, 'dangling benign should allow');
|
|
332
|
+
|
|
333
|
+
const example = path.join(dir, '.env.example');
|
|
334
|
+
fs.writeFileSync(example, 'EXAMPLE=1');
|
|
335
|
+
const link2 = path.join(dir, 'link-example.txt');
|
|
336
|
+
fs.symlinkSync(example, link2);
|
|
337
|
+
fs.unlinkSync(example);
|
|
338
|
+
const { stdout: s2 } = runHook({ tool_name: 'Read', tool_input: { file_path: link2 } }, dir);
|
|
339
|
+
assert.strictEqual(permissionDecision(s2), null, 'dangling link to .env.example should allow');
|
|
340
|
+
} finally {
|
|
341
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('still allows real link to allowed example (regression: real link to .env.example)', () => {
|
|
346
|
+
const dir = tmpDir();
|
|
347
|
+
try {
|
|
348
|
+
const example = path.join(dir, '.env.example');
|
|
349
|
+
fs.writeFileSync(example, 'EXAMPLE=1');
|
|
350
|
+
const link = path.join(dir, 'real-example-link.txt');
|
|
351
|
+
fs.symlinkSync(example, link);
|
|
352
|
+
const { stdout } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
|
|
353
|
+
assert.strictEqual(permissionDecision(stdout), null);
|
|
354
|
+
} finally {
|
|
355
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('fails closed on malformed/looping symlink (hop limit)', () => {
|
|
360
|
+
const dir = tmpDir();
|
|
361
|
+
try {
|
|
362
|
+
const a = path.join(dir, 'a');
|
|
363
|
+
const b = path.join(dir, 'b');
|
|
364
|
+
fs.symlinkSync(b, a);
|
|
365
|
+
fs.symlinkSync(a, b);
|
|
366
|
+
const { stdout } = runHook({ tool_name: 'Read', tool_input: { file_path: a } }, dir);
|
|
367
|
+
// Loop should fail closed to ask (treated as sensitive to be safe) or at least not allow silently
|
|
368
|
+
// Our implementation returns ask for looping link
|
|
369
|
+
assert.strictEqual(permissionDecision(stdout), 'ask');
|
|
370
|
+
} finally {
|
|
371
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
372
|
+
}
|
|
373
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
|
|
10
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '../../../..');
|
|
11
|
+
const VALIDATOR = path.join(PACKAGE_ROOT, 'src/claude/scripts/validate-spec-output.cjs');
|
|
12
|
+
const HOOKS = ['claude', 'codex'].map((runtime) => path.join(PACKAGE_ROOT, `src/${runtime}/hooks/semantic-review-authority.cjs`));
|
|
13
|
+
|
|
14
|
+
function fixture(base, name) {
|
|
15
|
+
const root = path.join(base, name);
|
|
16
|
+
const specDir = path.join(root, 'specs', 'feature');
|
|
17
|
+
fs.mkdirSync(specDir, { recursive: true });
|
|
18
|
+
fs.writeFileSync(path.join(specDir, 'requirements.md'), '# Requirements\n\n- **R1.1** The service shall return one result.\n');
|
|
19
|
+
fs.writeFileSync(path.join(specDir, 'design.md'), '# Design\n\n### D1 — Stable result\n\nReturn one stable result.\n');
|
|
20
|
+
fs.writeFileSync(path.join(specDir, 'spec.json'), `${JSON.stringify({ feature_name: 'feature', status: 'in_progress', task_registry: {}, workflow_policy: { assurance_level: 'Strict' } })}\n`);
|
|
21
|
+
return { root, specDir, specFile: path.join(specDir, 'spec.json') };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function installSemanticRuntime(target, runtime) {
|
|
25
|
+
const runtimeRoot = path.join(target.root, runtime === 'claude' ? '.claude' : '.codex');
|
|
26
|
+
const hooks = path.join(runtimeRoot, 'hooks');
|
|
27
|
+
const scripts = path.join(runtimeRoot, 'scripts');
|
|
28
|
+
fs.mkdirSync(path.join(hooks, 'lib'), { recursive: true });
|
|
29
|
+
fs.cpSync(path.join(PACKAGE_ROOT, 'src/claude/scripts'), scripts, { recursive: true });
|
|
30
|
+
for (const name of ['semantic-review-authority.cjs', 'completion-authority-state.cjs']) {
|
|
31
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, `src/${runtime}/hooks`, name), path.join(hooks, name));
|
|
32
|
+
}
|
|
33
|
+
fs.copyFileSync(
|
|
34
|
+
path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-path-safety.cjs'),
|
|
35
|
+
path.join(hooks, 'lib/runtime-path-safety.cjs'),
|
|
36
|
+
);
|
|
37
|
+
return {
|
|
38
|
+
hook: path.join(hooks, 'semantic-review-authority.cjs'),
|
|
39
|
+
validator: path.join(scripts, 'validate-spec-output.cjs'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function envFor(root, home) {
|
|
44
|
+
return { ...process.env, HOME: home, USERPROFILE: home, PROJECT_ROOT: root };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function digest(target, home) {
|
|
48
|
+
const result = spawnSync(process.execPath, [VALIDATOR, target.specDir, '--semantic-digest'], { cwd: target.root, env: envFor(target.root, home), encoding: 'utf8' });
|
|
49
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
50
|
+
return result.stdout.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function claim(target, semanticDigest, overrides = {}) {
|
|
54
|
+
return `CAFEKIT_SEMANTIC_REVIEW_ATTESTATION ${JSON.stringify({ feature_name: 'feature', spec_file: 'specs/feature/spec.json', semantic_digest: semanticDigest, verdict: 'PASS', ...overrides })}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function runHook(hook, target, home, message, event = 'SubagentStop', payload = {}) {
|
|
58
|
+
return spawnSync(process.execPath, [hook], {
|
|
59
|
+
cwd: target.root, env: envFor(target.root, home), encoding: 'utf8',
|
|
60
|
+
input: JSON.stringify({ hook_event_name: event, session_id: 'host-session', agent_id: 'review-agent', agent_type: 'code_auditor', last_assistant_message: message, cwd: target.root, ...payload }),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function verify(hook, target, home, semanticDigest) {
|
|
65
|
+
const script = 'const a=require(process.argv[1]); console.log(JSON.stringify(a.verifyAttestation(process.argv[2],process.argv[3],process.argv[4],process.argv[5])))';
|
|
66
|
+
const result = spawnSync(process.execPath, ['-e', script, hook, target.root, target.specFile, 'feature', semanticDigest], { env: envFor(target.root, home), encoding: 'utf8' });
|
|
67
|
+
assert.equal(result.status, 0, result.stderr);
|
|
68
|
+
return JSON.parse(result.stdout);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function observationFile(home) {
|
|
72
|
+
const files = fs.readdirSync(home, { recursive: true }).filter((entry) => String(entry).endsWith('.json') && String(entry).includes('observation-'));
|
|
73
|
+
assert.equal(files.length, 1);
|
|
74
|
+
return path.join(home, files[0]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test('Claude and Codex observe equivalent allowlisted SubagentStop claims with HMAC integrity', () => {
|
|
78
|
+
for (const hook of HOOKS) {
|
|
79
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-semantic-authority-'));
|
|
80
|
+
try {
|
|
81
|
+
const target = fixture(base, 'project');
|
|
82
|
+
const home = path.join(base, 'home');
|
|
83
|
+
const current = digest(target, home);
|
|
84
|
+
assert.equal(runHook(hook, target, home, claim(target, current), 'SessionStart').stderr, '');
|
|
85
|
+
assert.equal(fs.existsSync(path.join(home, '.cafekit')), false, 'non-SubagentStop event wrote authority state');
|
|
86
|
+
assert.equal(runHook(hook, target, home, 'Ordinary worker finished without a semantic review claim.').stderr, '');
|
|
87
|
+
assert.equal(fs.existsSync(path.join(home, '.cafekit')), false, 'ordinary SubagentStop wrote authority state');
|
|
88
|
+
const recorded = runHook(hook, target, home, claim(target, current));
|
|
89
|
+
assert.equal(recorded.status, 0);
|
|
90
|
+
assert.equal(recorded.stderr, '');
|
|
91
|
+
const observed = verify(hook, target, home, current);
|
|
92
|
+
assert.equal(observed.ok, true);
|
|
93
|
+
assert.equal(observed.record.kind, 'semantic-review-observation');
|
|
94
|
+
assert.equal(observed.record.event, 'SubagentStop');
|
|
95
|
+
|
|
96
|
+
const before = current;
|
|
97
|
+
const spec = JSON.parse(fs.readFileSync(target.specFile, 'utf8'));
|
|
98
|
+
spec.status = 'done';
|
|
99
|
+
spec.updated_at = '2026-08-13T12:00:00+07:00';
|
|
100
|
+
fs.writeFileSync(target.specFile, `${JSON.stringify(spec)}\n`);
|
|
101
|
+
assert.equal(digest(target, home), before);
|
|
102
|
+
assert.equal(verify(hook, target, home, before).ok, true);
|
|
103
|
+
|
|
104
|
+
fs.appendFileSync(path.join(target.specDir, 'requirements.md'), '\nSemantic behavior changed.\n');
|
|
105
|
+
const changed = digest(target, home);
|
|
106
|
+
assert.notEqual(changed, before);
|
|
107
|
+
assert.match(verify(hook, target, home, changed).reason, /stale/);
|
|
108
|
+
} finally { fs.rmSync(base, { recursive: true, force: true }); }
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('observation rejects wrong identity, digest, verdict, tampered MAC, copied record, and self-authored strings', () => {
|
|
113
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-semantic-adversarial-'));
|
|
114
|
+
try {
|
|
115
|
+
const home = path.join(base, 'home');
|
|
116
|
+
const first = fixture(base, 'first');
|
|
117
|
+
const second = fixture(base, 'second');
|
|
118
|
+
const firstDigest = digest(first, home);
|
|
119
|
+
const secondDigest = digest(second, home);
|
|
120
|
+
const hook = HOOKS[0];
|
|
121
|
+
|
|
122
|
+
for (const badClaim of [
|
|
123
|
+
claim(first, `sha256:${'0'.repeat(64)}`),
|
|
124
|
+
claim(first, firstDigest, { verdict: 'FAIL' }),
|
|
125
|
+
claim(first, firstDigest, { feature_name: 'copied-feature' }),
|
|
126
|
+
claim(first, firstDigest, { spec_file: 'specs/missing/spec.json' }),
|
|
127
|
+
]) {
|
|
128
|
+
const result = runHook(hook, first, home, badClaim);
|
|
129
|
+
assert.match(result.stderr, /rejected/);
|
|
130
|
+
}
|
|
131
|
+
assert.equal(verify(hook, first, home, firstDigest).ok, false, 'self-authored claims created authority');
|
|
132
|
+
|
|
133
|
+
assert.equal(runHook(hook, first, home, claim(first, firstDigest)).stderr, '');
|
|
134
|
+
const firstRecordPath = observationFile(home);
|
|
135
|
+
const firstRecord = fs.readFileSync(firstRecordPath);
|
|
136
|
+
assert.equal(runHook(hook, second, home, claim(second, secondDigest)).stderr, '');
|
|
137
|
+
const records = fs.readdirSync(home, { recursive: true }).filter((entry) => String(entry).includes('observation-'));
|
|
138
|
+
assert.equal(records.length, 2);
|
|
139
|
+
const secondRecordPath = records.map((entry) => path.join(home, entry)).find((file) => file !== firstRecordPath);
|
|
140
|
+
fs.writeFileSync(secondRecordPath, firstRecord);
|
|
141
|
+
assert.equal(verify(hook, second, home, secondDigest).ok, false, 'copied project record was accepted');
|
|
142
|
+
|
|
143
|
+
const tampered = JSON.parse(fs.readFileSync(firstRecordPath, 'utf8'));
|
|
144
|
+
tampered.verdict = 'FAIL';
|
|
145
|
+
fs.writeFileSync(firstRecordPath, `${JSON.stringify(tampered)}\n`);
|
|
146
|
+
assert.match(verify(hook, first, home, firstDigest).reason, /MAC|malformed/);
|
|
147
|
+
} finally { fs.rmSync(base, { recursive: true, force: true }); }
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('SubagentStop markers and claimed capabilities cannot turn a non-reviewer into authority', () => {
|
|
151
|
+
for (const hook of HOOKS) {
|
|
152
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-semantic-role-'));
|
|
153
|
+
try {
|
|
154
|
+
const target = fixture(base, 'project');
|
|
155
|
+
const home = path.join(base, 'home');
|
|
156
|
+
const current = digest(target, home);
|
|
157
|
+
for (const payload of [
|
|
158
|
+
{ agent_type: 'implementer' },
|
|
159
|
+
{ agent_type: 'test_runner', capability: 'reviewer' },
|
|
160
|
+
{ agent_type: 'worker', reviewer_capability: true },
|
|
161
|
+
]) {
|
|
162
|
+
const result = runHook(hook, target, home, claim(target, current), 'SubagentStop', payload);
|
|
163
|
+
assert.equal(result.status, 0);
|
|
164
|
+
assert.equal(result.stderr, '');
|
|
165
|
+
assert.equal(verify(hook, target, home, current).ok, false);
|
|
166
|
+
}
|
|
167
|
+
assert.equal(runHook(hook, target, home, claim(target, current), 'SubagentStop', { agent_type: 'code-auditor' }).stderr, '');
|
|
168
|
+
assert.equal(verify(hook, target, home, current).ok, true);
|
|
169
|
+
} finally { fs.rmSync(base, { recursive: true, force: true }); }
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('canonicalizes and contains the validator before semantic digest spawn', () => {
|
|
174
|
+
for (const runtime of ['claude', 'codex']) {
|
|
175
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), `cafekit-semantic-validator-${runtime}-`));
|
|
176
|
+
try {
|
|
177
|
+
const target = fixture(base, 'project');
|
|
178
|
+
const installed = installSemanticRuntime(target, runtime);
|
|
179
|
+
const digestValue = digest(target, path.join(base, 'digest-home'));
|
|
180
|
+
const validHome = path.join(base, 'valid-home');
|
|
181
|
+
const valid = runHook(installed.hook, target, validHome, claim(target, digestValue));
|
|
182
|
+
assert.equal(valid.status, 0);
|
|
183
|
+
assert.equal(valid.stderr, '', `${runtime} contained validator should be accepted`);
|
|
184
|
+
assert.equal(verify(installed.hook, target, validHome, digestValue).ok, true);
|
|
185
|
+
|
|
186
|
+
const outsideValidator = path.join(base, 'outside-validator.cjs');
|
|
187
|
+
fs.copyFileSync(VALIDATOR, outsideValidator);
|
|
188
|
+
fs.rmSync(installed.validator);
|
|
189
|
+
fs.symlinkSync(outsideValidator, installed.validator);
|
|
190
|
+
const escapedHome = path.join(base, 'escaped-home');
|
|
191
|
+
const escaped = runHook(installed.hook, target, escapedHome, claim(target, digestValue));
|
|
192
|
+
assert.equal(escaped.status, 0);
|
|
193
|
+
assert.match(escaped.stderr, /semantic validator|escapes|rejected/i, `${runtime} validator escape must be rejected`);
|
|
194
|
+
assert.equal(verify(installed.hook, target, escapedHome, digestValue).ok, false);
|
|
195
|
+
|
|
196
|
+
fs.rmSync(installed.validator);
|
|
197
|
+
fs.copyFileSync(VALIDATOR, installed.validator);
|
|
198
|
+
const dependency = path.join(path.dirname(installed.validator), 'workflow-policy.cjs');
|
|
199
|
+
const outsideDependency = path.join(base, 'outside-workflow-policy.cjs');
|
|
200
|
+
fs.copyFileSync(dependency, outsideDependency);
|
|
201
|
+
fs.rmSync(dependency);
|
|
202
|
+
fs.symlinkSync(outsideDependency, dependency);
|
|
203
|
+
const dependencyHome = path.join(base, 'dependency-home');
|
|
204
|
+
const dependencyEscape = runHook(installed.hook, target, dependencyHome, claim(target, digestValue));
|
|
205
|
+
assert.equal(dependencyEscape.status, 0);
|
|
206
|
+
assert.match(dependencyEscape.stderr, /dependency|symlink|rejected/i, `${runtime} transitive dependency escape must be rejected`);
|
|
207
|
+
assert.equal(verify(installed.hook, target, dependencyHome, digestValue).ok, false);
|
|
208
|
+
|
|
209
|
+
fs.rmSync(dependency);
|
|
210
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs'), dependency);
|
|
211
|
+
const runtimeRoot = path.dirname(path.dirname(installed.hook));
|
|
212
|
+
const outsideRuntime = path.join(base, `outside-${runtime}`);
|
|
213
|
+
fs.renameSync(runtimeRoot, outsideRuntime);
|
|
214
|
+
fs.symlinkSync(outsideRuntime, runtimeRoot);
|
|
215
|
+
const rootHome = path.join(base, 'root-home');
|
|
216
|
+
const rootEscape = runHook(installed.hook, target, rootHome, claim(target, digestValue));
|
|
217
|
+
assert.equal(rootEscape.status, 0);
|
|
218
|
+
assert.match(rootEscape.stderr, /symlink|identity|rejected/i, `${runtime} adapter-root symlink must be rejected`);
|
|
219
|
+
assert.equal(verify(installed.hook, target, rootHome, digestValue).ok, false);
|
|
220
|
+
} finally { fs.rmSync(base, { recursive: true, force: true }); }
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('rejects scratch/spec.json and traversal feature identities', () => {
|
|
225
|
+
for (const hook of HOOKS) {
|
|
226
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-semantic-scratch-'));
|
|
227
|
+
try {
|
|
228
|
+
const target = fixture(base, 'project');
|
|
229
|
+
const home = path.join(base, 'home');
|
|
230
|
+
const current = digest(target, home);
|
|
231
|
+
const badScratch = claim(target, current, { spec_file: 'scratch/spec.json' });
|
|
232
|
+
const resScratch = runHook(hook, target, home, badScratch);
|
|
233
|
+
assert.match(resScratch.stderr, /rejected|spec identity/);
|
|
234
|
+
assert.equal(verify(hook, target, home, current).ok, false, 'scratch identity should not create observation');
|
|
235
|
+
|
|
236
|
+
const traversal = `CAFEKIT_SEMANTIC_REVIEW_ATTESTATION ${JSON.stringify({ feature_name: '../evil', spec_file: 'specs/../evil/spec.json', semantic_digest: current, verdict: 'PASS' })}`;
|
|
237
|
+
const resTraversal = runHook(hook, target, home, traversal);
|
|
238
|
+
assert.match(resTraversal.stderr, /rejected|malformed/);
|
|
239
|
+
assert.equal(verify(hook, target, home, current).ok, false);
|
|
240
|
+
|
|
241
|
+
const slash = claim(target, current, { feature_name: 'evil/traversal' });
|
|
242
|
+
const resSlash = runHook(hook, target, home, slash);
|
|
243
|
+
assert.match(resSlash.stderr, /rejected|malformed/);
|
|
244
|
+
} finally { fs.rmSync(base, { recursive: true, force: true }); }
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('code-auditor agent ships Strict conditional marker contract', () => {
|
|
249
|
+
const agentPath = path.join(PACKAGE_ROOT, 'src/claude/agents/code-auditor.md');
|
|
250
|
+
const content = fs.readFileSync(agentPath, 'utf8');
|
|
251
|
+
assert.match(content, /Strict Semantic Review Attestation/);
|
|
252
|
+
assert.match(content, /CAFEKIT_SEMANTIC_REVIEW_ATTESTATION/);
|
|
253
|
+
assert.match(content, /Emit only for `Strict`/);
|
|
254
|
+
assert.match(content, /specs\/<feature>\/spec\.json/);
|
|
255
|
+
assert.match(content, /MAC-protected host-hook observation/);
|
|
256
|
+
assert.doesNotMatch(content, /host-signed|proves causal/);
|
|
257
|
+
});
|