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

+8 -3
View File
@@ -2,24 +2,29 @@
## [1.91.68.0] - 2026-10-08
**A /cso eval cell whose post-run checks fail now still records what it spent, and Claude producers no longer break their own source check.**
**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.**
The first paid baseline smoke cells ran the agent to completion, then stopped with a bare `INVALID_PRODUCER_SOURCE` and no receipt. That meant no tokens, no cost and no clue which file was at fault. The cause was the producer's own Claude launch. It passed the sealed source copy to Claude as an `--add-dir`. Claude Code treats each added directory as a sandbox write root and blocks writes to its `.mcp.json`. With no `.mcp.json` present, bubblewrap creates an empty one on the host as a mount point. Claude Code can't remove it from the read-only directory afterwards, so the source check rightly failed.
### What this means for you
- Claude producers no longer receive the source as an added directory. The constrained launcher reads the sealed copy through the sandbox's read-only root, so the source stays byte-for-byte unchanged.
- Claude producers no longer receive the source as an added directory, so the source stays byte-for-byte unchanged.
- The trusted launcher now runs outside Claude Code's sandbox, as it does for users. Inside the sandbox, root-owned paths look like they belong to `nobody` and the Docker socket is unreachable. So the launcher's own checks failed, and every comprehensive cell quietly fell back to a static-only review: `Private state ancestor has an unexpected owner` and `ISOLATION_FAILED: Docker endpoint is not a local Unix socket`. Every other Bash command still runs sandboxed and is denied by the allowed-tools list. Write stays inside the provider work directory.
- When a post-run integrity check fails, the producer writes a receipt with `status: "failed"` instead of no receipt. The receipt carries the check's code and reason, plus the run's usage, tokens, estimated cost, duration and tool calls. This applies to a changed source, mode or Git state, a changed helper or provider installation, an artifact problem, and oversized output. That receipt has no output and no artifacts, and it never counts as succeeded.
- `INVALID_PRODUCER_SOURCE` and `PRODUCER_CHANGED_SOURCE_MODE` name the first offending relative path and the rule it broke, for example `".mcp.json" is not in the expected source listing`. They never include file contents.
### Itemized changes
#### Fixed
- `claudeExecArgs` drops `--add-dir <source>` for CSO producers. The README's containment note now explains why.
- `claudeExecArgs` drops `--add-dir <source>` for CSO producers.
- `claudeExecArgs` passes `--settings` with a sandbox policy for CSO producers: enabled, fail if unavailable, no unsandboxed fallback, no auto-allow for sandboxed commands, and `excludedCommands` holding only the exact launcher path from `--allowed-tools`. It no longer sets `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, which in Claude Code 2.1.263 forces every command into the sandbox, `excludedCommands` notwithstanding. The launcher still replaces its environment with a fixed allowlist before it runs anything.
- `ClaudeAdapter.run` refuses to start a producer agent (`CLAUDE_PRODUCER_SANDBOX_POLICY_MISMATCH`) unless the generated arguments and environment carry exactly that policy.
- The README's containment note explains both.
- `runProducerCell` turns post-run check failures into failed receipts that keep usage. A redaction failure still withholds the receipt, as before.
- Source validation reports a missing or extra path, the hard-link count, a size or hash mismatch, symlinks and special files, and a Git `include` directive, each with its path.
#### For contributors
- The sandbox-policy check has tests for a missing, duplicated or `=`-joined `--settings`, a widened or symlinked exclusion, fallback or auto-allow turned on, extra keys, widened allowed tools, and the scrub variable.
- New tests cover a failed receipt for a provider race, a helper-generation change, oversized output, four source mutations (including a leftover empty `.mcp.json`), and a mode change. Each test checks the usage numbers and confirms collection counts the cell as failed.
## [1.91.67.0] - 2026-10-08
+1 -1
View File
@@ -412,7 +412,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
The private paid CSO evaluation producer is a packaging contract, not an ordinary `bun run build` artifact. On macOS or Linux, a release operator compiles `cso-eval-producer` with the documented hardened Bun flags in the same clean build session as `bun run build:cso`, then installs it beside `gstack-cso-launcher`, `gstack-cso-core`, `gstack-cso-watchdog`, and the hidden `.gstack-cso-generation` manifest as one root-owned, nonwritable five-artifact unit. The producer rejects root execution, writable/symlinked/incomplete installations, and unreviewed provider CLI versions; each receipt binds all five artifact hashes, and collection rejects receipts from different unit identities. See the [clean producer procedure](test/fixtures/cso-eval/README.md#trusted-five-artifact-producer-unit). Paid producer evaluation remains unavailable on Windows because the detached watchdog has no Windows build.
Each producer gets a curated one-cell source copy with directories sealed to `0555` and files to `0444`, plus pre/post content, Git, and mode checks. Claude receives no source add-directory: its constrained launcher command reads the sealed copy through the sandbox's read-only root, and an add-directory would be a sandbox write root where Claude Code's bubblewrap leaves an empty `.mcp.json` mount point. Gemini receives no source working directory or include-directory. Codex technically receives read-only filesystem access to the exact curated source root because the trusted helper inherits the Codex permission profile; its private provider work directory and exact `cso-home` artifact directory are the only write roots, every other root path remains denied, and the producer prompt requires source access through the helper. This is evaluation containment for an immutable public fixture, not a claim that Codex cannot directly read that fixture.
Each producer gets a curated one-cell source copy with directories sealed to `0555` and files to `0444`, plus pre/post content, Git, and mode checks. Claude receives no source add-directory, because an add-directory is a sandbox write root where Claude Code's bubblewrap leaves an empty `.mcp.json` mount point. Its Bash runs inside Claude Code's sandbox with one exception: the exact absolute launcher path that `--allowed-tools` names, which runs outside it as it does for users. The launcher is the boundary there. It replaces its environment with a fixed allowlist, uses only the local Docker Unix socket, and runs every target in hardened containers. The producer passes that policy through `--settings` (sandbox enabled, fail if unavailable, no unsandboxed fallback, no auto-allow, and `excludedCommands` naming only the launcher). It refuses to start the agent (`CLAUDE_PRODUCER_SANDBOX_POLICY_MISMATCH`) unless the generated arguments and environment carry exactly that policy. `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` stays unset, because it would force the launcher back into the sandbox. Every other Bash command is denied by `--allowed-tools`, and Write is confined to the provider work directory and `cso-home`. Gemini receives no source working directory or include-directory. Codex technically receives read-only filesystem access to the exact curated source root because the trusted helper inherits the Codex permission profile; its private provider work directory and exact `cso-home` artifact directory are the only write roots, every other root path remains denied, and the producer prompt requires source access through the helper. This is evaluation containment for an immutable public fixture, not a claim that Codex cannot directly read that fixture.
Copy each completed cell's receipt together with `state/cso-home/security/cso/` to the trusted adjudication host. Receipt entries are sorted paths relative to that directory and bind every retained file's size and SHA-256 plus an aggregate inventory hash. Provider homes, settings, sessions, and credentials live under separate disposable directories and are removed after the cell; they are never part of the retained artifact tree. The adjudicator must re-hash the transported tree against the receipt before trusting reports or repair bundles.
+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({