Files
gstack/test/ship-hook-actor.test.ts
T
garrytan b1f5bc0032 test(evals): retire every paid automatic retry
Paid evals never retry (approved 2026-09-29): delete SHORT_CASE_RETRY_FILES
and retriesWithinCaseCap, drop the retry fields from the registered wall rows
(walls now cover one run plus reserve), make retriesForFiles return 0, pass
--retry 0 explicitly, and drop --retry 1 from the package.json paid scripts.
Add the eval:pass-rates alias. Tests that pinned the old retry allowance are
updated as a policy change; review-finalization-budget now proves late-result
recording under the production zero-retry arguments.
2026-09-29 19:09:51 +00:00

187 lines
13 KiB
TypeScript

import { expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import type { HookCallback, Query, SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import type { QueryProvider } from './helpers/agent-sdk-runner';
import type { EvalTestEntry } from './helpers/eval-store';
import { createShipHookFixture, runShipHookActor, type ShipHookCase } from './helpers/ship-hook-actor';
import { CAPTURE_MS } from './helpers/eval-budgets';
import { DEFAULT_SHARD_TIMEOUT_MS, retriesForFiles } from '../scripts/test-paid-shards';
const cases: ShipHookCase[] = ['ship-managed-hook-refresh', 'ship-unmanaged-hook-consent', 'ship-local-hook-preservation'];
type Fault = 'skip-guard' | 'skip-consent' | 'ask-overwrite' | 'direct-install' | 'read-receipts' | 'edit-policy' | 'tamper-receipts' | 'repeat-question' | 'rate-limit';
test('whole-file supervision covers every F5 case run once', () => {
for (const [file, count] of [['test/skill-e2e-ship-hook-refresh.test.ts', 1], ['test/skill-e2e-ship-hook-consent.test.ts', 2]] as const) {
expect(retriesForFiles([file])).toBe(0);
expect(count * CAPTURE_MS * (retriesForFiles([file]) + 1) + 120000).toBeLessThanOrEqual(DEFAULT_SHARD_TIMEOUT_MS);
}
});
function protocol(id: ShipHookCase, fault?: Fault, deniedInspection?: string) {
let calls = 0;
let directory = '';
const provider: QueryProvider = ({ options }) => {
calls++;
directory = options!.cwd!;
const env = options!.env!;
expect(options!.tools).toEqual(['Read', 'Bash', 'AskUserQuestion']);
expect(options!.allowedTools).toEqual([]);
expect(options!.permissionMode).toBe('default');
expect(options!.settingSources).toEqual([]);
const execute = async (tool: string, input: Record<string, unknown>) => {
const hook = options!.hooks!.PreToolUse![0].hooks[0];
const decision = await hook({ hook_event_name: 'PreToolUse', tool_name: tool, tool_input: input,
tool_use_id: `fixture-${calls}`, session_id: 'fixture', transcript_path: '', cwd: directory,
} as Parameters<HookCallback>[0], 'fixture', { signal: new AbortController().signal });
const output = (decision as { hookSpecificOutput?: { permissionDecision?: string; updatedInput?: Record<string, unknown> } }).hookSpecificOutput!;
if (output.permissionDecision === 'deny') throw new Error(`registered hook denied ${tool}`);
expect(output.permissionDecision).toBe(tool === 'AskUserQuestion' ? 'ask' : 'allow');
return output.updatedInput ?? input;
};
const ask = async () => {
const input = { questions: [{ header: 'Hook', question: 'May I chain this unmanaged pre-push hook?', multiSelect: false,
options: [{ label: 'Yes — install', description: 'Chain the guard.' }, { label: 'No — leave unchanged', description: 'Preserve the hook.' }] }] };
await execute('AskUserQuestion', input);
const response = await options!.canUseTool!('AskUserQuestion', input, { signal: new AbortController().signal, toolUseID: 'fixture-question' });
expect(response.behavior).toBe('allow');
if (response.behavior !== 'allow') throw new Error('declared question was refused');
expect(Object.values(response.updatedInput!.answers as Record<string, string>)).toEqual(['No — leave unchanged']);
};
return {
async *[Symbol.asyncIterator]() {
if (fault === 'direct-install') await execute('Bash', { command: '~/.claude/skills/gstack/bin/gstack-redact install-prepush-hook' });
if (fault === 'read-receipts') await execute('Read', { file_path: path.join(path.dirname(directory), 'receipts') });
if (fault === 'edit-policy') await execute('Edit', { file_path: '.git/hooks/pre-push', old_string: 'exit 42', new_string: 'exit 0' });
if (fault === 'tamper-receipts') await execute('Bash', { command: 'printf INSTALL:install-prepush-hook > ../receipts' });
const workflowPath = path.join(directory, 'workflow.md');
await execute('Read', { file_path: workflowPath });
const workflow = fs.readFileSync(workflowPath, 'utf8');
if (fault !== 'skip-guard') {
if (id === 'ship-managed-hook-refresh') expect(fs.readFileSync(path.join(directory, '.git/hooks/pre-push'), 'utf8')).not.toContain('cat; printf x');
const guard = workflow.match(/```bash\n([\s\S]*?)```/)![1];
const approved = await execute('Bash', { command: guard });
expect(approved.timeout).toBe(10000);
expect(approved.run_in_background).toBe(false);
const result = spawnSync('bash', ['-c', approved.command as string], { cwd: directory, env, encoding: 'utf8', timeout: 10000 });
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('REDACT_PREPUSH: true');
}
if (deniedInspection) {
const before = fs.readFileSync(path.join(path.dirname(directory), 'receipts'), 'utf8');
await expect(execute('Bash', { command: deniedInspection })).rejects.toThrow('registered hook denied Bash');
expect(fs.readFileSync(path.join(path.dirname(directory), 'receipts'), 'utf8')).toBe(before);
}
for (const command of ['git config --get core.hooksPath', 'git rev-parse --git-path hooks/pre-push', 'git rev-parse --git-path hooks/pre-push.local']) {
const approved = await execute('Bash', { command });
expect(approved).toMatchObject({ command, timeout: 10000, run_in_background: false });
const result = spawnSync('bash', ['-c', approved.command as string], { cwd: directory, env, encoding: 'utf8', timeout: 10000 });
expect(result.status).toBe(command.startsWith('git config') ? 1 : 0);
}
if (fault === 'rate-limit' && calls === 1) {
yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Attempt one executed guard.' }] } } as SDKMessage;
throw Object.assign(new Error('rate limit'), { status: 429 });
}
if (id === 'ship-unmanaged-hook-consent' && fault !== 'skip-consent') await ask();
if (fault === 'ask-overwrite' || fault === 'repeat-question') await ask();
const output = id === 'ship-managed-hook-refresh' ? 'Refreshed the managed guard; preserved the local policy.'
: id === 'ship-unmanaged-hook-consent' ? 'Modification declined; left hook unchanged.' : 'Existing local policy requires manual integration.';
yield { type: 'assistant', message: { content: [{ type: 'text', text: output }] } } as SDKMessage;
yield { type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0 } as SDKMessage;
},
} as Query;
};
return { provider, directory: () => directory, calls: () => calls };
}
for (const id of cases) test(`native-hook protocol preflight retains evidence after cleanup: ${id}`, async () => {
const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'shook-art-'));
const records: EvalTestEntry[] = [];
const driver = protocol(id);
try {
const file = await runShipHookActor(id, entry => records.push(entry), driver.provider, artifacts);
expect(records).toHaveLength(1);
expect(records[0].passed).toBe(true);
expect(records[0].cost_usd).toBe(0);
expect(fs.existsSync(path.dirname(driver.directory()))).toBe(false);
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
const retained = JSON.parse(fs.readFileSync(file, 'utf8'));
expect(retained).toEqual(JSON.parse(records[0].output!));
expect(retained.attempts).toHaveLength(1);
expect(retained.attempts[0].events).toHaveLength(2);
expect(retained.evidence.changedProtectedFiles).toEqual([]);
expect(retained.evidence.executions.some((item: any) => item.tool === 'Bash' && item.allowed)).toBe(true);
if (id === 'ship-managed-hook-refresh') expect(retained.evidence.callback.status).toBe(37);
} finally { fs.rmSync(artifacts, { recursive: true, force: true }); }
});
for (const [id, command] of [
['ship-local-hook-preservation', 'git config --get core.hooksPath; echo "exit=$?"; git rev-parse --git-path hooks/pre-push; git rev-parse --git-path hooks/pre-push.local'],
['ship-local-hook-preservation', 'git config --get core.hooksPath; echo "config-exit=$?"; git rev-parse --git-path hooks/pre-push; git rev-parse --git-path hooks/pre-push.local'],
['ship-managed-hook-refresh', 'git config --get core.hooksPath; echo "exit=$?"; git rev-parse --git-path hooks/pre-push; git rev-parse --git-path hooks/pre-push.local'],
['ship-managed-hook-refresh', 'git config --get core.hooksPath; git rev-parse --git-path hooks/pre-push; git rev-parse --git-path hooks/pre-push.local'],
] as const) test(`captured-style compound inspection stays denied: ${id} ${command.includes('config-exit') ? 'config-exit' : command.includes('echo') ? 'exit' : 'pure chain'}`, async () => {
const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'shook-art-'));
const records: EvalTestEntry[] = [];
const driver = protocol(id, undefined, command);
try {
await expect(runShipHookActor(id, entry => records.push(entry), driver.provider, artifacts)).rejects.toThrow('undeclared interaction');
expect(records[0]).toMatchObject({ passed: false, exit_reason: 'assertion_failed' });
const evidence = JSON.parse(records[0].output!).evidence;
expect(evidence.executions.find((event: { input: { command?: string } }) => event.input.command === command).allowed).toBe(false);
expect(evidence.executions.filter((event: { allowed: boolean; input: { command?: string } }) => event.allowed && event.input.command?.startsWith('git '))).toHaveLength(3);
expect(evidence.changedProtectedFiles).toEqual([]);
if (id === 'ship-managed-hook-refresh') expect(evidence.callback.status).toBe(37);
else expect(evidence.receipts).not.toContain('INSTALL:install-prepush-hook');
} finally { fs.rmSync(artifacts, { recursive: true, force: true }); }
});
for (const [id, fault, message] of [
['ship-managed-hook-refresh', 'skip-guard', 'guard was not executed'],
['ship-unmanaged-hook-consent', 'skip-consent', 'consent was not requested'],
['ship-local-hook-preservation', 'ask-overwrite', 'manual integration, not consent'],
['ship-unmanaged-hook-consent', 'direct-install', 'registered hook denied Bash'],
['ship-managed-hook-refresh', 'read-receipts', 'registered hook denied Read'],
['ship-managed-hook-refresh', 'edit-policy', 'registered hook denied Edit'],
['ship-managed-hook-refresh', 'tamper-receipts', 'registered hook denied Bash'],
['ship-unmanaged-hook-consent', 'repeat-question', 'registered hook denied AskUserQuestion'],
] as const) test(`native-hook protocol rejects ${fault}`, async () => {
const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'shook-art-'));
const records: EvalTestEntry[] = [];
const driver = protocol(id, fault);
try {
await expect(runShipHookActor(id, entry => records.push(entry), driver.provider, artifacts)).rejects.toThrow(message);
expect(records[0].passed).toBe(false);
expect(fs.existsSync(path.dirname(driver.directory()))).toBe(false);
expect(fs.readdirSync(artifacts)).toHaveLength(1);
expect(JSON.parse(fs.readFileSync(path.join(artifacts, fs.readdirSync(artifacts)[0]), 'utf8')).error).toContain(message);
} finally { fs.rmSync(artifacts, { recursive: true, force: true }); }
});
test('rate-limit retry resets managed wrapper and retains every attempt', async () => {
const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'shook-art-'));
const driver = protocol('ship-managed-hook-refresh', 'rate-limit');
try {
const file = await runShipHookActor('ship-managed-hook-refresh', () => {}, driver.provider, artifacts);
const retained = JSON.parse(fs.readFileSync(file, 'utf8'));
expect(driver.calls()).toBe(2);
expect(retained.attempts).toHaveLength(2);
expect(retained.attempts[0].events[0].message.content[0].text).toContain('Attempt one');
for (const attempt of retained.attempts) expect(attempt.evidence.receipts.match(/^INSTALL:install-prepush-hook$/gm)).toHaveLength(1);
} finally { fs.rmSync(artifacts, { recursive: true, force: true }); }
});
test('fixture input is the exact generated guard and does not link to live registrations', () => {
const fixture = createShipHookFixture('ship-managed-hook-refresh');
try {
expect(fs.lstatSync(path.join(fixture.home, '.claude/skills/gstack')).isSymbolicLink()).toBe(false);
const workflow = fs.readFileSync(path.join(fixture.repo, 'workflow.md'), 'utf8');
const generated = fs.readFileSync(path.resolve(import.meta.dir, '../ship/SKILL.md'), 'utf8');
expect(generated).toContain(workflow);
expect(fixture.prompt).not.toContain('requires manual integration');
expect(fixture.snapshot().workflowSha256).toMatch(/^[a-f0-9]{64}$/);
} finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
});