fix(cso-eval): run the trusted launcher outside Claude's sandbox; keep every other command sandboxed

Claude Code 2.1.263 forces every Bash command into its bubblewrap sandbox
while CLAUDE_CODE_SUBPROCESS_ENV_SCRUB is set, excludedCommands
notwithstanding. Inside it the launcher's owner check sees root as nobody
and the Docker socket is unreachable, so comprehensive cells degraded to
static-only results.

- drop CLAUDE_CODE_SUBPROCESS_ENV_SCRUB for producers; pass --settings with
  sandbox enabled, failIfUnavailable, no unsandboxed fallback, no
  auto-allow, and excludedCommands naming only the exact launcher path
- ClaudeAdapter.run refuses to start the agent
  (CLAUDE_PRODUCER_SANDBOX_POLICY_MISMATCH) unless args and env carry
  exactly that policy
- README containment note and CHANGELOG updated
This commit is contained in:
garrytan committed 2026-10-09 05:07:52 +00:00
1 parent 4350c0621d
commit f175f8554a
4 files changed
+82 -9

No files matched your search

+39 -3
View File
@@ -77,13 +77,15 @@ export class ClaudeAdapter implements ProviderAdapter {
}
const model = opts.model ?? process.env.EVALS_MODEL ?? resolveEvalModel('capture');
const stateDirectory = csoProducerStateDirectory(opts);
const args = claudeExecArgs(opts, model, resolved.argsPrefix);
const env = claudeExecEnvironment(opts, process.env);
if (stateDirectory) {
assertClaudeProducerPrerequisites();
assertClaudeProducerSandboxPolicy(args, env, csoProducerHelperLauncher(opts)!);
}
try {
if (stateDirectory) prepareClaudeProducerState(stateDirectory);
const args = claudeExecArgs(opts, model, resolved.argsPrefix);
const out = execFileSync(resolved.command, args, {
input: opts.prompt,
cwd: claudeExecWorkingDirectory(opts),
@@ -92,7 +94,7 @@ export class ClaudeAdapter implements ProviderAdapter {
maxBuffer: 32 * 1024 * 1024,
// Default GSTACK_HEADLESS=1 so a benchmark run classifies as headless (an
// AskUserQuestion failure BLOCKs rather than emitting unanswerable prose).
env: claudeExecEnvironment(opts, process.env),
env,
});
return resultFromClaudeOutput(out,{model,durationMs:Date.now()-start,producer:!!opts.csoProducer});
} catch (err: unknown) {
@@ -185,6 +187,40 @@ export function claudeProducerTools(opts: RunOpts): string {
return [`Bash(${launcher})`, `Bash(${launcher} *)`, 'Write'].join(',');
}
/** Claude's sandbox wraps every Bash command except the trusted launcher, which must run where users run it. */
export function claudeProducerSettings(launcher: string): { sandbox: Record<string, unknown> } {
return {
sandbox: {
enabled: true,
failIfUnavailable: true,
allowUnsandboxedCommands: false,
autoAllowBashIfSandboxed: false,
excludedCommands: [launcher, `${launcher} *`],
},
};
}
export function assertClaudeProducerSandboxPolicy(args: readonly string[], env: Record<string, string>, launcher: string): void {
const fail = (detail: string): never => { throw new Error(`CLAUDE_PRODUCER_SANDBOX_POLICY_MISMATCH: ${detail}`); };
const value = (flag: string): string => {
const positions = args.flatMap((arg, index) => arg === flag || arg.startsWith(`${flag}=`) ? [index] : []);
if (positions.length !== 1 || args[positions[0]] !== flag || positions[0] + 1 >= args.length) fail(`expected exactly one ${flag} <value>`);
return args[positions[0] + 1];
};
if (value('--allowed-tools') !== `Bash(${launcher}),Bash(${launcher} *),Write`) fail('--allowed-tools must name only the launcher and Write');
const raw = value('--settings');
let settings: unknown;
try { settings = JSON.parse(raw); } catch { fail('--settings is not JSON'); }
const expected = { enabled: true, failIfUnavailable: true, allowUnsandboxedCommands: false, autoAllowBashIfSandboxed: false, excludedCommands: [launcher, `${launcher} *`] };
const sandbox = (settings as { sandbox?: Record<string, unknown> } | null)?.sandbox;
if (!settings || typeof settings !== 'object' || Object.keys(settings).join() !== 'sandbox' || !sandbox || typeof sandbox !== 'object') fail('--settings must hold only a sandbox object');
if (Object.keys(sandbox!).sort().join() !== Object.keys(expected).sort().join()) fail('sandbox settings keys differ from the launcher-only exclusion policy');
for (const [key, wanted] of Object.entries(expected)) {
if (JSON.stringify(sandbox![key]) !== JSON.stringify(wanted)) fail(`sandbox.${key} must be ${JSON.stringify(wanted)}`);
}
if (env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB !== undefined) fail('CLAUDE_CODE_SUBPROCESS_ENV_SCRUB forces every command, the launcher included, into the sandbox');
}
export function claudeExecArgs(opts: RunOpts, model: string, argsPrefix: readonly string[] = []): string[] {
const args = [...argsPrefix, '-p', '--output-format', 'json', '--model', model];
const stateDirectory = csoProducerStateDirectory(opts);
@@ -202,6 +238,7 @@ export function claudeExecArgs(opts: RunOpts, model: string, argsPrefix: readonl
'--allowed-tools', tools,
'--add-dir', claudeProducerPaths(stateDirectory).workdir,
'--add-dir', helperHome,
'--settings', JSON.stringify(claudeProducerSettings(csoProducerHelperLauncher(opts)!)),
'--strict-mcp-config',
'--no-chrome',
);
@@ -219,7 +256,6 @@ export function claudeExecEnvironment(
return {
...(opts.csoProducer ? csoProducerChildEnvironment('claude', source) : source),
...(paths ? { HOME: paths.home, GSTACK_HOME: csoProducerHelperHome(opts)! } : {}),
...(stateDirectory ? { CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1' } : {}),
GSTACK_HEADLESS: '1',
} as Record<string, string>;
}
+34 -2
View File
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { ClaudeAdapter, claudeExecArgs, claudeExecEnvironment, claudeExecWorkingDirectory, claudeProducerPaths, resultFromClaudeOutput } from './helpers/providers/claude';
import { assertClaudeProducerSandboxPolicy, ClaudeAdapter, claudeExecArgs, claudeExecEnvironment, claudeExecWorkingDirectory, claudeProducerPaths, resultFromClaudeOutput } from './helpers/providers/claude';
import { codexExecArgs, codexExecEnvironment, codexExecWorkingDirectory, codexProducerConfig, codexProducerPaths, GptAdapter, resultFromCodexStream } from './helpers/providers/gpt';
import { geminiExecArgs, geminiExecEnvironment, geminiExecWorkingDirectory, geminiProducerPaths, geminiProducerSystemSettings } from './helpers/providers/gemini';
import { CSO_PRODUCER_SHELL_ENV, csoProducerChildEnvironment } from './helpers/providers/types';
@@ -80,6 +80,7 @@ describe('CSO producer provider policies', () => {
'--allowed-tools', `Bash(${helper}),Bash(${helper} *),Write`,
'--add-dir', claudeProducerPaths(state).workdir,
'--add-dir', join(state, 'cso-home'),
'--settings', JSON.stringify({ sandbox: { enabled: true, failIfUnavailable: true, allowUnsandboxedCommands: false, autoAllowBashIfSandboxed: false, excludedCommands: [helper, `${helper} *`] } }),
'--strict-mcp-config', '--no-chrome',
]);
expect(claudeExecArgs(producer, 'claude-test')).not.toContain(source);
@@ -88,6 +89,37 @@ describe('CSO producer provider policies', () => {
expect(claudeExecArgs(producer, 'claude-test').join(' ')).not.toMatch(/bypassPermissions|danger/);
});
test('fails the Claude producer closed unless its sandbox exempts exactly the launcher', () => {
const work = join(tmpdir(), 'gstack-claude-sandbox-policy');
const state = join(work, 'state');
const helper = helperAt(join(work, 'installed'));
const producer = { prompt: '', workdir: work, timeoutMs: 1, csoProducer: policy(state, join(work, 'source'), helper) };
const args = claudeExecArgs(producer, 'claude-test'), env = claudeExecEnvironment(producer, { PATH: '/bin', ANTHROPIC_API_KEY: 'key' });
expect(() => assertClaudeProducerSandboxPolicy(args, env, helper)).not.toThrow();
const at = args.indexOf('--settings'), settings = JSON.parse(args[at + 1]);
const withSettings = (change: (sandbox: Record<string, unknown>) => void) => {
const next = structuredClone(settings); change(next.sandbox);
return args.map((arg, index) => index === at + 1 ? JSON.stringify(next) : arg);
};
const withoutSettings = args.filter((_, index) => index !== at && index !== at + 1);
for (const [tampered, tamperedEnv, detail] of [
[withoutSettings, env, 'expected exactly one --settings'],
[[...args, '--settings', args[at + 1]], env, 'expected exactly one --settings'],
[[...withoutSettings, `--settings=${args[at + 1]}`], env, 'expected exactly one --settings'],
[withSettings(sandbox => { sandbox.excludedCommands = [helper, `${helper} *`, 'cat']; }), env, 'sandbox.excludedCommands'],
[withSettings(sandbox => { sandbox.excludedCommands = [`${helper}-link`, `${helper}-link *`]; }), env, 'sandbox.excludedCommands'],
[withSettings(sandbox => { sandbox.allowUnsandboxedCommands = true; }), env, 'sandbox.allowUnsandboxedCommands'],
[withSettings(sandbox => { delete sandbox.autoAllowBashIfSandboxed; }), env, 'keys differ'],
[withSettings(sandbox => { sandbox.enabled = false; }), env, 'sandbox.enabled'],
[withSettings(sandbox => { sandbox.dangerouslyDisableSandbox = true; }), env, 'keys differ'],
[args.map(arg => arg.startsWith('Bash(') ? `${arg},Bash(cat *)` : arg), env, '--allowed-tools'],
[args, { ...env, CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1' }, 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB'],
] as Array<[string[], Record<string, string>, string]>) {
expect(() => assertClaudeProducerSandboxPolicy(tampered, tamperedEnv, helper)).toThrow(`CLAUDE_PRODUCER_SANDBOX_POLICY_MISMATCH: `);
expect(() => assertClaudeProducerSandboxPolicy(tampered, tamperedEnv, helper)).toThrow(detail);
}
});
test('keeps exact Gemini defaults and replaces deprecated yolo only for the producer', () => {
const work = join(tmpdir(), 'gstack-gemini-work');
const state = join(work, 'state');
@@ -154,7 +186,7 @@ describe('CSO producer provider policies', () => {
const claudePaths = claudeProducerPaths(state);
expect(claudeExecEnvironment(common, sourceEnv)).toEqual({
...safe, HOME: claudePaths.home, GSTACK_HOME: join(state, 'cso-home'),
ANTHROPIC_API_KEY: 'anthropic-auth', CLAUDE_CODE_OAUTH_TOKEN: 'claude-auth', CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1',
ANTHROPIC_API_KEY: 'anthropic-auth', CLAUDE_CODE_OAUTH_TOKEN: 'claude-auth',
});
const geminiPaths = geminiProducerPaths(state);
expect(geminiExecEnvironment(common, sourceEnv)).toEqual({