mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
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:
1 parent
4350c0621d
commit
f175f8554a
4 files changed
+82
-9
No files matched your search
@@ -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>;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user