Files
gstack/test/helpers/workflow-boundaries-fixture.ts
Garry Tanandcapy-ai[bot] 3766edac8d v1.91.144.0 feat(evals): Opus 5.5 is the paid gate's model under test; judge stays Fable (GSTA-49 TM1) (#3275)
* feat(evals): Opus 5.5 is the paid gate's model under test; judge stays Fable (GSTA-49 TM1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.140.0 chore: stamp TM1 (Opus 5.5 model under test) and CHANGELOG

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): repair the five Opus gate reds from run 39161502626

- coverage audit: owned cd; followed by one && chain, wc -l on in-cwd files
- shared-libs: Claude Code 2.1.292 git --version probe, exact argv only
- ship hook: accept 'integrate the hook yourself' / 'combine ... by hand'
- qa-only: deadline starts only after the charter Write completes
- plan floor: tool-only parent session identifies the owned Create grant;
  floor runner fails fast with PERMISSION_DIALOG_UNANSWERED

Each repair replays its captured run as a free regression with negative
controls. HARNESS_VERSION stays 6 (re-recorded from main's 5).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): ship hook manual-integration sentence rule; qa-only inspection uses Read/Glob; tool-only permission tests join their owner file

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): tpa-absent-linux accepts instructions the user follows themselves as the manual option

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): owned plan Edit menu binds to exactly one pending Edit, including unpublished ones

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): shared-libs read evidence counts a literal path loop whose output names each file

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: restamp to v1.91.142.0 (queue slot moved)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): investigate boundary fixture identifies the owner question by its continue/abort options

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): plan-design-with-ui focus check accepts the skill's design-completeness rating in the question body

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): phrasing sweep across the 108 gate rule cases; replay captured Opus paraphrases with negative controls

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): command-detector sweep — exact-count anchor, label slot, 2.1.292 worktree probe

- coverage audit (gate 39207508651 plan-eng-coverage-audit): output of exactly
  known length (wc -l over literal in-cwd files) anchors the next owned read
  at that line; a label's slot counts from the end and a later complete copy
  voids the credit (review/specialists/testing.md prints '---')
- shared-libs-unsupported-git (39161502626): Claude Code 2.1.292's askPass-first
  'worktree list --porcelain -z' cwd probe is Claude's own; the read-only Git
  check is shared (repoGitRequests, isReadOnlyRepoGitRequest) and the free
  regression replays every Git request of that capture

Each fix replays its captured command with negative controls. No threshold,
trial count, case kind or judge changed. HARNESS_VERSION stays 6.

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-19 07:26:49 +00:00

287 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { CanUseTool, HookCallback, SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import type { AgentSdkResult, QueryProvider } from './agent-sdk-runner';
import type { EvalTestEntry } from './eval-store';
import { CAPTURE_MS } from './eval-budgets';
export type BoundaryCase = 'investigate-owned-completion' | 'investigate-owned-abort' |
'investigate-owned-ending-error';
const ROOT = path.resolve(import.meta.dir, '../..');
const quote = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
const read = (file: string) => fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
export function createBoundaryFixture(id: BoundaryCase, root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'gbound-'))) {
fs.mkdirSync(root, { recursive: true });
root = fs.realpathSync(root);
fs.chmodSync(root, 0o700);
const repo = path.join(root, 'project');
const home = path.join(root, 'home');
const state = path.join(root, 'state');
const installed = path.join(home, '.claude/skills/gstack');
const receipts = path.join(root, 'receipts');
const boundary = path.join(state, 'freeze-dir.txt');
const source = path.join(repo, 'src/value.js');
const interactions: Array<{ tool: string; disposition: string; input: unknown; owner?: string }> = [];
const executions: Array<{ tool: string; input: Record<string, unknown>; allowed: boolean }> = [];
const commands = new Map<string, string>();
const readable = new Set([path.join(repo, 'workflow.md'), source]);
const protectedFiles = new Map<string, string>();
const write = (file: string, text: string, executable = false) => {
let ancestor = path.dirname(file);
while (!fs.lstatSync(ancestor, { throwIfNoEntry: false })) ancestor = path.dirname(ancestor);
const resolved = fs.realpathSync(ancestor);
if (resolved !== root && !resolved.startsWith(root + path.sep)) throw new Error('fixture write escapes its isolated root');
fs.mkdirSync(path.dirname(file), { recursive: true });
const parent = fs.realpathSync(path.dirname(file));
if (!parent.startsWith(root + path.sep) ||
(fs.lstatSync(file, { throwIfNoEntry: false }) && !fs.realpathSync(file).startsWith(root + path.sep))) {
throw new Error('fixture write escapes its isolated root');
}
fs.writeFileSync(file, text, { mode: executable ? 0o755 : 0o600 });
protectedFiles.set(file, text);
};
fs.mkdirSync(repo);
fs.mkdirSync(state);
const env = {
HOME: home, GSTACK_HOME: state, CLAUDE_PLUGIN_DATA: '', CLAUDE_CONFIG_DIR: path.join(root, 'claude-config'),
GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
PATH: `${path.join(root, 'bin')}:${path.dirname(process.execPath)}:${process.env.PATH ?? '/usr/bin:/bin'}`,
};
const generated = read(path.join(ROOT, 'investigate/SKILL.md'));
const scope = generated.match(/^## Scope Lock\n[\s\S]*?(?=^## )/m)?.[0];
if (!scope) throw new Error('generated /investigate Scope Lock missing');
write(path.join(repo, 'workflow.md'), scope);
const blocks = [...scope.matchAll(/```bash\n([\s\S]*?)```/g)].map(match => match[1].trim());
if (blocks.length !== 3) throw new Error('Scope Lock must have availability, acquisition and release blocks');
commands.set(blocks[0], 'availability');
commands.set(blocks[1].replace('<detected-directory>', 'src'), 'acquire');
commands.set('bash ./verify.sh', 'verify');
const releaseTemplate = blocks[2];
for (const file of ['bin/gstack-state-root.sh', 'careful/bin/hook-extract.sh', 'freeze/bin/check-freeze.sh']) {
write(path.join(installed, file), read(path.join(ROOT, file)), true);
}
write(path.join(installed, 'freeze/bin/freeze-state-real.sh'), read(path.join(ROOT, 'freeze/bin/freeze-state.sh')), true);
write(path.join(installed, 'freeze/bin/freeze-state.sh'), `#!/bin/bash
printf 'ACTION:%s:%s\\n' "$1" "\${2:-}" >> ${quote(receipts)}
_out=$(bash ${quote(path.join(installed, 'freeze/bin/freeze-state-real.sh'))} "$@")
_rc=$?
printf '%s\\nRESULT:%s\\n' "$_out" "$_rc" >> ${quote(receipts)}
printf '%s\\n' "$_out"
exit "$_rc"
`, true);
write(source, 'export function value() { return 1; }\n');
protectedFiles.delete(source);
const shellSource = process.platform === 'win32'
? spawnSync('bash', ['-c', 'cd "$(cygpath -u "$1")" && pwd -P', '_', path.join(repo, 'src')],
{ encoding: 'utf8', timeout: 10_000 })
: null;
if (shellSource && shellSource.status !== 0) throw new Error(`Cannot resolve Bash fixture source: ${shellSource.stderr}`);
const physicalSource = shellSource ? shellSource.stdout.trim() : path.join(repo, 'src');
write(path.join(repo, 'verify.sh'), `#!/bin/bash
printf 'VERIFY\\n' >> ${quote(receipts)}
${id === 'investigate-owned-ending-error'
? "printf 'FIXTURE_ENDING_ERROR: required verifier is unavailable; end this investigation without retry.\\n' >&2\n_rc=69"
: `${quote(process.execPath)} -e 'import { value } from "./src/value.js"; if (value() !== 2) process.exit(1); console.log("FIXTURE_VERIFIED")'\n_rc=$?`}
printf 'VERIFY_STATUS:%s\\n' "$_rc" >> ${quote(receipts)}
exit "$_rc"
`, true);
const prompt = `Load gstack's /investigate Scope Lock workflow from workflow.md. This is a bounded
fixture: the root cause is established, value() returns 1 but must return 2. Apply
the scope workflow. After acquisition, ask the owner via AskUserQuestion whether
to continue or abort. If continuing, use Edit to change src/value.js, then run
bash ./verify.sh. Do not run other investigation phases.
Supported interactions: Read workflow.md and src/value.js; Bash for the supplied
scope blocks and verifier; Edit only src/value.js, changing only the return value
from 1 to 2; one continue/abort question. Use each entire Bash block verbatim,
including its comment-only lines. Substitute
src for <detected-directory>, and your returned token for <retained-owner-token>.
The only verifier command is exactly bash ./verify.sh. Never combine commands,
change directories or run a command in the background.
No installation, other file
edits, direct state-file access, or deleting state outside the supplied helper.
The owner may explicitly abort at that question. Honor that answer.
A FIXTURE_ENDING_ERROR verifier result ends the investigation, not a new repair.
Finish with the actual outcome and scope disposition. HOME and GSTACK_HOME are
already isolated to this fixture. Do not change them.`;
const invalidTool = (tool: string, input: Record<string, unknown>): string | undefined => {
const continued = interactions.some(event => event.disposition === 'continue-investigation');
const owner = read(receipts).match(/^FREEZE_OWNER=([a-f0-9]{32})$/m)?.[1];
const owned = !!owner && read(boundary) === `${physicalSource}\ngstack-freeze-v1:${owner}\n`;
if (tool === 'Read') {
if (typeof input.file_path !== 'string' || !readable.has(path.resolve(repo, input.file_path))) return 'Read is limited to the workflow and declared source paths.';
} else if (tool === 'Bash') {
if (typeof input.command !== 'string' || input.run_in_background) return 'Only foreground fixture commands are supported.';
const command = input.command.trim();
if (owner && command === releaseTemplate.replace('<retained-owner-token>', owner)) return;
const action = commands.get(command);
if (!action) return 'Use one of the declared Bash blocks verbatim; state reads and receipt writes are forbidden.';
if (action === 'acquire' && owner) return 'This run already acquired its boundary.';
if (action === 'verify' && (!continued || !owned)) return 'Verification requires continuation and the active owned boundary.';
} else if (tool === 'Edit') {
if (typeof input.file_path !== 'string' || path.resolve(repo, input.file_path) !== source || !continued || !owned) return 'Only the continued investigation under its owned boundary may edit its source file.';
const current = read(source);
if (typeof input.old_string !== 'string' || !input.old_string || typeof input.new_string !== 'string' ||
current !== 'export function value() { return 1; }\n' ||
current.replace(input.old_string, input.new_string) !== 'export function value() { return 2; }\n') return 'The only permitted edit changes the existing return value from 1 to 2.';
} else if (tool === 'AskUserQuestion') {
if (interactions.some(event => event.tool === 'AskUserQuestion')) return 'Only one declared owner question is supported.';
} else return 'This tool is outside the declared fixture interactions.';
};
const preToolUse: HookCallback = async input => {
if (input.hook_event_name !== 'PreToolUse') throw new Error('unexpected fixture hook');
const toolInput = input.tool_input as Record<string, unknown>;
const reason = invalidTool(input.tool_name, toolInput);
executions.push({ tool: input.tool_name, input: toolInput, allowed: !reason });
if (reason) interactions.push({ tool: input.tool_name, disposition: 'unsupported-tool', input: toolInput });
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: reason ? 'deny' : input.tool_name === 'AskUserQuestion' ? 'ask' : 'allow',
...(reason ? { permissionDecisionReason: reason } : {}),
...(input.tool_name === 'Bash' && !reason ? { updatedInput: { command: toolInput.command, timeout: 10000, run_in_background: false } } : {}) } };
};
const canUseTool: CanUseTool = async (tool, input) => {
const invalid = invalidTool(tool, input);
if (invalid) {
interactions.push({ tool, disposition: 'unsupported-tool', input });
return { behavior: 'deny', message: invalid };
}
if (tool === 'AskUserQuestion') {
const owner = read(boundary).match(/gstack-freeze-v1:([a-f0-9]{32})/)?.[1];
const questions = input.questions as Array<{ question: string; options: Array<{ label: string; description?: string }> }>;
const question = questions?.[0];
const options = Array.isArray(question?.options) ? question.options : [];
// The declared question is identified by its two options' meanings, not its wording.
const proceed = options.filter(option => /\b(continue|proceed)\b/i.test(option.label));
const stop = options.filter(option => /\b(abort|stop|cancel)\b/i.test(option.label));
const means = (option: { description?: string } | undefined, contrary: RegExp) =>
Boolean(option) && (typeof option!.description !== 'string' || !contrary.test(option!.description));
const declared = options.length === 2 && proceed.length === 1 && stop.length === 1 && proceed[0] !== stop[0]
&& means(proceed[0], /\b(abort|stop|cancel|no edits?|without edit\w*)\b/i) && means(stop[0], /\b(continue|proceed)\b/i);
const abort = id === 'investigate-owned-abort';
const choice = abort ? stop[0] : proceed[0];
if (!owner || questions?.length !== 1 || !declared || !choice) {
interactions.push({ tool, disposition: 'unsupported-question', input, owner });
return { behavior: 'deny', message: 'Ask only whether to continue or abort, after scope acquisition.' };
}
interactions.push({ tool, disposition: abort ? 'explicit-abort' : 'continue-investigation', input, owner });
return { behavior: 'allow', updatedInput: { ...input, answers: { [question.question]: choice.label } } };
}
return { behavior: 'allow', updatedInput: input };
};
const snapshot = () => ({ receipts: read(receipts), boundary: read(boundary), source: read(source), interactions, executions,
changedProtectedFiles: [...protectedFiles].filter(([file, bytes]) => read(file) !== bytes).map(([file]) => path.relative(root, file)) });
return { id, root, repo, env, prompt, source, receipts, boundary, installed, physicalSource, canUseTool, preToolUse, snapshot };
}
/** One sentence naming the verifier and its failure ("the verifier couldn't run", "is unavailable", "isn't verified"). */
export const acknowledgesEndingError = (finalText: string): boolean => finalText.split(/(?<=[.!?])\s+|\n/).some(sentence =>
/\bverif\w*/i.test(sentence) && /\b(?:error\w*|unavailable|cannot|can['’]t|could(?: not|n['’]t)|unable|wasn['’]t able|not (?:be )?verified|isn['’]t verified|failed)\b/i.test(sentence));
export function boundaryFailures(fixture: ReturnType<typeof createBoundaryFixture>, result: Pick<AgentSdkResult, 'exitReason' | 'events' | 'assistantTurns'>) {
const evidence = fixture.snapshot();
const session = result.events.find(event => event.type === 'system' && event.subtype === 'init')?.session_id;
const assistant = result.assistantTurns.filter(event => event.session_id === session && event.parent_tool_use_id === null && event.message.role === 'assistant');
const finalId = assistant.at(-1)?.message.id;
const finalText = session && finalId ? assistant.filter(event => event.message.id === finalId)
.flatMap(event => event.message.content).filter(block => block.type === 'text').map(block => block.text).join('\n') : '';
const failures: string[] = [];
const check = (ok: boolean, message: string) => { if (!ok) failures.push(message); };
check(result.exitReason === 'success', `actor ended: ${result.exitReason}`);
check(evidence.changedProtectedFiles.length === 0, 'protected fixture files changed');
check(!evidence.interactions.some(event => /invalid|unsupported/.test(event.disposition)), 'undeclared interaction');
check(evidence.executions.some(event => event.tool === 'Bash' && event.allowed), 'registered native hook saw no Bash execution');
const owners = [...evidence.receipts.matchAll(/^FREEZE_OWNER=([a-f0-9]{32})$/gm)].map(match => match[1]);
check(owners.length === 1, 'actor must acquire exactly one run-owned boundary');
check(evidence.receipts.includes(`FREEZE_DIR=${fixture.physicalSource}\n`), 'actor did not acquire the affected module');
check(evidence.receipts.includes(`ACTION:release:${owners[0]}\n`), 'actor did not release its acquired owner token');
check(evidence.receipts.includes('FREEZE_RELEASED:'), 'helper did not confirm owned cleanup');
check(!evidence.boundary && !fs.existsSync(fixture.boundary), 'owned boundary remains');
if (fixture.id === 'investigate-owned-abort') {
check(evidence.interactions.some(event => event.disposition === 'explicit-abort' && event.owner === owners[0]), 'explicit abort was not delivered after acquisition');
check(evidence.source === 'export function value() { return 1; }\n', 'edit occurred despite explicit abort');
check(!evidence.receipts.includes('VERIFY\n'), 'verifier ran after abort');
check(/abort|stop|cancel/i.test(finalText), 'actor did not acknowledge abort');
} else {
// Outcome, not step count: the correction landed, the final verification
// reports the expected status, and no edit follows that verification.
const allowed = evidence.executions.filter(event => event.allowed);
const lastVerify = allowed.findLastIndex(event => event.tool === 'Bash' && /\bverify\.sh\b/.test(String(event.input.command ?? '')));
const statuses = [...evidence.receipts.matchAll(/^VERIFY_STATUS:(\d+)$/gm)].map(match => match[1]);
check(allowed.some(event => event.tool === 'Edit'), 'expected an allowed native Edit');
check(evidence.interactions.some(event => event.disposition === 'continue-investigation' && event.owner === owners[0]), 'continuation was not authorized under the acquired owner');
check(evidence.source.includes('return 2'), 'requested correction is missing');
check((evidence.receipts.match(/^VERIFY$/gm)?.length ?? 0) >= 1, 'verifier did not run');
check(lastVerify >= 0 && !allowed.slice(lastVerify + 1).some(event => event.tool === 'Edit'), 'an edit followed the final verification');
if (fixture.id === 'investigate-owned-ending-error') {
// The fixture's ending error says to stop without retrying the verifier.
check(statuses.length === 1 && statuses[0] === '69', 'ending verifier error was not delivered once without retry');
check(acknowledgesEndingError(finalText), 'ending error not acknowledged');
} else check(statuses.at(-1) === '0', 'final verification did not succeed');
}
return failures;
}
export async function runBoundaryActor(id: BoundaryCase, record: (entry: EvalTestEntry) => void, provider?: QueryProvider) {
const started = Date.now();
let fixture: ReturnType<typeof createBoundaryFixture> | undefined;
let result: AgentSdkResult | undefined;
let passed = false;
let error: unknown;
const attempts: Array<{ events: SDKMessage[]; evidence?: ReturnType<ReturnType<typeof createBoundaryFixture>['snapshot']> }> = [];
try {
fixture = createBoundaryFixture(id);
const { runAgentSdkTest, resolveClaudeBinary } = await import('./agent-sdk-runner');
const executable = resolveClaudeBinary();
if (!provider && !executable) throw new Error('F9 actor requires the pinned native Claude CLI');
const query = provider ?? (await import('@anthropic-ai/claude-agent-sdk')).query;
result = await runAgentSdkTest({
systemPrompt: { type: 'preset', preset: 'claude_code' },
userPrompt: fixture.prompt, workingDirectory: fixture.repo, env: fixture.env,
pathToClaudeCodeExecutable: executable ?? undefined,
allowedTools: ['Read', 'Bash', 'Edit', 'AskUserQuestion'], settingSources: [],
canUseTool: (...args) => fixture!.canUseTool(...args), maxTurns: 12, signal: AbortSignal.timeout(CAPTURE_MS - 20000),
testName: id,
onRetry: () => {
attempts.at(-1)!.evidence = fixture!.snapshot();
const root = fixture!.root;
fs.rmSync(root, { recursive: true, force: true });
fixture = createBoundaryFixture(id, root);
},
queryProvider: options => {
const attempt: typeof attempts[number] = { events: [] };
attempts.push(attempt);
const stream = query({ ...options, options: { ...options.options,
allowedTools: [], hooks: { PreToolUse: [{ hooks: [(...args) => fixture!.preToolUse(...args)] }] },
} });
return new Proxy(stream, { get(target, property) {
if (property === Symbol.asyncIterator) return async function* () {
try { for await (const event of target) { attempt.events.push(event); yield event; } }
finally { attempt.evidence = fixture!.snapshot(); }
};
const value = Reflect.get(target, property, target);
return typeof value === 'function' ? value.bind(target) : value;
} });
},
});
const failures = boundaryFailures(fixture, result);
if (failures.length) throw new Error(failures.join('; '));
passed = true;
} catch (failure) {
error = failure;
throw failure;
} finally {
try {
record({ name: id, suite: 'workflow-boundaries', tier: 'e2e', passed,
duration_ms: Date.now() - started, cost_usd: result?.costUsd ?? 0,
transcript: result?.events, prompt: fixture?.prompt, turns_used: result?.turnsUsed, model: result?.model,
output: JSON.stringify({ assistant: result?.output, error: error === undefined ? undefined : String(error), evidence: fixture?.snapshot(), attempts }),
exit_reason: passed ? 'success' : result?.exitReason === 'success' ? 'assertion_failed' : result?.exitReason ?? 'runner_error' });
} finally {
if (fixture) fs.rmSync(fixture.root, { recursive: true, force: true });
}
}
}