v1.91.69.0 fix(cso-eval): producers keep usage on timeout, give the agent reporting time, and accept the helper's own artifact names (#3111)

- every helper artifact path tripped the secret redactor: the run ID's
  13-digit epoch reads as a phone number, some 32-hex IDs as wallets.
  Validate paths against the helper layout; schema-shaped ID components
  are accepted by shape, every other component still goes through redact
- the provider was killed at exactly --budget after cell start, before
  the helper deadline (start + budget) and before finish; give it the
  budget plus a 300 s reporting grace (evaluator graceSeconds)
- Claude producers stream events (stream-json --verbose
  --include-partial-messages) and add usage per message, so a timed-out
  receipt keeps tokens, cost, turns and the last text
- failed post-run checks name the check in the receipt reason
This commit is contained in:
Garry Tan authored and GitHub committed 2026-10-10 18:59:17 +00:00
1 parent 20eb6202fa
commit 91bbd9e6c4
10 files changed
+352 -25

No files matched your search

+28
View File
@@ -1,5 +1,33 @@
# Changelog
## [1.91.69.0] - 2026-10-10
**/cso eval cells now get time to finish their report, keep their spend when they run out of time, and stop mislabeling the helper's own files as secrets.**
The first paid smoke cells that ran real scanners both ended as `REDACTION_FAILED` with no usage and no artifacts after the full 30 minutes. There were three causes.
- **Every artifact path tripped the secret check.** The helper names each run `<epoch ms>-<hex>`, and the secret redactor reads a 13-digit epoch as a phone number. Some 32-hex review IDs also read as wallet addresses. So the first run that actually wrote artifacts failed the post-run inventory, every time.
- **The provider was killed at the budget.** The producer stopped Claude at exactly `--budget` after the cell started. The helper's own deadline is the budget after `start`, and its `finish` writes the report after that deadline. Both agents started the helper within 30 seconds, worked until the deadline, and were killed before they could call `finish`.
- **A kill threw the spend away.** `--output-format json` reports usage only when Claude exits, so a timeout recorded zero tokens.
### What this means for you
- Helper artifact names are checked against the helper's layout: `<repoId>/<runId>/...`, `public-cache/` and `legacy-imports/`. A path component that is wholly a helper identifier is accepted by its shape. Every other component, including source paths mirrored under `snapshot/` and `readable/`, still goes through the secret redactor.
- The producer gives the provider the budget plus 300 seconds of reporting time (`PRODUCER_REPORTING_GRACE_SECONDS`, the evaluator's frozen `graceSeconds`). The helper still refuses evidence after its deadline, so the measured budget is unchanged. The agent just gets to call `finish`, as a user's agent would.
- Claude producers stream events (`stream-json --verbose --include-partial-messages`) and add up usage message by message. A timed-out receipt carries its tokens, estimated cost, turns and the agent's last text, marked `failed` with `timeout`. A run with no successful, nonblank final result still fails.
- A failed post-run check now names the check in the receipt's reason, for example `artifact inventory check: ...`. A redaction rejection says which component failed and never includes the component itself.
### Itemized changes
#### Fixed
- `inventoryProducerArtifacts` validates paths with `assertProducerArtifactPath`. Paths outside the helper layout fail as `INVALID_PRODUCER_ARTIFACTS`. A component that looks like a secret fails as `REDACTION_FAILED` with its position and masked parent.
- `runProducerCell` passes `producerProviderTimeoutMs(budget)`, which is the budget plus 300 s. Integrity failures read `<check> check: <detail>`, and add the provider's own error code when there was one.
- The Claude producer runs through `runClaudeProducerStream` in `test/helpers/providers/claude-stream.ts`. It folds the event stream as it arrives, so no output buffer can overflow. It resolves even when a grandchild holds stdout, and keeps the `Command failed: <command> <args>` reason on a nonzero exit. Non-producer Claude evals are unchanged.
#### For contributors
- `test/claude-producer-stream.test.ts` covers stream accounting, the paid result contract, a timed-out stub, and nonzero and clean exits. `test/cso-eval.test.ts` covers the real helper layout (an epoch run ID and a wallet-shaped review ID), a planted secret-shaped file name, and a timed-out receipt that keeps its usage.
- The evaluator's cells need a matching change: an outer timeout of the budget plus 600 s, and the new Claude flags in the stub preflight prefix.
## [1.91.68.0] - 2026-10-08
**Claude /cso eval cells run the real scanners again, never break their own source check, and still record what they spent when a post-run check fails.**
+1 -1
View File
@@ -1 +1 @@
1.91.68.0
1.91.69.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.68.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.69.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.68",
"version": "1.91.69",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+55 -6
View File
@@ -42,6 +42,16 @@ const OUTPUT_LIMIT = 32 * 1024 * 1024;
const ARTIFACT_FILE_LIMIT = 32 * 1024 * 1024;
const ARTIFACT_TOTAL_LIMIT = 128 * 1024 * 1024;
const ARTIFACT_COUNT_LIMIT = 4096;
/**
* `/cso --budget` bounds the helper run: it accepts evidence until its deadline (start + budget) and `finish` writes the
* report after it. A user's agent is never killed at the budget, so the provider gets the budget, the agent's time
* before `start`, and the reporting time after the deadline. Matches the evaluator's frozen `graceSeconds`.
*/
export const PRODUCER_REPORTING_GRACE_SECONDS = 300;
export function producerProviderTimeoutMs(budgetSeconds: number): number {
return (budgetSeconds + PRODUCER_REPORTING_GRACE_SECONDS) * 1000;
}
function inside(parent: string, child: string): boolean {
const path = relative(parent, child);
@@ -168,6 +178,34 @@ function emptyArtifactInventory(): ProducerArtifactInventory {
return { ...base, identityHash: producerArtifactInventoryHash(base) };
}
/** A whole path component that is one of the helper's own identifiers: `<epoch ms>-<hex>` run and replay IDs, retired runs, and hex artifact IDs with an optional lowercase-word prefix (`gitleaks-<hex>-<hex>.json`). */
const HELPER_ID_COMPONENT = /^(?:\d{13}-[a-f0-9]{16}|\.retired-\d{13}-[a-f0-9]{16}-[a-f0-9]{32}|(?:[a-z]+-)*[a-f0-9]{16,64}(?:-[a-f0-9]{16})*(?:\.[a-z]+)?)$/;
/**
* Helper artifact paths are `<repoId>/<runId>/...` plus the helper's public-cache/ and legacy-imports/. Components that
* are wholly a helper identifier are checked by shape, because the secret redactor reads them as data (a 13-digit epoch
* as a phone number, some 32-hex IDs as wallets). Every other component (fixed helper names, and source paths mirrored
* under snapshot/ and readable/) still goes through the redactor.
*/
export function assertProducerArtifactPath(relativePath: string): void {
const parts = relativePath.split('/');
const reject = (rule: string): never => { throw new Error(`INVALID_PRODUCER_ARTIFACTS: ${rule}`); };
if (!relativePath || relativePath.length > 1024) reject('an artifact path is empty or longer than 1024 bytes');
if (!/^[A-Za-z0-9._/-]+$/.test(relativePath) || parts.some(part => !part || part === '.' || part === '..')) reject('an artifact path has a component outside [A-Za-z0-9._-]');
const inRun = /^[a-f0-9]{24}$/.test(parts[0]) && /^(?:\d{13}-[a-f0-9]{16}|\.retired-\d{13}-[a-f0-9]{16}-[a-f0-9]{32})$/.test(parts[1] ?? '');
if (parts.length < 2 || !(inRun ? parts.length >= 3 : ['public-cache', 'legacy-imports'].includes(parts[0]))) reject('an artifact lies outside <repoId>/<runId>/, public-cache/ and legacy-imports/');
const schemaId = (part: string, index: number) => index === 0 ? /^[a-f0-9]{24}$/.test(part) : HELPER_ID_COMPONENT.test(part);
for (const [index, part] of parts.entries()) {
if (schemaId(part, index)) continue;
let clean: boolean;
try { clean = redact(part) === part; } catch { clean = false; }
if (!clean) {
const parent = parts.slice(0, index).map((item, at) => schemaId(item, at) ? 'ID' : item).join('/');
throw new CsoError('REDACTION_FAILED', `artifact path component ${index + 1} under ${JSON.stringify(parent)} looks like a secret; the component is withheld`);
}
}
}
export function inventoryProducerArtifacts(helperHome: string): ProducerArtifactInventory {
const security = join(helperHome, 'security'), artifactRoot = join(security, 'cso');
for (const directory of [security, artifactRoot]) {
@@ -187,9 +225,7 @@ export function inventoryProducerArtifacts(helperHome: string): ProducerArtifact
if (entry.isDirectory()) { visit(full, depth + 1); continue; }
if (!entry.isFile() || entries.length >= ARTIFACT_COUNT_LIMIT) throw new Error('INVALID_PRODUCER_ARTIFACTS');
const relativePath = relative(artifactRoot, full).split(sep).join('/');
if (!relativePath || relativePath.length > 1024 || !/^[A-Za-z0-9._/-]+$/.test(relativePath) || relativePath.split('/').some(part => !part || part === '.' || part === '..') || redact(relativePath) !== relativePath) {
throw new CsoError('REDACTION_FAILED', 'Producer artifact path withheld');
}
assertProducerArtifactPath(relativePath);
const contents = readBoundedStable(full, ARTIFACT_FILE_LIMIT, 'Producer artifact');
totalBytes += contents.byteLength;
if (totalBytes > ARTIFACT_TOTAL_LIMIT) throw new Error('PRODUCER_ARTIFACTS_TOO_LARGE');
@@ -494,7 +530,11 @@ export function producerFailureMessage(error: unknown): string {
/** A post-run check failed: the receipt records it as a failed cell with no output or artifacts, keeping the run's usage. */
export function producerIntegrityFailure(error: unknown): { code: string; reason: string } {
if (error instanceof CsoError) return { code: error.code, reason: producerFailureMessage(error) };
if (error instanceof CsoError) {
let reason: string;
try { reason = redact(error.message); } catch { reason = 'detail withheld'; }
return { code: error.code, reason };
}
const message = producerFailureMessage(error);
const named = error instanceof Error && !(error as NodeJS.ErrnoException).code ? /^([A-Z][A-Z0-9_]+)(?::\s*([\s\S]*))?$/.exec(message) : null;
return named ? { code: named[1], reason: named[2] || named[1] } : { code: 'PRODUCER_POST_RUN_CHECK_FAILED', reason: message };
@@ -544,7 +584,7 @@ export async function runProducerCell(inputPath: string, receiptPath: string, op
const runOptions = {
prompt: promptFor(cell, input.skill, sourceRoot, helper),
workdir: stateRoot,
timeoutMs: cell.budgetSeconds * 1000,
timeoutMs: producerProviderTimeoutMs(cell.budgetSeconds),
model: cell.model,
csoProducer: {
stateDirectory: stateRoot,
@@ -584,23 +624,32 @@ export async function runProducerCell(inputPath: string, receiptPath: string, op
const finishedAt = new Date().toISOString();
let artifacts = emptyArtifactInventory();
let integrityFailure: { code: string; reason: string } | undefined;
let check = '';
try {
check = 'helper installation';
const installationAfter = producerInstallationIdentity(helper);
if (installationAfter.identityHash !== installationIdentity.identityHash) {
throw new Error('PRODUCER_HELPER_GENERATION_CHANGED: installed helper identity changed during the run');
}
check = 'provider installation';
const providerAfter = artifactIdentity(provider.command.executable);
if (providerAfter.sha256 !== providerIdentity.executable.sha256 || providerAfter.bytes !== providerIdentity.executable.bytes) {
throw new Error('PRODUCER_PROVIDER_INSTALLATION_RACE: provider executable changed during the run');
}
check = 'source modes';
assertProducerSourceSealed(sourceRoot);
check = 'source content';
validateSource(sourceRoot, input.source, cell.sourceHash);
check = 'source Git state';
if (repositoryIdentity(sourceRoot) !== originalRepositoryIdentity) throw new Error('PRODUCER_CHANGED_SOURCE: Git HEAD, branch, config or status changed during the run');
check = 'artifact inventory';
artifacts = inventoryProducerArtifacts(helperHome);
check = 'output size';
const outputBytes = typeof run.output === 'string' ? Buffer.byteLength(run.output) : 0;
if (outputBytes > OUTPUT_LIMIT) throw new Error(`PRODUCER_OUTPUT_TOO_LARGE: output is ${outputBytes} bytes; the limit is ${OUTPUT_LIMIT}`);
} catch (error) {
integrityFailure = producerIntegrityFailure(error);
const failure = producerIntegrityFailure(error);
integrityFailure = { code: failure.code, reason: `${check} check: ${failure.reason}${run.error ? `; the provider run also failed (${run.error.code})` : ''}` };
artifacts = emptyArtifactInventory();
}
if (!integrityFailure) {
+68
View File
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { claudeStreamState, consumeClaudeStreamLine, resultFromClaudeStream, runClaudeProducerStream } from './helpers/providers/claude-stream';
// The event shapes claude 2.1.263 prints with -p --output-format stream-json --verbose --include-partial-messages.
const start = (id: string, input: number, cached = 7) => ({ type: 'stream_event', parent_tool_use_id: null, event: { type: 'message_start', message: { id, usage: { input_tokens: input, output_tokens: 1, cache_read_input_tokens: cached } } } });
const delta = (output: number) => ({ type: 'stream_event', parent_tool_use_id: null, event: { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: output } } });
const stop = { type: 'stream_event', parent_tool_use_id: null, event: { type: 'message_stop' } };
const text = (id: string, value: string) => ({ type: 'assistant', message: { id, content: [{ type: 'text', text: value }], usage: { input_tokens: 1, output_tokens: 1 } } });
const result = (extra: Record<string, unknown>) => ({ type: 'result', subtype: 'success', is_error: false, num_turns: 2, result: 'REPORT', usage: { input_tokens: 2001, output_tokens: 201, cache_read_input_tokens: 14 }, ...extra });
const lines = (...events: unknown[]) => events.map(event => JSON.stringify(event));
const fixtures: string[] = [];
afterEach(() => { for (const path of fixtures.splice(0)) rmSync(path, { recursive: true, force: true }); });
const stub = (body: string) => {
const directory = mkdtempSync(join(tmpdir(), 'claude-stream-')); fixtures.push(directory);
const script = join(directory, 'claude');
writeFileSync(script, `#!/bin/sh\ncat > /dev/null\n${body}\n`, { mode: 0o755 });
return { script, directory };
};
const printf = (events: unknown[]) => lines(...events).map(line => `printf '%s\\n' '${line}'`).join('\n');
describe('Claude producer stream accounting', () => {
test('counts every message from its stream usage, including one a kill interrupted', () => {
const state = claudeStreamState();
for (const line of lines(start('a', 1000), text('a', 'first note'), delta(100), stop, start('b', 1001), text('b', 'second note'), delta(40))) consumeClaudeStreamLine(state, line);
const killed = resultFromClaudeStream(state, { model: 'claude-test', durationMs: 5, error: { code: 'timeout', reason: 'exceeded 5ms' } });
expect(killed).toMatchObject({ tokens: { input: 2001, output: 140, cached: 14 }, toolCalls: 2, output: 'second note', error: { code: 'timeout' }, modelUsed: 'claude-test' });
});
test('takes usage and turns from the final result and keeps the paid result contract', () => {
const state = claudeStreamState();
for (const line of lines(start('a', 1000), delta(100), stop, result({}))) consumeClaudeStreamLine(state, line);
expect(resultFromClaudeStream(state, { model: 'claude-test', durationMs: 5 })).toEqual({ output: 'REPORT', tokens: { input: 2001, output: 201, cached: 14 }, durationMs: 5, toolCalls: 2, modelUsed: 'claude-test' });
for (const ending of [[], [result({ result: ' ' })], [result({ result: 7 })], [result({ is_error: true })], [result({ subtype: 'error_max_turns' })]]) {
const partial = claudeStreamState();
for (const line of lines(start('a', 10), delta(3), stop, ...ending)) consumeClaudeStreamLine(partial, line);
const outcome = resultFromClaudeStream(partial, { model: 'claude-test', durationMs: 1 });
expect(outcome).toMatchObject({ output: '', error: { code: 'unknown', reason: 'empty or invalid output from claude CLI (exit 0)' } });
expect(outcome.tokens.input).toBeGreaterThan(0);
}
});
test.skipIf(process.platform === 'win32')('a timed-out run returns the usage and last text it streamed before the kill', async () => {
const { script, directory } = stub(`${printf([start('a', 1000), text('a', 'investigating'), delta(100), stop, start('b', 1001), delta(9)])}\nsleep 30`);
const began = Date.now();
const outcome = await runClaudeProducerStream(script, ['-p'], { input: 'go', cwd: directory, env: { PATH: '/usr/bin:/bin' }, timeoutMs: 1500, model: 'claude-test' });
expect(Date.now() - began).toBeLessThan(10_000);
expect(outcome).toMatchObject({ output: 'investigating', tokens: { input: 2001, output: 109, cached: 14 }, toolCalls: 2, error: { code: 'timeout', reason: 'exceeded 1500ms' } });
});
test.skipIf(process.platform === 'win32')('keeps the failed-command reason shape and the stream usage on a nonzero exit', async () => {
const { script, directory } = stub(`${printf([result({ is_error: true, result: 'Invalid API key', usage: { input_tokens: 0, output_tokens: 0 } })])}\nexit 1`);
const outcome = await runClaudeProducerStream(script, ['-p', '--model', 'm'], { input: 'go', cwd: directory, env: { PATH: '/usr/bin:/bin' }, timeoutMs: 5000, model: 'claude-test' });
expect(outcome.error?.code).toBe('unknown');
expect(outcome.error?.reason.startsWith(`Command failed: ${script} -p --model m`)).toBe(true);
expect(outcome.tokens).toEqual({ input: 0, output: 0, cached: 0 });
});
test.skipIf(process.platform === 'win32')('a clean exit with a final result succeeds', async () => {
const { script, directory } = stub(printf([start('a', 10), delta(2), stop, result({})]));
const outcome = await runClaudeProducerStream(script, [], { input: 'go', cwd: directory, env: { PATH: '/usr/bin:/bin' }, timeoutMs: 5000, model: 'claude-test' });
expect(outcome).toMatchObject({ output: 'REPORT', tokens: { input: 2001, output: 201, cached: 14 }, toolCalls: 2 });
expect(outcome.error).toBeUndefined();
});
});
+57 -11
View File
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { collectProducerReceipts, createEvalBaseline, createEvalMatrix, createPortableSkillPayload, loadPortableSkillPayload, prepareEvalJobs, REQUIRED_CONTAINMENT, scoreCollectedEval, scoreEval, validateMatrix, validatePortableSkillPayload, type EvalBaseline, type EvalCell, type EvalMatrix, type EvalResult, type PreparedEvalSchedule } from '../scripts/cso-eval';
import { PRODUCER_PROVIDER_POLICY, producerFailureMessage, producerInstallationIdentity, resolveProducerHelperBinding, runProducerCell, validateProductionProducerInstallation } from '../scripts/cso-eval-producer';
import { assertProducerArtifactPath, PRODUCER_PROVIDER_POLICY, producerFailureMessage, producerProviderTimeoutMs, producerInstallationIdentity, resolveProducerHelperBinding, runProducerCell, validateProductionProducerInstallation } from '../scripts/cso-eval-producer';
import { producerArtifactInventoryHash, producerHostPlatform, producerInstallationIdentityHash, producerProviderIdentityHash, producerReceiptHash, sha256, type ProducerArtifactInventory, type ProducerInstallationIdentity, type ProducerProviderIdentity, type ProducerReceipt } from '../scripts/cso-eval-protocol';
import type { Family, ProviderAdapter, RunOpts, RunResult } from './helpers/providers/types';
import { CORPUS_VERSION, FAMILIES, STACKS, defineEvalCorpus, loadCorpusManifest, materializeCase, sourceFiles, sourceHash, validateCorpusManifest, type CorpusManifest, type EvalVariant } from './fixtures/cso-eval/materialize';
@@ -17,6 +17,7 @@ import { CsoError } from '../lib/cso/contracts';
import { geminiProducerPaths, geminiProducerSystemSettings } from './helpers/providers/gemini';
const temporary: string[] = [];
const randomHex = (bytes: number) => createHash('sha256').update(`${Math.random()}:${process.hrtime.bigint()}`).digest('hex').slice(0, bytes * 2);
const root = () => { const path = mkdtempSync(join(tmpdir(), 'cso-eval-')); temporary.push(path); return path; };
function unlockTemporary(path: string): void {
if (!existsSync(path)) return;
@@ -526,6 +527,8 @@ describe('CSO matched producer orchestration', () => {
testProviderCommand: providerCommandFor(launcher),
});
// The helper's real layout: `<repoId>/<epoch ms>-<hex>`; the secret redactor reads that epoch as a phone number.
const helperRun = `${'e'.repeat(24)}/${Date.now()}-${'0123456789abcdef'}`;
class FakeAdapter implements ProviderAdapter {
readonly name = 'fake';
constructor(
@@ -729,7 +732,7 @@ describe('CSO matched producer orchestration', () => {
expect(existsSync(input)).toBe(false);
expect(opts).toMatchObject({
model: cell.model,
timeoutMs: cell.budgetSeconds * 1000,
timeoutMs: (cell.budgetSeconds + 300) * 1000,
workdir: join(isolated.job, 'state'),
csoProducer: {
stateDirectory: join(isolated.job, 'state'),
@@ -751,7 +754,7 @@ describe('CSO matched producer orchestration', () => {
expect(opts.prompt).not.toContain(cell.caseId);
expect(opts.prompt).not.toContain(cell.variant);
expect(opts.prompt).not.toContain('ORACLE_SQL_MARKER');
const artifactRoot = join(process.env.GSTACK_HOME!, 'security', 'cso', 'run-1');
const artifactRoot = join(process.env.GSTACK_HOME!, 'security', 'cso', helperRun);
mkdirSync(artifactRoot, { recursive: true });
writeFileSync(join(artifactRoot, 'report.json'), '{"status":"complete"}\n');
writeFileSync(join(artifactRoot, 'repair.patch'), 'diff --git a/app.mjs b/app.mjs\n');
@@ -780,10 +783,10 @@ describe('CSO matched producer orchestration', () => {
expect(receipt.providerIdentity).toEqual(providerIdentityFor('gpt', launcher));
expect(receipt.artifacts).toMatchObject({ schemaVersion: 1, root: 'security/cso' });
expect(receipt.artifacts.totalBytes).toBe(receipt.artifacts.entries.reduce((sum, entry) => sum + entry.bytes, 0));
expect(receipt.artifacts.entries.map(entry => entry.path)).toEqual(['run-1/repair.patch', 'run-1/report.json']);
expect(receipt.artifacts.entries.map(entry => entry.path)).toEqual([`${helperRun}/repair.patch`, `${helperRun}/report.json`]);
expect(receipt.artifacts.identityHash).toBe(producerArtifactInventoryHash({ schemaVersion: 1, root: 'security/cso', entries: receipt.artifacts.entries, totalBytes: receipt.artifacts.totalBytes }));
const retainedHome = join(isolated.job, 'state', 'cso-home');
expect(readFileSync(join(retainedHome, 'security', 'cso', 'run-1', 'report.json'), 'utf8')).toContain('complete');
expect(readFileSync(join(retainedHome, 'security', 'cso', helperRun, 'report.json'), 'utf8')).toContain('complete');
expect(receipt.artifacts.entries.every(entry => !/provider|credential|session/i.test(entry.path))).toBe(true);
expect(JSON.parse(readFileSync(output, 'utf8'))).toEqual(receipt);
const { receiptHash, ...receiptWithoutHash } = receipt;
@@ -913,7 +916,7 @@ describe('CSO matched producer orchestration', () => {
const receiptPath = join(outputRoot, `${cell.id}.json`);
const adapter = new FakeAdapter(opts => writeFileSync(opts.csoProducer!.providerCommand.executable, '#!/bin/sh\nexit 1\n'));
const receipt = await runProducerCell(isolated.input, receiptPath, withHelper({ adapter, paidExecutionAuthorized: true }, launcher));
expectIntegrityReceipt(receipt, receiptPath, { code: 'PRODUCER_PROVIDER_INSTALLATION_RACE', reason: 'provider executable changed during the run' });
expectIntegrityReceipt(receipt, receiptPath, { code: 'PRODUCER_PROVIDER_INSTALLATION_RACE', reason: 'provider installation check: provider executable changed during the run' });
});
test('records a failed receipt with usage when the helper generation changes during a cell', async () => {
@@ -928,7 +931,7 @@ describe('CSO matched producer orchestration', () => {
writeFileSync(opts.csoProducer!.helperGeneration, `${sha256(readFileSync(core))}\n`);
});
const receipt = await runProducerCell(isolated.input, receiptPath, withHelper({ adapter, paidExecutionAuthorized: true }, launcher));
expectIntegrityReceipt(receipt, receiptPath, { code: 'PRODUCER_HELPER_GENERATION_CHANGED', reason: 'installed helper identity changed during the run' });
expectIntegrityReceipt(receipt, receiptPath, { code: 'PRODUCER_HELPER_GENERATION_CHANGED', reason: 'helper installation check: installed helper identity changed during the run' });
});
test('records a failed receipt with usage when the provider output exceeds the limit', async () => {
@@ -937,7 +940,50 @@ describe('CSO matched producer orchestration', () => {
const isolated = isolate(destination, cell), receiptPath = join(root(), `${cell.id}.json`);
const output = 'x'.repeat(32 * 1024 * 1024 + 1);
const receipt = await runProducerCell(isolated.input, receiptPath, withHelper({ adapter: new FakeAdapter(() => {}, 'gpt-5.4', 'gpt', { output }), paidExecutionAuthorized: true }));
expectIntegrityReceipt(receipt, receiptPath, { code: 'PRODUCER_OUTPUT_TOO_LARGE', reason: `output is ${output.length} bytes; the limit is ${32 * 1024 * 1024}` });
expectIntegrityReceipt(receipt, receiptPath, { code: 'PRODUCER_OUTPUT_TOO_LARGE', reason: `output size check: output is ${output.length} bytes; the limit is ${32 * 1024 * 1024}` });
});
test('accepts real helper artifact names the secret redactor misreads, and names the check that rejects the rest', async () => {
const { redact } = await import('../lib/cso/process');
const runId = `${Date.now()}-${'0123456789abcdef'}`;
let walletShaped = '';
while (!walletShaped) { const id = randomHex(16); if (redact(id) !== id) walletShaped = id; }
expect(redact(runId)).not.toBe(runId);
const repo = 'e'.repeat(24);
const accepted = [`${repo}/${runId}/report.json`, `${repo}/${runId}/reviews/${walletShaped}.json`, `${repo}/${runId}/scanner-outcomes/gitleaks-${randomHex(8)}-${randomHex(8)}.json`,
`${repo}/${runId}/snapshot/test/fixtures/cso-canary-token.txt`, 'public-cache/osv/index.json'];
for (const path of accepted) expect(() => assertProducerArtifactPath(path)).not.toThrow();
for (const [path, message] of [
[`run-1/report.json`, 'INVALID_PRODUCER_ARTIFACTS: an artifact lies outside'],
[`${repo}/report.json`, 'INVALID_PRODUCER_ARTIFACTS: an artifact lies outside'],
[`${repo}/${runId}/bad name.json`, 'INVALID_PRODUCER_ARTIFACTS: an artifact path has a component outside'],
] as const) expect(() => assertProducerArtifactPath(path)).toThrow(message);
const planted = `sk-ant-api03-${randomHex(24)}`;
expect(redact(planted)).not.toBe(planted);
let failure: unknown;
try { assertProducerArtifactPath(`${repo}/${runId}/notes/${planted}.txt`); } catch (error) { failure = error; }
expect((failure as CsoError).code).toBe('REDACTION_FAILED');
expect((failure as Error).message).toBe('artifact path component 4 under "ID/ID/notes" looks like a secret; the component is withheld');
const destination = join(root(), 'planted-artifact-prepared'), cell = selected[0];
prepareEvalJobs(producerMatrix, skills, destination, [cell.id]);
const isolated = isolate(destination, cell), receiptPath = join(root(), `${cell.id}.json`);
const adapter = new FakeAdapter(() => {
const directory = join(process.env.GSTACK_HOME!, 'security', 'cso', repo, runId, 'notes');
mkdirSync(directory, { recursive: true }); writeFileSync(join(directory, `${planted}.txt`), 'x');
}, 'gpt-5.4', 'gpt', { error: { code: 'timeout', reason: 'exceeded 1ms' } });
const receipt = await runProducerCell(isolated.input, receiptPath, withHelper({ adapter, paidExecutionAuthorized: true }));
expectIntegrityReceipt(receipt, receiptPath, { code: 'REDACTION_FAILED', reason: 'artifact inventory check: artifact path component 4 under "ID/ID/notes" looks like a secret; the component is withheld; the provider run also failed (timeout)' });
expect(readFileSync(receiptPath, 'utf8')).not.toContain(planted);
});
test('gives the provider the budget plus the reporting grace and keeps a timed-out run\'s usage and last result', async () => {
expect(producerProviderTimeoutMs(1800)).toBe(2100 * 1000);
const destination = join(root(), 'timeout-prepared'), cell = selected[0];
prepareEvalJobs(producerMatrix, skills, destination, [cell.id]);
const isolated = isolate(destination, cell), receiptPath = join(root(), `${cell.id}.json`);
const adapter = new FakeAdapter(() => {}, 'gpt-5.4', 'gpt', { output: 'last assistant text before the kill', error: { code: 'timeout', reason: 'exceeded 2100000ms' } });
const receipt = await runProducerCell(isolated.input, receiptPath, withHelper({ adapter, paidExecutionAuthorized: true }));
expect(receipt).toMatchObject({ status: 'failed', error: { code: 'timeout', reason: 'exceeded 2100000ms' }, output: 'last assistant text before the kill', toolCalls: 4, usage: { inputTokens: 120, outputTokens: 30, cachedTokens: 10, estimatedCostUSD: 0.0042 } });
});
test('names the first offending source path and rule in a failed receipt that keeps usage', async () => {
@@ -957,7 +1003,7 @@ describe('CSO matched producer orchestration', () => {
const schedule = prepareEvalJobs(producerMatrix, skills, destination, [cell.id]);
const isolated = isolate(destination, cell), receiptPath = join(root(), `${cell.id}.json`);
const receipt = await runProducerCell(isolated.input, receiptPath, withHelper({ adapter: new FakeAdapter(opts => mutate(opts.csoProducer!.sourceDirectory, outside)), paidExecutionAuthorized: true }));
expectIntegrityReceipt(receipt, receiptPath, { code: 'INVALID_PRODUCER_SOURCE', reason });
expectIntegrityReceipt(receipt, receiptPath, { code: 'INVALID_PRODUCER_SOURCE', reason: `source content check: ${reason}` });
const group = collectProducerReceipts(producerMatrix, schedule, [receipt]).summary.groups.find(item => item.version === cell.version && item.mode === cell.mode);
expect(group).toMatchObject({ submitted: 1, succeeded: 0, failed: 1, estimatedCost: { measured: 1, totalUSD: 0.0042 } });
}
@@ -1044,8 +1090,8 @@ describe('CSO matched producer orchestration', () => {
});
const written = await runProducerCell(isolated.input, receipt, withHelper({ adapter, paidExecutionAuthorized: true }));
expectIntegrityReceipt(written, receipt, process.platform === 'win32'
? { code: 'INVALID_PRODUCER_SOURCE', reason: `"app.mjs" is ${'changed by producer\n'.length} bytes; expected ${Buffer.byteLength(original)}` }
: { code: 'PRODUCER_CHANGED_SOURCE_MODE', reason: '"app.mjs" has mode 644; expected 444' });
? { code: 'INVALID_PRODUCER_SOURCE', reason: `source content check: "app.mjs" is ${'changed by producer\n'.length} bytes; expected ${Buffer.byteLength(original)}` }
: { code: 'PRODUCER_CHANGED_SOURCE_MODE', reason: 'source modes check: "app.mjs" has mode 644; expected 444' });
expect(readFileSync(join(isolated.source, 'app.mjs'), 'utf8')).not.toBe(original);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { spawn } from 'child_process';
import type { RunResult } from './types';
/** Usage, turns and the latest text from `claude -p --output-format stream-json --verbose --include-partial-messages`. */
export interface ClaudeStreamState {
totals: { input: number; output: number; cached: number };
/** The message each stream (main thread or a tool's subagent) is currently receiving. */
open: Map<string, { input: number; output: number; cached: number }>;
turns: number;
lastText: string;
result?: Record<string, unknown>;
invalidLines: number;
}
export function claudeStreamState(): ClaudeStreamState {
return { totals: { input: 0, output: 0, cached: 0 }, open: new Map(), turns: 0, lastText: '', invalidLines: 0 };
}
const count = (value: unknown): number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
function close(state: ClaudeStreamState, key: string): void {
const message = state.open.get(key);
if (!message) return;
state.totals.input += message.input; state.totals.output += message.output; state.totals.cached += message.cached;
state.open.delete(key);
}
export function consumeClaudeStreamLine(state: ClaudeStreamState, line: string): void {
if (!line.trim()) return;
let event: any;
try { event = JSON.parse(line); } catch { state.invalidLines++; return; }
if (!event || typeof event !== 'object') { state.invalidLines++; return; }
if (event.type === 'result') { state.result = event; return; }
if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
for (const block of event.message.content) if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) state.lastText = block.text;
return;
}
if (event.type !== 'stream_event' || !event.event) return;
const key = typeof event.parent_tool_use_id === 'string' ? event.parent_tool_use_id : 'main';
const stream = event.event;
if (stream.type === 'message_start') {
close(state, key);
const usage = stream.message?.usage ?? {};
state.open.set(key, { input: count(usage.input_tokens), output: count(usage.output_tokens), cached: count(usage.cache_read_input_tokens) });
if (key === 'main') state.turns++;
} else if (stream.type === 'message_delta' && stream.usage) {
const message = state.open.get(key);
if (!message) return;
// Anthropic message_delta usage is cumulative for the message.
if (stream.usage.output_tokens !== undefined) message.output = count(stream.usage.output_tokens);
if (stream.usage.input_tokens !== undefined) message.input = count(stream.usage.input_tokens);
if (stream.usage.cache_read_input_tokens !== undefined) message.cached = count(stream.usage.cache_read_input_tokens);
} else if (stream.type === 'message_stop') {
close(state, key);
}
}
/** Every message seen so far, including ones a kill interrupted mid-stream. */
export function claudeStreamUsage(state: ClaudeStreamState): { input: number; output: number; cached: number } {
const totals = { ...state.totals };
for (const message of state.open.values()) { totals.input += message.input; totals.output += message.output; totals.cached += message.cached; }
return totals;
}
/**
* The final `result` event is authoritative for usage and turns; without it (a killed run) the per-message stream
* usage stands in. A paid producer still needs a successful result event with a nonblank `result`.
*/
export function resultFromClaudeStream(state: ClaudeStreamState, opts: { model: string; durationMs: number; error?: RunResult['error'] }): RunResult {
const result = state.result, usage = (result?.usage ?? undefined) as Record<string, unknown> | undefined;
const tokens = usage
? { input: count(usage.input_tokens), output: count(usage.output_tokens), cached: count(usage.cache_read_input_tokens) }
: claudeStreamUsage(state);
const toolCalls = result && Number.isSafeInteger(result.num_turns) ? result.num_turns as number : state.turns;
const modelUsed = typeof result?.model === 'string' && result.model ? result.model : opts.model;
const base = { tokens, durationMs: opts.durationMs, toolCalls, modelUsed };
if (opts.error) return { ...base, output: typeof result?.result === 'string' ? result.result : state.lastText, error: opts.error };
const explicitError = result?.is_error === true || (typeof result?.subtype === 'string' && result.subtype !== 'success');
if (!result || explicitError || typeof result.result !== 'string' || !result.result.trim()) {
return { ...base, output: '', error: { code: 'unknown', reason: 'empty or invalid output from claude CLI (exit 0)' } };
}
return { ...base, output: result.result };
}
const LINE_LIMIT = 32 * 1024 * 1024, STDERR_LIMIT = 64 * 1024, KILL_GRACE_MS = 10_000;
/** Run the producer's claude, folding its event stream as it arrives so a timeout still leaves usage behind. */
export function runClaudeProducerStream(command: string, args: string[], opts: { input: string; cwd: string; env: Record<string, string>; timeoutMs: number; model: string }): Promise<RunResult> {
return new Promise(resolve => {
const started = Date.now(), state = claudeStreamState();
let pending = '', stderr = '', timedOut = false, overflow = false, spawnError: Error | undefined;
let killTimer: ReturnType<typeof setTimeout> | undefined;
const child = spawn(command, args, { cwd: opts.cwd, env: opts.env, stdio: ['pipe', 'pipe', 'pipe'] });
const stop = () => { child.kill('SIGTERM'); killTimer = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS); };
const timer = setTimeout(() => { timedOut = true; stop(); }, opts.timeoutMs);
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
pending += chunk;
let newline: number;
while ((newline = pending.indexOf('\n')) >= 0) { consumeClaudeStreamLine(state, pending.slice(0, newline)); pending = pending.slice(newline + 1); }
if (pending.length > LINE_LIMIT && !overflow) { overflow = true; pending = ''; stop(); }
});
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk: string) => { if (stderr.length < STDERR_LIMIT) stderr += chunk.slice(0, STDERR_LIMIT - stderr.length); });
child.stdin.on('error', () => {});
let settled = false, exitTimer: ReturnType<typeof setTimeout> | undefined;
child.on('error', error => { spawnError = error; if (child.pid === undefined) finish(null, null); });
// A grandchild that inherited stdout must not hold the result open after claude itself exits.
child.on('exit', (code, signal) => { exitTimer = setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); finish(code, signal); }, 2000); });
child.on('close', (code, signal) => finish(code, signal));
function finish(code: number | null, signal: NodeJS.Signals | null): void {
if (settled) return;
settled = true;
clearTimeout(timer); if (killTimer) clearTimeout(killTimer); if (exitTimer) clearTimeout(exitTimer);
if (!overflow) consumeClaudeStreamLine(state, pending);
const durationMs = Date.now() - started;
const fail = (error: NonNullable<RunResult['error']>) => resolve(resultFromClaudeStream(state, { model: opts.model, durationMs, error }));
if (timedOut) return fail({ code: 'timeout', reason: `exceeded ${opts.timeoutMs}ms` });
if (overflow) return fail({ code: 'unknown', reason: `claude stream-json line exceeded ${LINE_LIMIT} bytes` });
if (spawnError) return fail({ code: 'unknown', reason: spawnError.message.slice(0, 400) });
if (code !== 0) {
if (/unauthorized|auth|login/i.test(stderr)) return fail({ code: 'auth', reason: stderr.slice(0, 400) });
if (/rate[- ]?limit|429/i.test(stderr)) return fail({ code: 'rate_limit', reason: stderr.slice(0, 400) });
return fail({ code: 'unknown', reason: `Command failed: ${[command, ...args].join(' ')}${signal ? ` (signal ${signal})` : ''}\n${stderr}`.slice(0, 400) });
}
resolve(resultFromClaudeStream(state, { model: opts.model, durationMs }));
}
child.stdin.end(opts.input);
});
}
+10 -4
View File
@@ -16,14 +16,15 @@ import * as path from 'path';
import * as os from 'os';
import { resolveClaudeCommand } from '../../../lib/claude-bin';
import { resolveEvalModel } from '../../../lib/eval-model';
import { runClaudeProducerStream } from './claude-stream';
/**
* Claude adapter — wraps the `claude` CLI via claude -p.
*
* For brevity and to avoid duplicating the full stream-json parser, this adapter
* uses claude CLI in non-interactive mode (--print) with the simpler JSON output
* format. If richer event-level metrics are needed (per-tool timing etc.),
* swap to session-runner's full stream-json parser.
* format. CSO producers stream events instead (claude-stream.ts) so a timed-out
* run still reports its usage.
*/
export class ClaudeAdapter implements ProviderAdapter {
readonly name = 'claude';
@@ -85,7 +86,10 @@ export class ClaudeAdapter implements ProviderAdapter {
}
try {
if (stateDirectory) prepareClaudeProducerState(stateDirectory);
if (stateDirectory) {
prepareClaudeProducerState(stateDirectory);
return await runClaudeProducerStream(resolved.command, args, { input: opts.prompt, cwd: claudeExecWorkingDirectory(opts), env, timeoutMs: opts.timeoutMs, model });
}
const out = execFileSync(resolved.command, args, {
input: opts.prompt,
cwd: claudeExecWorkingDirectory(opts),
@@ -222,8 +226,10 @@ export function assertClaudeProducerSandboxPolicy(args: readonly string[], env:
}
export function claudeExecArgs(opts: RunOpts, model: string, argsPrefix: readonly string[] = []): string[] {
const args = [...argsPrefix, '-p', '--output-format', 'json', '--model', model];
const stateDirectory = csoProducerStateDirectory(opts);
// Producers stream events so a timed-out or killed run still reports the usage it spent.
const output = stateDirectory ? ['stream-json', '--verbose', '--include-partial-messages'] : ['json'];
const args = [...argsPrefix, '-p', '--output-format', ...output, '--model', model];
if (stateDirectory) {
if (opts.extraArgs?.length) throw new Error('CSO producer does not accept extra provider arguments');
const tools = claudeProducerTools(opts);
+1 -1
View File
@@ -73,7 +73,7 @@ describe('CSO producer provider policies', () => {
]);
const producer = { ...common, csoProducer: policy(state, source, helper) };
expect(claudeExecArgs(producer, 'claude-test')).toEqual([
'-p', '--output-format', 'json', '--model', 'claude-test',
'-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--model', 'claude-test',
'--restricted', '--safe-mode', '--no-session-persistence',
'--permission-prompts', 'none', '--permission-mode', 'dontAsk',
'--tools', 'Bash,Write',