From 1103fcbee991dfd3cf51986280922850f99aa7a5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:56:10 -0700 Subject: [PATCH 1/3] feat(cli): workspace Claude seats run on Linux under bwrap A workspace Claude seat on Linux was refused. bwrap made the project the child's HOME, so transcripts would land in the person's project, and nothing carried the Linux Claude login into the jail. The seat now gets the per-seat state folder the macOS path uses, outside the project. bwrap binds it writable and makes it HOME, the project is read-only unless the seat holds the write grant, and the project's .git stays read-only either way. Claude Code on Linux keeps its login in .credentials.json; each spawn copies the person's file into the state folder at 0600, refuses a symlink or non-regular source, and removes the copy when the person has logged out. Nothing is written back: the jail can write its copy, and a write-back would let it replace the person's login. TASK-440 reads the transcript from the state folder. bwrap is checked before any state is prepared, so a host that cannot jail never gets a login copy. The public bwrap seat is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu --- .../adapters.claude.environment.test.mjs | 175 ++++++++++++++++- cli/__tests__/bwrap.test.mjs | 28 +++ cli/src/lib/adapters/claude.js | 182 +++++++++++++++--- cli/src/lib/sandbox/bwrap.js | 21 +- 4 files changed, 373 insertions(+), 33 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 322cc8112f..d0463abdbc 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1386,11 +1386,12 @@ describe('claude adapter — a workspace seat', () => { } }); - test('under bwrap it refuses to spawn', async () => { + test('under bwrap on a Mac it refuses before preparing any state', async () => { const { impl, calls } = makeSpawnImpl(); await expect(workspaceSpawn(impl, { mode: 'bwrap', trust: 'workspace' })) - .rejects.toThrow(/workspace Claude agents run on macOS only for now/); + .rejects.toThrow(/workspace Claude agents require bwrap: bwrap is Linux-only/); expect(calls).toHaveLength(0); + expect(fs.readdirSync(publicState)).toEqual([]); }); test('with an unenforced mode it refuses rather than falls through to a bare spawn', async () => { @@ -1401,6 +1402,176 @@ describe('claude adapter — a workspace seat', () => { }); }); +// A workspace seat on Linux runs under bwrap. bwrap used to make the project +// its HOME, so its transcripts landed in the person's project and it had no +// login, and the adapter refused the combination. The seat now gets the same +// per-seat state folder macOS uses, outside the project, with a copy of the +// person's Claude login in it. +describe('claude adapter — a workspace seat on Linux', () => { + const originalPlatform = process.platform; + let cwd; + let state; + let operatorClaude; + const login = '{"claudeAiOauth":{"accessToken":"test-only"}}\n'; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-linux-ws-')); + state = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-linux-state-')); + operatorClaude = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-linux-login-')); + fs.writeFileSync(path.join(operatorClaude, '.credentials.json'), login, { mode: 0o600 }); + Object.defineProperty(process, 'platform', { value: 'linux' }); + }); + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + for (const dir of [cwd, state, operatorClaude]) fs.rmSync(dir, { recursive: true, force: true }); + }); + + // Plays the child: it looks for its login under the HOME it was given and + // writes its transcript there, as claude does. + const childImpl = () => { + const calls = []; + const impl = (cmd, args, opts) => { + // bwrap sets the child's HOME with --setenv; that is the one it sees. + const home = args[args.indexOf('HOME') + 1]; + const loginPath = path.join(home, '.claude', '.credentials.json'); + const foundLogin = fs.existsSync(loginPath) ? fs.readFileSync(loginPath, 'utf8') : null; + const sid = args[args.indexOf('--session-id') + 1]; + const dir = path.join(home, '.claude', 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-')); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${sid}.jsonl`), `${JSON.stringify({ + type: 'assistant', + message: { usage: { input_tokens: 1, cache_creation_input_tokens: 2, cache_read_input_tokens: 40 } }, + })}\n`); + calls.push({ cmd, args, opts, foundLogin }); + return fakeChild({ stdout: 'ok' }); + }; + return { impl, calls }; + }; + + const linuxSpawn = (impl, sandbox = { trust: 'workspace' }, env = {}) => claude.spawn('hi', { + agentName: 'linux-workspace-test', + sessionId: null, + cwd, + env: { PATH: process.env.PATH, CLAUDE_CONFIG_DIR: operatorClaude, ...env }, + environment: { model: 'test-model', sandbox }, + memoryLongTerm: '', + _publicClaudeState: state, + _detectBwrap: () => ({ available: true, path: 'bwrap' }), + _spawnImpl: impl, + }); + + const flagTriples = (args, flag) => args.flatMap((arg, i) => ( + arg === flag ? [[args[i + 1], args[i + 2]]] : [] + )); + + test('runs in bwrap with a HOME outside the project, finds its login, and writes nothing into the project', async () => { + const { impl, calls } = childImpl(); + const res = await linuxSpawn(impl); + + expect(calls).toHaveLength(1); + const { cmd, args, opts, foundLogin } = calls[0]; + expect(cmd).toBe('bwrap'); + const outer = args.slice(0, args.indexOf('--')); + expect(outer.slice(outer.indexOf('HOME') - 1, outer.indexOf('HOME') + 2)) + .toEqual(['--setenv', 'HOME', state]); + expect(opts.env.HOME).toBe(state); + expect(opts.env.CLAUDE_CONFIG_DIR).toBeUndefined(); + // The state folder is the only writable bind; with no write grant the + // project is read-only. + expect(flagTriples(outer, '--bind')).toEqual([[state, state]]); + expect(flagTriples(outer, '--ro-bind')).toContainEqual([cwd, cwd]); + + expect(foundLogin).toBe(login); + const copy = path.join(state, '.claude', '.credentials.json'); + expect(fs.lstatSync(copy).isFile()).toBe(true); + expect(fs.statSync(copy).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.join(state, '.claude')).mode & 0o777).toBe(0o700); + expect(fs.readdirSync(cwd)).toEqual([]); + // TASK-440 reads the transcript from the HOME the child wrote under. + expect(res.contextTokens).toBe(43); + + // The seat runs the workspace tier's policy, not the public seat's. + const inner = args.slice(args.indexOf('--') + 1); + expect(inner).toEqual(expect.arrayContaining(['--permission-mode', 'dontAsk'])); + expect(inner.join(' ')).toContain('Read(./.env)'); + }); + + test('a write grant makes the project writable and keeps its .git read-only', async () => { + const { impl, calls } = childImpl(); + await linuxSpawn(impl, { trust: 'workspace', allow: ['write'] }); + + const outer = calls[0].args.slice(0, calls[0].args.indexOf('--')); + expect(flagTriples(outer, '--bind')).toEqual([[state, state], [cwd, cwd]]); + const gitDir = path.join(cwd, '.git'); + const triples = flagTriples(outer, '--ro-bind-try'); + expect(triples).toContainEqual([gitDir, gitDir]); + // .git is bound after the project, so it lands on top of it. + const bindAt = (flag, p) => outer.findIndex((arg, i) => arg === flag && outer[i + 1] === p); + expect(bindAt('--ro-bind-try', gitDir)).toBeGreaterThan(bindAt('--bind', cwd)); + }); + + test('refuses a login that is a symlink, and spawns nothing', async () => { + const real = path.join(operatorClaude, 'elsewhere.json'); + fs.renameSync(path.join(operatorClaude, '.credentials.json'), real); + fs.symlinkSync(real, path.join(operatorClaude, '.credentials.json')); + const { impl, calls } = childImpl(); + + await expect(linuxSpawn(impl)).rejects.toThrow(/refusing a Claude login that is not a regular file/); + expect(calls).toHaveLength(0); + expect(fs.existsSync(path.join(state, '.claude', '.credentials.json'))).toBe(false); + }); + + test('replaces a symlink the jail left at the copy, rather than writing through it', async () => { + const victim = path.join(operatorClaude, 'victim.txt'); + fs.writeFileSync(victim, 'untouched'); + fs.mkdirSync(path.join(state, '.claude'), { mode: 0o700 }); + fs.symlinkSync(victim, path.join(state, '.claude', '.credentials.json')); + const { impl, calls } = childImpl(); + + await linuxSpawn(impl); + expect(calls[0].foundLogin).toBe(login); + expect(fs.lstatSync(path.join(state, '.claude', '.credentials.json')).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(victim, 'utf8')).toBe('untouched'); + }); + + test('a person who logged out is not still signed in here', async () => { + fs.rmSync(path.join(operatorClaude, '.credentials.json')); + fs.mkdirSync(path.join(state, '.claude'), { mode: 0o700 }); + fs.writeFileSync(path.join(state, '.claude', '.credentials.json'), login, { mode: 0o600 }); + const { impl, calls } = childImpl(); + + await linuxSpawn(impl); + expect(calls[0].foundLogin).toBeNull(); + }); + + test('a host with no usable bwrap refuses before preparing any state', async () => { + const { impl, calls } = childImpl(); + await expect(claude.spawn('hi', { + agentName: 'linux-workspace-test', + sessionId: null, + cwd, + env: { PATH: process.env.PATH, CLAUDE_CONFIG_DIR: operatorClaude }, + environment: { model: 'test-model', sandbox: { trust: 'workspace' } }, + _publicClaudeState: state, + _detectBwrap: () => ({ available: false, error: 'bwrap not found on PATH.' }), + _spawnImpl: impl, + })).rejects.toThrow(/workspace Claude agents require bwrap: bwrap not found/); + expect(calls).toHaveLength(0); + expect(fs.readdirSync(state)).toEqual([]); + }); + + test('a public Linux seat keeps its old jail: the project is its HOME and nothing else is writable', async () => { + const { impl, calls } = childImpl(); + await linuxSpawn(impl, { trust: 'public' }); + + const outer = calls[0].args.slice(0, calls[0].args.indexOf('--')); + expect(outer.slice(outer.indexOf('HOME') - 1, outer.indexOf('HOME') + 2)) + .toEqual(['--setenv', 'HOME', cwd]); + expect(flagTriples(outer, '--bind')).toEqual([[cwd, cwd]]); + expect(fs.readdirSync(state)).toEqual([]); + }); +}); + describe('claude adapter — a workspace seat\'s git tool', () => { let cwd; let publicState; diff --git a/cli/__tests__/bwrap.test.mjs b/cli/__tests__/bwrap.test.mjs index 52f28dd9f9..2b3e7d2ca9 100644 --- a/cli/__tests__/bwrap.test.mjs +++ b/cli/__tests__/bwrap.test.mjs @@ -181,6 +181,34 @@ describe('wrapArgvWithBwrap', () => { expect(argv).not.toContain('/home/operator/.config/commonly'); }); + itLinux('binds an adapter-owned HOME writable, separate from a read-only workspace', () => { + const argv = wrapArgvWithBwrap(['claude'], {}, { + workspacePath: '/tmp/ws', + workspaceReadOnly: true, + homePath: '/home/u/.commonly/claude-homes/abc', + writablePaths: ['/home/u/.commonly/claude-homes/abc'], + readOnlyWorkspaceEntries: ['.git'], + }); + const at = (flag, p) => argv.findIndex((arg, i) => arg === flag && argv[i + 1] === p && argv[i + 2] === p); + expect(at('--bind', '/home/u/.commonly/claude-homes/abc')).toBeGreaterThan(-1); + expect(at('--ro-bind', '/tmp/ws')).toBeGreaterThan(-1); + expect(at('--bind', '/tmp/ws')).toBe(-1); + expect(at('--ro-bind-try', '/tmp/ws/.git')).toBeGreaterThan(at('--ro-bind', '/tmp/ws')); + const homeIndex = argv.indexOf('HOME'); + expect(argv[homeIndex + 1]).toBe('/home/u/.commonly/claude-homes/abc'); + }); + + itLinux('rejects a relative writable path and a workspace entry that leaves the workspace', () => { + expect(() => wrapArgvWithBwrap(['claude'], {}, { + workspacePath: '/tmp/ws', writablePaths: ['state'], + })).toThrow(/writablePaths entries must be absolute/); + for (const entry of ['../outside', '/etc', 'a/../../b']) { + expect(() => wrapArgvWithBwrap(['claude'], {}, { + workspacePath: '/tmp/ws', readOnlyWorkspaceEntries: [entry], + })).toThrow(/relative names inside the workspace/); + } + }); + itLinux('rejects relative adapter-owned transient paths', () => { expect(() => wrapArgvWithBwrap( ['claude'], diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 2c7ef54c39..6c675cda47 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -57,6 +57,7 @@ import { mkdtemp, readFile, readlink, + rename, rm, symlink, unlink, @@ -208,6 +209,29 @@ const statOrNull = async (path) => { } }; +// The non-secret account metadata Claude needs from ~/.claude.json to find its +// login, and nothing else from that file. +const claudeAuthMetadata = async () => { + const operatorConfigPath = join(homedir(), '.claude.json'); + const sourceStat = await statOrNull(operatorConfigPath); + let source = {}; + if (sourceStat) { + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + throw new Error(`refusing non-regular operator Claude config: ${operatorConfigPath}`); + } + try { + source = JSON.parse(await readFile(operatorConfigPath, 'utf8')); + } catch (err) { + throw new Error(`failed to read operator Claude auth metadata: ${err.message}`); + } + } + const sanitized = { hasCompletedOnboarding: true }; + for (const key of ['installMethod', 'userID', 'machineID', 'oauthAccount']) { + if (source[key] !== undefined) sanitized[key] = source[key]; + } + return sanitized; +}; + // Claude's macOS OAuth credential lives in Keychain, but Claude also needs a // small amount of non-secret account metadata from ~/.claude.json to locate // it. Never bind or copy the operator's full file: it contains project paths, @@ -248,35 +272,91 @@ const preparePublicClaudeState = async (ctx) => { await symlink(operatorKeychains, stateKeychains); } - const operatorConfigPath = join(homedir(), '.claude.json'); - const sourceStat = await statOrNull(operatorConfigPath); - let source = {}; - if (sourceStat) { - if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { - throw new Error(`refusing non-regular operator Claude config: ${operatorConfigPath}`); - } - try { - source = JSON.parse(await readFile(operatorConfigPath, 'utf8')); - } catch (err) { - throw new Error(`failed to read operator Claude auth metadata: ${err.message}`); - } - } - - const sanitized = { - hasCompletedOnboarding: true, - }; - for (const key of ['installMethod', 'userID', 'machineID', 'oauthAccount']) { - if (source[key] !== undefined) sanitized[key] = source[key]; - } await writeFile( configPath, - `${JSON.stringify(sanitized, null, 2)}\n`, + `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }, ); await chmod(configPath, 0o600); return { statePath, tmpPath, configPath }; }; +// Writes a 0600 file into a directory the confined child can also write. The +// directory must still be a real directory, and the file is written beside the +// target and renamed over it, so a symlink the child left at either name is +// replaced, never followed into another of the person's files. +const replacePrivateFile = async (dir, name, content) => { + const dirStat = await statOrNull(dir); + if (!dirStat || dirStat.isSymbolicLink() || !dirStat.isDirectory()) { + throw new Error(`refusing to write Claude state into a non-directory: ${dir}`); + } + const target = join(dir, name); + const staged = join(dir, `.${name}.${randomUUID()}.tmp`); + await writeFile(staged, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + try { + await chmod(staged, 0o600); + await rename(staged, target); + } catch (err) { + await rm(staged, { force: true }); + throw err; + } + return target; +}; + +// A workspace seat on Linux runs under bwrap, which has no keychain. Claude +// Code on Linux keeps its login in /.credentials.json, so the +// seat's own state dir gets a copy of the person's, taken fresh each spawn: +// their own credential on their own machine, the exposure the macOS keychain +// arm already has. Only the copy is inside the jail. Nothing is written back: +// the jail can write its copy, and a write-back would let it replace the +// person's login with one of its choosing. A missing login removes the copy, +// so a person who logged out is not still signed in here. +const claudeLoginSource = (env) => join( + env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), + '.credentials.json', +); + +const carryClaudeLogin = async (env, configDir) => { + const source = claudeLoginSource(env); + const sourceStat = await statOrNull(source); + if (sourceStat && (sourceStat.isSymbolicLink() || !sourceStat.isFile())) { + throw new Error(`refusing a Claude login that is not a regular file: ${source}`); + } + if (!sourceStat) { + await rm(join(configDir, '.credentials.json'), { force: true }); + return null; + } + return replacePrivateFile(configDir, '.credentials.json', await readFile(source, 'utf8')); +}; + +// State for a workspace seat under bwrap: the same per-seat folder the macOS +// path uses, outside the person's project, with the auth metadata whitelist +// and the login copy. The jail binds this folder writable and makes it HOME, +// so transcripts land here rather than in the project. +const prepareWorkspaceBwrapClaudeState = async (ctx) => { + const identity = ctx.agentName || ctx.cwd || 'anonymous'; + const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20); + const statePath = ctx._publicClaudeState || publicClaudeStateRoot(identityHash); + const tmpPath = join(statePath, 'tmp'); + const configDir = join(statePath, '.claude'); + await mkdir(statePath, { recursive: true, mode: 0o700 }); + await chmod(statePath, 0o700); + for (const dir of [tmpPath, configDir]) { + const existing = await statOrNull(dir); + if (existing && (existing.isSymbolicLink() || !existing.isDirectory())) { + throw new Error(`refusing Claude state path that is not a directory: ${dir}`); + } + if (!existing) await mkdir(dir, { mode: 0o700 }); + await chmod(dir, 0o700); + } + + const configPath = await replacePrivateFile( + statePath, '.claude.json', `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`, + ); + const loginPath = await carryClaudeLogin(ctx.env, configDir); + return { statePath, tmpPath, configPath, loginPath }; +}; + const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, publicSeat = false) => { const source = input || process.env; const output = state || publicSeat ? {} : { ...source }; @@ -732,6 +812,9 @@ const prepareArgv = async (innerArgv, ctx) => { // get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on // the operator's own Claude settings inside the namespace (Vera 69578). const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap'; + // A workspace seat on Linux: the same jail, with the workspace tier's policy + // and a HOME of its own outside the project. + const workspaceBwrapSandbox = sandboxTrust === 'workspace' && sandboxMode === 'bwrap'; const commitHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null; const originalHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null; const commitHookExecutables = commitHooksPath @@ -754,12 +837,6 @@ const prepareArgv = async (innerArgv, ctx) => { // non-string) used to fall through to the bare `claude` spawn below — an // unconfined seat with a public record, which is how a record can claim // confinement it never applies (Vera 69548). - // bwrap points the child's HOME at its folder (childHome below), so a - // workspace seat's transcripts would be written into the person's project. - if (sandboxTrust === 'workspace' && sandboxMode === 'bwrap') { - throw new Error('workspace Claude agents run on macOS only for now: under bwrap ' - + 'their transcripts would be written into the project folder'); - } if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') { throw new Error( `${sandboxTrust} Claude agents require an enforced sandbox mode ` @@ -818,23 +895,60 @@ const prepareArgv = async (innerArgv, ctx) => { }; } - if (publicBwrapSandbox) { + if (publicBwrapSandbox || workspaceBwrapSandbox) { // The mode is chosen here, so the mechanism is verified here: a missing // bwrap must fail the seat's derivation with an actionable message, not // every one of its spawns with a bare ENOENT (Wren 69586). On a non-Linux // host this reports the macOS-only message, same as the wrapper would. const bwrap = (ctx._detectBwrap || detectBwrap)(); if (!bwrap.available) { - throw new Error(`public Claude agents require bwrap: ${bwrap.error}`); + throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`); + } + if (workspaceBwrapSandbox && !ctx.publicClaudeState) { + throw new Error('workspace Claude state was not prepared before argv construction'); } innerArgv = [ ...innerArgv, - ...buildPublicClaudePolicyArgs(allowedPatterns, { memoryWithheld: ctx.memoryWithheld === true }), + ...buildPublicClaudePolicyArgs(allowedPatterns, workspaceBwrapSandbox + ? { trust: sandboxTrust, allow: grants, memoryWithheld: ctx.memoryWithheld === true } + : { memoryWithheld: ctx.memoryWithheld === true }), ...(ctx.publicSkillArgs || []), ]; } else if (allowedPatterns.length > 0) { innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns]; } + if (workspaceBwrapSandbox) { + const claudeBin = resolveClaudePath(claudeEnv); + const { statePath } = ctx.publicClaudeState; + let claudeInstall = null; + try { + claudeInstall = dirname(realpathSync(claudeBin)); + } catch { /* an unresolvable binary fails at spawn with its own error */ } + const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, { + workspacePath: ctx.cwd, + workspaceReadOnly: !grants.includes('write'), + readOnlyWorkspaceEntries: ['.git'], + homePath: statePath, + writablePaths: [statePath], + readOnlyPaths: [ + ctx.mcpConfigDir, commitHooksPath, originalHooksPath, + ...(ctx.mcpReadDirectories || []), + ...(ctx.workspaceGit?.readDirectories || []), + ...(ctx.workspaceGit?.readPaths || []), + ].filter(Boolean), + // The binaries the jail runs: claude (a user-local install is outside + // the default binds, and its link points into a versions folder), the + // node that runs the stdio MCP servers, and the git the history tool runs. + optionalReadOnlyPaths: [...new Set([ + dirname(claudeBin), claudeInstall, dirname(process.execPath), + ctx.workspaceGit ? dirname(ctx.workspaceGit.executable) : null, + // A claude not found on PATH is left bare and fails at spawn; its + // dirname is '.', which is not a path to bind. + ].filter((p) => p && isAbsolute(p)))], + }); + // TASK-440 reads the transcript from the HOME the child wrote under. + return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv, childHome: statePath }; + } if (sandboxMode === 'bwrap') { const claudeBin = resolveClaudePath(claudeEnv); const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, { @@ -974,6 +1088,8 @@ export default { && PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox)); const publicBwrapSandbox = publicTrust && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap'; + const workspaceBwrapSandbox = ctx.environment?.sandbox?.trust === 'workspace' + && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap'; try { // Trust is normalized at entry (legacy `internal` reads as public). The // MCP policy follows trust alone, whatever confines the seat: a public @@ -981,6 +1097,12 @@ export default { // (Rhea on #2247). if (publicNativeSandbox) { publicClaudeState = await preparePublicClaudeState(ctx); + } else if (workspaceBwrapSandbox) { + // Checked before the state is prepared, so a host that cannot run the + // jail never gets a copy of the login. + const bwrap = (ctx._detectBwrap || detectBwrap)(); + if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`); + publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx); } if (publicTrust) { const prompt = await publicSkillPromptArgs(mountedSkills); diff --git a/cli/src/lib/sandbox/bwrap.js b/cli/src/lib/sandbox/bwrap.js index 1a967d9c32..9d5c7e66d8 100644 --- a/cli/src/lib/sandbox/bwrap.js +++ b/cli/src/lib/sandbox/bwrap.js @@ -161,7 +161,26 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => { flags.push('--ro-bind-try', p, p); } - flags.push('--bind', opts.workspacePath, opts.workspacePath); + // A HOME the adapter keeps outside the workspace (a workspace seat's state) + // is bound writable on its own, so the child's transcripts and login stay + // out of the person's project. + for (const p of opts.writablePaths || []) { + if (!p || !isAbsolute(p)) { + throw new Error('wrapArgvWithBwrap opts.writablePaths entries must be absolute paths'); + } + flags.push('--bind', p, p); + } + + flags.push(opts.workspaceReadOnly ? '--ro-bind' : '--bind', opts.workspacePath, opts.workspacePath); + // Entries held read-only inside a writable workspace (its .git). Bound after + // the workspace so they land on top of it. + for (const entry of opts.readOnlyWorkspaceEntries || []) { + if (!entry || isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) { + throw new Error('wrapArgvWithBwrap opts.readOnlyWorkspaceEntries entries must be relative names inside the workspace'); + } + const p = join(opts.workspacePath, entry); + flags.push('--ro-bind-try', p, p); + } flags.push('--chdir', opts.workspacePath); flags.push('--setenv', 'HOME', opts.homePath || opts.workspacePath); From 680643b7e6fe64ca702d06ef96625711888b91a2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:09:43 -0700 Subject: [PATCH 2/3] fix(cli): a writable Linux Claude seat needs a .git to hold read-only bwrap can only mount a path that exists, so --ro-bind-try on a missing .git bound nothing and left the jail free to create .git/hooks in the person's project. Refuse a write grant on a project without a real .git directory, and bind .git strictly in a writable workspace so a .git that vanishes before the spawn stops bwrap. Read-only seats are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu --- .../adapters.claude.environment.test.mjs | 38 +++++++++++++++++-- cli/__tests__/bwrap.test.mjs | 9 +++++ cli/src/lib/adapters/claude.js | 11 ++++++ cli/src/lib/sandbox/bwrap.js | 7 +++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index d0463abdbc..166baffe9c 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1497,17 +1497,47 @@ describe('claude adapter — a workspace seat on Linux', () => { }); test('a write grant makes the project writable and keeps its .git read-only', async () => { + const gitDir = path.join(cwd, '.git'); + fs.mkdirSync(gitDir); const { impl, calls } = childImpl(); await linuxSpawn(impl, { trust: 'workspace', allow: ['write'] }); const outer = calls[0].args.slice(0, calls[0].args.indexOf('--')); expect(flagTriples(outer, '--bind')).toEqual([[state, state], [cwd, cwd]]); - const gitDir = path.join(cwd, '.git'); - const triples = flagTriples(outer, '--ro-bind-try'); - expect(triples).toContainEqual([gitDir, gitDir]); + // Strict, not -try: a .git that vanished must stop bwrap, not go unbound. + expect(flagTriples(outer, '--ro-bind')).toContainEqual([gitDir, gitDir]); // .git is bound after the project, so it lands on top of it. const bindAt = (flag, p) => outer.findIndex((arg, i) => arg === flag && outer[i + 1] === p); - expect(bindAt('--ro-bind-try', gitDir)).toBeGreaterThan(bindAt('--bind', cwd)); + expect(bindAt('--ro-bind', gitDir)).toBeGreaterThan(bindAt('--bind', cwd)); + }); + + test('a write grant on a project with no .git refuses, and creates nothing in it', async () => { + const { impl, calls } = childImpl(); + await expect(linuxSpawn(impl, { trust: 'workspace', allow: ['write'] })) + .rejects.toThrow(/need the project to be a git repository/); + expect(calls).toHaveLength(0); + expect(fs.readdirSync(cwd)).toEqual([]); + }); + + test('a write grant refuses a .git that is a symlink', async () => { + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-linux-git-')); + try { + fs.symlinkSync(elsewhere, path.join(cwd, '.git')); + const { impl, calls } = childImpl(); + await expect(linuxSpawn(impl, { trust: 'workspace', allow: ['write'] })) + .rejects.toThrow(/need the project to be a git repository/); + expect(calls).toHaveLength(0); + } finally { + fs.rmSync(elsewhere, { recursive: true, force: true }); + } + }); + + test('without a write grant a project with no .git still runs, read-only', async () => { + const { impl, calls } = childImpl(); + await linuxSpawn(impl); + const outer = calls[0].args.slice(0, calls[0].args.indexOf('--')); + expect(flagTriples(outer, '--ro-bind')).toContainEqual([cwd, cwd]); + expect(fs.readdirSync(cwd)).toEqual([]); }); test('refuses a login that is a symlink, and spawns nothing', async () => { diff --git a/cli/__tests__/bwrap.test.mjs b/cli/__tests__/bwrap.test.mjs index 2b3e7d2ca9..cb48d7d017 100644 --- a/cli/__tests__/bwrap.test.mjs +++ b/cli/__tests__/bwrap.test.mjs @@ -198,6 +198,15 @@ describe('wrapArgvWithBwrap', () => { expect(argv[homeIndex + 1]).toBe('/home/u/.commonly/claude-homes/abc'); }); + itLinux('in a writable workspace a read-only entry is bound strictly, so a missing one stops bwrap', () => { + const argv = wrapArgvWithBwrap(['claude'], {}, { + workspacePath: '/tmp/ws', readOnlyWorkspaceEntries: ['.git'], + }); + const at = (flag, p) => argv.findIndex((arg, i) => arg === flag && argv[i + 1] === p && argv[i + 2] === p); + expect(at('--ro-bind', '/tmp/ws/.git')).toBeGreaterThan(at('--bind', '/tmp/ws')); + expect(at('--ro-bind-try', '/tmp/ws/.git')).toBe(-1); + }); + itLinux('rejects a relative writable path and a workspace entry that leaves the workspace', () => { expect(() => wrapArgvWithBwrap(['claude'], {}, { workspacePath: '/tmp/ws', writablePaths: ['state'], diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 6c675cda47..6ee72c5a84 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -918,6 +918,17 @@ const prepareArgv = async (innerArgv, ctx) => { innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns]; } if (workspaceBwrapSandbox) { + // Seatbelt denies writes under .git by path, present or not. A bwrap mount + // needs the path to exist, so a writable project without a .git could get + // one from the child, hooks included, that the person's next git command + // runs. Refuse that rather than create anything in their project. + if (grants.includes('write')) { + const gitEntry = await statOrNull(join(ctx.cwd, '.git')); + if (!gitEntry || gitEntry.isSymbolicLink()) { + throw new Error('workspace Claude agents with write on Linux need the project to be a git ' + + `repository (run \`git init\` in ${ctx.cwd}), so its .git can be held read-only`); + } + } const claudeBin = resolveClaudePath(claudeEnv); const { statePath } = ctx.publicClaudeState; let claudeInstall = null; diff --git a/cli/src/lib/sandbox/bwrap.js b/cli/src/lib/sandbox/bwrap.js index 9d5c7e66d8..d22ea5e53f 100644 --- a/cli/src/lib/sandbox/bwrap.js +++ b/cli/src/lib/sandbox/bwrap.js @@ -173,13 +173,16 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => { flags.push(opts.workspaceReadOnly ? '--ro-bind' : '--bind', opts.workspacePath, opts.workspacePath); // Entries held read-only inside a writable workspace (its .git). Bound after - // the workspace so they land on top of it. + // the workspace so they land on top of it. In a writable workspace a missing + // entry cannot be protected — the child could create it, and a planted + // .git/hooks runs on the person's next git command — so the bind is strict + // there and bwrap fails rather than starts. for (const entry of opts.readOnlyWorkspaceEntries || []) { if (!entry || isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) { throw new Error('wrapArgvWithBwrap opts.readOnlyWorkspaceEntries entries must be relative names inside the workspace'); } const p = join(opts.workspacePath, entry); - flags.push('--ro-bind-try', p, p); + flags.push(opts.workspaceReadOnly ? '--ro-bind-try' : '--ro-bind', p, p); } flags.push('--chdir', opts.workspacePath); flags.push('--setenv', 'HOME', opts.homePath || opts.workspacePath); From f9ea10c9b76fca21a835ce52f3eef145f33080c7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:19:54 -0700 Subject: [PATCH 3/3] fix(cli): read the Claude login through one no-follow open The login copy checked the source with lstat and then read it by path, so a symlink swapped in between was followed. Open it once with O_NOFOLLOW (and O_NONBLOCK, so a FIFO cannot hold the open), check the opened file is regular, and read from that handle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu --- .../adapters.claude.environment.test.mjs | 9 +++++++ cli/src/lib/adapters/claude.js | 26 ++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 166baffe9c..0a088b65fb 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1551,6 +1551,15 @@ describe('claude adapter — a workspace seat on Linux', () => { expect(fs.existsSync(path.join(state, '.claude', '.credentials.json'))).toBe(false); }); + test('refuses a login that is not a regular file, checked on the opened file', async () => { + const login = path.join(operatorClaude, '.credentials.json'); + fs.rmSync(login); + fs.mkdirSync(login); + const { impl, calls } = childImpl(); + await expect(linuxSpawn(impl)).rejects.toThrow(/refusing a Claude login that is not a regular file/); + expect(calls).toHaveLength(0); + }); + test('replaces a symlink the jail left at the copy, rather than writing through it', async () => { const victim = path.join(operatorClaude, 'victim.txt'); fs.writeFileSync(victim, 'untouched'); diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 6ee72c5a84..3eff813427 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -55,6 +55,7 @@ import { lstat, mkdir, mkdtemp, + open, readFile, readlink, rename, @@ -316,17 +317,28 @@ const claudeLoginSource = (env) => join( '.credentials.json', ); +// Opened once, without following a symlink, and checked on the open handle, +// so the file read is the file checked (a FIFO cannot hold the open either). const carryClaudeLogin = async (env, configDir) => { const source = claudeLoginSource(env); - const sourceStat = await statOrNull(source); - if (sourceStat && (sourceStat.isSymbolicLink() || !sourceStat.isFile())) { - throw new Error(`refusing a Claude login that is not a regular file: ${source}`); + const refuse = () => new Error(`refusing a Claude login that is not a regular file: ${source}`); + let handle; + try { + handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + } catch (err) { + if (err?.code === 'ENOENT') { + await rm(join(configDir, '.credentials.json'), { force: true }); + return null; + } + if (err?.code === 'ELOOP') throw refuse(); + throw err; } - if (!sourceStat) { - await rm(join(configDir, '.credentials.json'), { force: true }); - return null; + try { + if (!(await handle.stat()).isFile()) throw refuse(); + return replacePrivateFile(configDir, '.credentials.json', await handle.readFile('utf8')); + } finally { + await handle.close(); } - return replacePrivateFile(configDir, '.credentials.json', await readFile(source, 'utf8')); }; // State for a workspace seat under bwrap: the same per-seat folder the macOS