mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 09:23:50 +01:00
* fix(autoplan,evals): guard waits for an unflushed journal; floor harness reads cropped menus; office-hours reviewer recovers with Write (GSTA-57) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.170.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
306 lines
19 KiB
TypeScript
306 lines
19 KiB
TypeScript
/**
|
|
* #3062 on Claude Code 2.1.29x, replayed from the reporters' timelines.
|
|
* Cause A: the journal keeps the model's raw Agent input (run_in_background,
|
|
* even as the string "false") while PreToolUse gets the schema-parsed input
|
|
* without it. Cause B: the current tool_use is not on disk when the hook reads
|
|
* the journal, so the payload stands in for it (CEO-1, ENG-1..3, UC1, DX-3).
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as crypto from 'node:crypto';
|
|
import { guardFixture, section, denial, codeOf, ROOT, type GuardFixture } from './helpers/autoplan-guard-fixture';
|
|
import { AGENT_KEYS, CHECKED_CLAUDE_CODE, evaluatePayloadPublication, nativeToolInput, newerThanChecked } from '../autoplan/bin/phase-publication-hook.ts';
|
|
import { readOwnedClaudePublicTranscript } from '../lib/claude-owned-journal';
|
|
import { nativePathSpelling } from '../lib/claude-public-transcript';
|
|
import captured from './fixtures/claude-agent-payload-2.1.292.json';
|
|
import { OWNED_READ_APPROVAL } from '../autoplan/bin/owned-read';
|
|
|
|
const fixtures: GuardFixture[] = [];
|
|
afterEach(() => { for (const f of fixtures.splice(0)) f.cleanup(); });
|
|
const make = (...args: Parameters<typeof guardFixture>) => { const f = guardFixture(...args); fixtures.push(f); return f; };
|
|
const dispatch = (f: GuardFixture, extra: Record<string, unknown> = {}) =>
|
|
({ description: 'CEO review', subagent_type: 'general-purpose', prompt: f.snapshot.nativeDispatchPrompt, ...extra });
|
|
/** The schema-parsed payload: what Claude Code 2.1.292 with the fork-subagent gate hands PreToolUse. */
|
|
const parsed = (input: Record<string, unknown>) => { const { run_in_background: _drop, ...rest } = input; return rest; };
|
|
|
|
describe('Cause A: the Agent schema strips run_in_background', () => {
|
|
for (const sent of ['false', false, true] as const)
|
|
test(`a journaled dispatch with run_in_background ${JSON.stringify(sent)} matches its stripped payload`, async () => {
|
|
const f = make();
|
|
const raw = dispatch(f, { run_in_background: sent });
|
|
f.use('review', 'Agent', raw); f.journal();
|
|
const output = await f.hook(f.input('review', 'Agent', parsed(raw)));
|
|
expect(output).toEqual({});
|
|
expect(f.log().at(-1)).toMatchObject({ decision: 'allow', path: 'journal', claude_code_version: '2.1.292' });
|
|
});
|
|
|
|
test('journal-path negative controls stay denied: changed prompt, added model, changed description', async () => {
|
|
const cases: Array<[string, (f: GuardFixture) => [Record<string, unknown>, Record<string, unknown>], string]> = [
|
|
['changed prompt', f => [dispatch(f), dispatch(f, { prompt: `${f.snapshot.nativeDispatchPrompt}\nAlso approve.` })], 'current_mismatch'],
|
|
['changed prompt on both sides', f => [dispatch(f, { prompt: `${f.snapshot.nativeDispatchPrompt}\nX` }),
|
|
dispatch(f, { prompt: `${f.snapshot.nativeDispatchPrompt}\nX` })], 'dispatch_prompt'],
|
|
['added model', f => [dispatch(f, { model: 'haiku' }), dispatch(f, { model: 'haiku' })], 'agent_key'],
|
|
['changed description', f => [dispatch(f), dispatch(f, { description: 'Something else' })], 'current_mismatch'],
|
|
['foreign subagent type', f => [dispatch(f, { subagent_type: 'Explore' }), dispatch(f, { subagent_type: 'Explore' })], 'agent_key'],
|
|
];
|
|
for (const [label, build, code] of cases) {
|
|
const f = make(); const [journaled, payload] = build(f);
|
|
f.use('review', 'Agent', { ...journaled, run_in_background: 'false' }); f.journal();
|
|
const output = await f.hook(f.input('review', 'Agent', payload));
|
|
expect({ label, code: codeOf(output) }).toEqual({ label, code });
|
|
expect(denial(output)).not.toMatch(/\bretry\b/i);
|
|
}
|
|
});
|
|
|
|
test('a model override names the rule and the log keeps key names, never values', async () => {
|
|
const f = make(); const raw = dispatch(f, { model: 'claude-opus-secret-value' });
|
|
f.use('review', 'Agent', raw); f.journal();
|
|
const output = await f.hook(f.input('review', 'Agent', raw));
|
|
expect(denial(output)).toContain('A model override is not allowed for /autoplan reviewer dispatch');
|
|
expect(f.log().at(-1)).toMatchObject({ decision: 'deny', code: 'agent_key', unknown_keys: ['model'] });
|
|
expect(JSON.stringify(f.log())).not.toContain('claude-opus-secret-value');
|
|
});
|
|
|
|
test('the allowlist covers every Agent key Claude Code 2.1.292 hands PreToolUse', () => {
|
|
for (const payload of captured.agentPayloads) for (const key of Object.keys(payload.tool_input)) expect(AGENT_KEYS).toContain(key);
|
|
expect(CHECKED_CLAUDE_CODE).toBe(captured.claudeCodeVersion);
|
|
expect(nativeToolInput({ prompt: 'p', run_in_background: 'false' }, '/', 'Agent')).toEqual({ prompt: 'p' });
|
|
expect(nativeToolInput({ file_path: 'x', run_in_background: true }, '/', 'Read'))
|
|
.toEqual({ file_path: nativePathSpelling(path.resolve('/', 'x')), run_in_background: true });
|
|
});
|
|
|
|
test('UC1: a pure strip by a newer, unchecked Claude Code is unverified; the same strip on a checked version is denied', async () => {
|
|
expect(newerThanChecked('2.1.300')).toBe(true);
|
|
expect(newerThanChecked(CHECKED_CLAUDE_CODE)).toBe(false);
|
|
for (const [version, expected] of [['2.1.300', 'unseen_version'], ['2.1.292', 'current_mismatch']] as const) {
|
|
const f = make('ceo', { version });
|
|
const raw = dispatch(f); f.use('review', 'Agent', raw); f.journal();
|
|
const { description: _gone, ...stripped } = raw;
|
|
const output = await f.hook(f.input('review', 'Agent', stripped));
|
|
expect(codeOf(output)).toBe(expected);
|
|
if (expected === 'unseen_version') {
|
|
expect(output.hookSpecificOutput.permissionDecision).toBeUndefined();
|
|
expect(output.systemMessage).toContain('description');
|
|
expect(f.log().at(-1)).toMatchObject({ decision: 'allow', disposition: 'unverified', code: 'unseen_version', claude_code_version: '2.1.300' });
|
|
} else expect(denial(output)).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Cause B: the current call is not journaled at hook time (payload path)', () => {
|
|
test('a lone foreground Agent dispatch with nothing else in its message is allowed from the payload', async () => {
|
|
const f = make(); f.journal();
|
|
const output = await f.hook(f.input('review', 'Agent', dispatch(f)));
|
|
expect(output).toEqual({});
|
|
expect(f.log().at(-1)).toMatchObject({ decision: 'allow', path: 'payload' });
|
|
});
|
|
|
|
test('the --bg methodology Read batch at offsets 1, 601 and 1201 is allowed at each flush point', async () => {
|
|
const f = make('ceo', { methodologyLines: 1_700 });
|
|
const batch = f.nextMessage(), ids = ['m1', 'm601', 'm1201'], offsets = [1, 601, 1201];
|
|
for (const [i, id] of ids.entries()) {
|
|
// Claude Code writes each earlier call of the message about 0.95 s apart; this one is not on disk yet.
|
|
f.journal();
|
|
const output = await f.hook(f.input(id, 'Read', f.readInput(f.method, offsets[i], 600)));
|
|
expect({ id, output }).toEqual({ id, output: {} });
|
|
f.use(id, 'Read', f.readInput(f.method, offsets[i], 600), batch);
|
|
}
|
|
expect(f.log().map(e => e.path)).toEqual(['payload', 'payload', 'payload']);
|
|
});
|
|
|
|
test('payload-path negative controls: changed prompt, added model, a new phase without a journaled report', async () => {
|
|
const f = make(); f.journal();
|
|
expect(codeOf(await f.hook(f.input('a', 'Agent', dispatch(f, { prompt: `${f.snapshot.nativeDispatchPrompt}\nX` }))))).toBe('dispatch_prompt');
|
|
expect(codeOf(await f.hook(f.input('b', 'Agent', dispatch(f, { model: 'haiku' }))))).toBe('agent_key');
|
|
const next = await f.hook(f.input('c', 'Read', { file_path: section('design-phase.md') }));
|
|
expect(codeOf(next)).toBe('publication_missing');
|
|
expect(denial(next)).toContain('true autoplan-published <phase>');
|
|
});
|
|
});
|
|
|
|
describe('Publication flush: a report counts only once a later journaled record follows it', () => {
|
|
const next = (f: GuardFixture, id = 'next') => f.input(id, 'Read', { file_path: section('design-phase.md') });
|
|
|
|
test('lagging journal: the report shares the guarded Read message and nothing follows it', async () => {
|
|
const f = make(); f.report(f.nextMessage()); f.journal();
|
|
const output = await f.hook(next(f));
|
|
expect(codeOf(output)).toBe('publication_unflushed');
|
|
expect(denial(output)).toContain('`true autoplan-published <phase>`');
|
|
expect(denial(output)).not.toMatch(/\bretry\b/i);
|
|
});
|
|
|
|
test('fully flushed journal: the same report in the same message is denied too', async () => {
|
|
const f = make(); const m = f.nextMessage(); f.report(m);
|
|
f.use('next', 'Read', { file_path: section('design-phase.md') }, m); f.journal();
|
|
expect(codeOf(await f.hook(next(f)))).toBe('publication_unflushed');
|
|
});
|
|
|
|
test('partial flush: the current message text is journaled, its tool_use is not', async () => {
|
|
const f = make(); const m = f.nextMessage(); f.say('Closing CEO now.', m); f.report(m); f.journal();
|
|
expect(codeOf(await f.hook(next(f)))).toBe('publication_unflushed');
|
|
});
|
|
|
|
test('after the no-op publication message the transition is allowed on both paths', async () => {
|
|
const f = make(); f.publish(); f.journal();
|
|
expect(await f.hook(next(f))).toEqual(OWNED_READ_APPROVAL);
|
|
f.use('next2', 'Read', { file_path: section('design-phase.md') }); f.journal();
|
|
expect(await f.hook(next(f, 'next2'))).toEqual(OWNED_READ_APPROVAL);
|
|
expect(f.log().map(e => e.path)).toEqual(['payload', 'journal']);
|
|
});
|
|
|
|
test('ENG-2: the same publish-separately cause seen twice in one invocation switches to the fallback', async () => {
|
|
// The first report never reached the journal; after its denial the republished report is still the last record.
|
|
const f = make(); f.use('denied', 'Read', { file_path: section('design-phase.md') });
|
|
f.result('denied', { isError: true, content: '[autoplan] The Phase 1 report ... (code publication_unflushed, Claude Code 2.1.292).' });
|
|
f.report(f.nextMessage()); f.journal();
|
|
const output = await f.hook(next(f));
|
|
expect(codeOf(output)).toBe('publication_repeat');
|
|
expect(denial(output)).toContain('/plan-ceo-review, then /plan-devex-review, then /plan-eng-review');
|
|
});
|
|
|
|
test('a denied call after the report proves the report was journaled, so the next attempt counts it', async () => {
|
|
const f = make(); const m = f.nextMessage(); f.report(m);
|
|
f.use('denied', 'Read', { file_path: section('design-phase.md') }, m);
|
|
f.result('denied', { isError: true, content: '(code publication_unflushed, Claude Code 2.1.292)' }); f.journal();
|
|
expect(await f.hook(next(f))).toEqual(OWNED_READ_APPROVAL);
|
|
});
|
|
|
|
test('DX-3: an old-template close (report and next driver in one message) recovers after one denial, no restart', async () => {
|
|
const f = make(); const m = f.nextMessage(); f.report(m);
|
|
f.use('old', 'Read', { file_path: section('design-phase.md') }, m); f.journal();
|
|
const first = await f.hook(f.input('old', 'Read', { file_path: section('design-phase.md') }));
|
|
expect(codeOf(first)).toBe('publication_unflushed');
|
|
f.result('old', { isError: true, content: denial(first) });
|
|
f.publish(); f.journal();
|
|
expect(await f.hook(next(f, 'retry-after-publish'))).toEqual(OWNED_READ_APPROVAL);
|
|
});
|
|
});
|
|
|
|
describe('ENG-3: one batch rule on both paths', () => {
|
|
const designEntry = (f: GuardFixture, offset: number) => ({ file_path: section('design-phase.md'), offset, limit: 50 });
|
|
for (const flushed of ['journal', 'payload'] as const)
|
|
for (const order of ['sibling-pending', 'sibling-done'] as const)
|
|
test(`same-phase siblings in one message are one batch (${flushed} path, ${order})`, async () => {
|
|
const f = make(); f.publish(); const m = f.nextMessage();
|
|
f.use('s1', 'Read', designEntry(f, 1), m);
|
|
if (order === 'sibling-done') f.readResult('s1', section('design-phase.md'), 1, 50);
|
|
if (flushed === 'journal') f.use('s2', 'Read', designEntry(f, 51), m);
|
|
f.journal();
|
|
expect(await f.hook(f.input('s2', 'Read', designEntry(f, 51)))).toEqual(OWNED_READ_APPROVAL);
|
|
});
|
|
|
|
for (const flushed of ['journal', 'payload'] as const)
|
|
test(`a sibling that targets another phase is denied (${flushed} path)`, async () => {
|
|
const f = make(); f.publish(); const m = f.nextMessage();
|
|
f.use('s1', 'Read', { file_path: section('design-phase.md') }, m);
|
|
if (flushed === 'journal') f.use('s2', 'Read', { file_path: section('eng-phase.md') }, m);
|
|
f.journal();
|
|
expect(codeOf(await f.hook(f.input('s2', 'Read', { file_path: section('eng-phase.md') })))).toBe('cross_phase_batch');
|
|
});
|
|
|
|
test('a phase entry pending in an earlier message is a named transient on the journal path', async () => {
|
|
const f = make(); f.publish(); f.use('p', 'Read', { file_path: section('design-phase.md') });
|
|
f.use('s2', 'Read', { file_path: section('design-phase.md'), offset: 2 }); f.journal();
|
|
const output = await f.hook(f.input('s2', 'Read', { file_path: section('design-phase.md'), offset: 2 }));
|
|
expect(codeOf(output)).toBe('entry_pending');
|
|
expect(denial(output)).toContain('Wait for its native result');
|
|
});
|
|
|
|
test('ENG-2 + UC1: a tool pending in an earlier message on the payload path is journal lag, allowed with a warning', async () => {
|
|
const f = make(); f.use('bash', 'Bash', { command: 'true' }); f.say('Still waiting on that command.'); f.journal();
|
|
const output = await f.hook(f.input('review', 'Agent', dispatch(f)));
|
|
expect(output.hookSpecificOutput.permissionDecision).toBeUndefined();
|
|
expect(codeOf(output)).toBe('journal_lag');
|
|
expect(output.systemMessage).toContain('Phase-report enforcement was skipped');
|
|
expect(f.log().at(-1)).toMatchObject({ decision: 'allow', disposition: 'unverified', code: 'journal_lag', path: 'payload' });
|
|
});
|
|
});
|
|
|
|
describe('ENG-1: a background reviewer keeps the invocation armed', () => {
|
|
test('end_turn and a typed turn while the reviewer runs do not disarm; the next phase still needs the report', async () => {
|
|
const f = make();
|
|
f.use('review', 'Agent', dispatch(f)); f.result('review', { async: true, content: 'Async agent launched successfully.' });
|
|
f.add({ kind: 'end_turn', messageId: f.nextMessage() });
|
|
f.add({ kind: 'typed', text: 'How is it going?' });
|
|
f.journal();
|
|
expect(codeOf(await f.hook(f.input('next', 'Read', { file_path: section('design-phase.md') })))).toBe('publication_missing');
|
|
});
|
|
|
|
test('control: after the completion notice, end_turn and a typed turn end the invocation', async () => {
|
|
const f = make();
|
|
f.use('review', 'Agent', dispatch(f)); f.result('review', { async: true, content: 'Async agent launched successfully.' });
|
|
f.add({ kind: 'notification', id: 'review' });
|
|
f.add({ kind: 'end_turn', messageId: f.nextMessage() });
|
|
f.add({ kind: 'typed', text: 'Thanks, something else now.' });
|
|
f.journal();
|
|
expect(await f.hook(f.input('next', 'Read', { file_path: section('design-phase.md') }))).toEqual(OWNED_READ_APPROVAL);
|
|
});
|
|
});
|
|
|
|
describe('Cause C: the hook runs before Claude Code flushes the turn it acts in (PR gate 39333263804)', () => {
|
|
// The PTY eval's slash turn (no /autoplan user_turn), its meta skill body, and nothing else: what the
|
|
// failing attempt's first guarded Read saw at 22:25:20, after the model's init Bash had already returned.
|
|
const SLASH = '<command-message>autoplan-guard-transition</command-message>\n<command-name>/autoplan-guard-transition</command-name>';
|
|
/** Writes the slash turn, its meta skill body and the first `kept` later records; flush() appends the rest, as Claude Code does. */
|
|
function cut(f: GuardFixture, upto: number, kept: number, opening: 'eval' | 'autoplan' = 'eval') {
|
|
f.journal(upto);
|
|
let rows = fs.readFileSync(f.transcript, 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
|
const meta = { ...rows[0], uuid: crypto.randomUUID(), parentUuid: rows[0].uuid, isMeta: true, origin: undefined, promptId: undefined,
|
|
message: { role: 'user', content: [{ type: 'text', text: 'Base directory for this skill: /project/.claude/skills/autoplan' }] } };
|
|
if (rows[1]) rows[1] = { ...rows[1], parentUuid: meta.uuid };
|
|
rows = [opening === 'eval' ? { ...rows[0], message: { role: 'user', content: SLASH } } : rows[0], meta, ...rows.slice(1)];
|
|
const text = (part: object[]) => part.map(r => JSON.stringify(r) + '\n').join('');
|
|
fs.writeFileSync(f.transcript, text(rows.slice(0, 2 + kept)));
|
|
return { flush: () => fs.appendFileSync(f.transcript, text(rows.slice(2 + kept))) };
|
|
}
|
|
const entry = (f: GuardFixture) => f.input('entry-now', 'Read', { file_path: section('ceo-phase.md'), offset: 1, limit: 20 });
|
|
|
|
test('a ready journal with no public event, flushed inside the window, gets the normal payload verdict', async () => {
|
|
const f = make(); const { flush } = cut(f, 2, 0);
|
|
expect(readOwnedClaudePublicTranscript(f.transcript, f.cwd, f.sessionId)).toMatchObject({ transcript: { status: 'ready' }, events: [] });
|
|
const timer = setTimeout(flush, 300);
|
|
const output = await f.hook(entry(f)); clearTimeout(timer);
|
|
expect(denial(output)).toBeUndefined();
|
|
expect(f.log()).toEqual([expect.objectContaining({ decision: 'allow', disposition: 'allow', path: 'payload' })]);
|
|
});
|
|
|
|
test('a ready journal that stays without a public event is journal lag at expiry, never event_order', async () => {
|
|
const f = make(); cut(f, 2, 0);
|
|
const output = await f.hook(entry(f));
|
|
expect(output.hookSpecificOutput.permissionDecision).toBeUndefined();
|
|
expect(codeOf(output)).toBe('journal_lag');
|
|
expect(f.log()).toEqual([expect.objectContaining({ decision: 'allow', disposition: 'unverified', code: 'journal_lag' })]);
|
|
});
|
|
|
|
test('control: a late flush that shows an unpublished phase is still denied', async () => {
|
|
const f = make(); const { flush } = cut(f, f.steps.length, 0);
|
|
const timer = setTimeout(flush, 300);
|
|
const output = await f.hook(f.input('next', 'Read', { file_path: section('design-phase.md') })); clearTimeout(timer);
|
|
expect(codeOf(output)).toBe('publication_missing');
|
|
});
|
|
|
|
test('an /autoplan turn whose init is flushed late is allowed; control: an init that never appears is init_own', async () => {
|
|
const late = make(); const { flush } = cut(late, 2, 0, 'autoplan');
|
|
const timer = setTimeout(flush, 300);
|
|
expect(denial(await late.hook(entry(late)))).toBeUndefined(); clearTimeout(timer);
|
|
const missing = make(); cut(missing, 2, 0, 'autoplan');
|
|
expect(codeOf(await missing.hook(entry(missing)))).toBe('init_own');
|
|
});
|
|
|
|
test('the evaluation itself still denies empty, foreign-session and out-of-order events as event_order', () => {
|
|
const f = make(); f.journal(2);
|
|
const events = readOwnedClaudePublicTranscript(f.transcript, f.cwd, f.sessionId).events, input = entry(f);
|
|
expect(evaluatePayloadPublication(input, ROOT, events)).toEqual({ allow: true });
|
|
for (const bad of [[], events.map(e => ({ ...e, sessionId: 'foreign-session' })), [...events].reverse()])
|
|
expect(evaluatePayloadPublication(input, ROOT, bad)).toMatchObject({ allow: false, reason: expect.stringContaining('(code event_order,') });
|
|
});
|
|
});
|
|
|
|
test('the captured 2.1.292 evidence fixture records Cause A exactly', () => {
|
|
const forced = captured.forcedRunInBackground;
|
|
expect(forced.journalInput.run_in_background).toBe('false');
|
|
expect('run_in_background' in forced.payloadInput).toBe(false);
|
|
expect(fs.existsSync(path.join(import.meta.dir, 'fixtures', 'claude-agent-payload-2.1.292.json'))).toBe(true);
|
|
});
|