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