Files
gstack/test/autoplan-publication-payload.test.ts
Garry Tanandcapy-ai[bot] 5de607f26c v1.91.170.0 fix(autoplan): guard waits for an unflushed journal; plan floor harness reads cropped menus; office-hours reviewer recovers with Write (GSTA-57) (#3326)
* 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>
2026-10-20 19:02:57 +00:00

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);
});