diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 322cc8112f..0a088b65fb 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,215 @@ 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 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]]); + // 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', 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 () => { + 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('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'); + 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..cb48d7d017 100644 --- a/cli/__tests__/bwrap.test.mjs +++ b/cli/__tests__/bwrap.test.mjs @@ -181,6 +181,43 @@ 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('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'], + })).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..3eff813427 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -55,8 +55,10 @@ import { lstat, mkdir, mkdtemp, + open, readFile, readlink, + rename, rm, symlink, unlink, @@ -208,6 +210,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 +273,102 @@ 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', +); + +// 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 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; + } + try { + if (!(await handle.stat()).isFile()) throw refuse(); + return replacePrivateFile(configDir, '.credentials.json', await handle.readFile('utf8')); + } finally { + await handle.close(); + } +}; + +// 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 +824,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 +849,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 +907,71 @@ 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) { + // 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; + 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 +1111,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 +1120,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..d22ea5e53f 100644 --- a/cli/src/lib/sandbox/bwrap.js +++ b/cli/src/lib/sandbox/bwrap.js @@ -161,7 +161,29 @@ 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. 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(opts.workspaceReadOnly ? '--ro-bind-try' : '--ro-bind', p, p); + } flags.push('--chdir', opts.workspacePath); flags.push('--setenv', 'HOME', opts.homePath || opts.workspacePath);