diff --git a/docs/sbx-integration.md b/docs/sbx-integration.md index 77e43fa7c..2dd84a98d 100644 --- a/docs/sbx-integration.md +++ b/docs/sbx-integration.md @@ -175,8 +175,31 @@ What `createSandbox()` shares, in order: host-installed tools live here and are mounted straight in. Note it is *narrow*: only `/usr/local/bin`, **not** `/usr`, `/lib`, `/lib64`, or `/opt`. - **`/tmp`** — agent runtime files (rendered prompts, logs). - - **`$HOME`** (`$HOME || /home/runner`) — writable agent dirs (`.cache`, - `.config`, `.local`, `.anthropic`, `.copilot`, …). + - **`$HOME` tool dirs** — a **curated whitelist** of writable agent dirs, not + the whole home directory. The manager mounts only the subdirs that exist on + the host from `HOME_TOOL_SUBDIRS` (`.cache`, `.config`, `.local`, + `.anthropic`, `.claude`, `.cargo`, `.rustup`, `.npm`, `.nvm`) plus the agent + state dirs `.copilot` and `.gemini`. Credential-store dirs such as `.aws`, + `.ssh`, `.docker`, `.kube`, `.azure` and `.gnupg` are **never** whitelisted, + so they never enter the VM. Each whitelisted dir is mounted **wholesale** (as + a directory — sbx positional mounts cannot target an individual file, so its + loose files like `~/.copilot/mcp-config.json` are preserved). + +**Scrubbing nested credential stores.** Several whitelisted dirs legitimately +hold tool settings but also stash a secret in a well-known child — e.g. +`.config/gh`, `.config/gcloud`, `.cargo/credentials`, `.claude/.credentials.json`, +`.copilot/config.json`, `.gemini/oauth_creds.json`. Because the parent is mounted +wholesale and sbx cannot overlay or mask a nested path, the manager instead +**moves those credential paths aside on the host before `sbx create` and restores +them after the sandbox is torn down** (`scrubHomeCredentials` / +`restoreHomeCredentials` in `sbx-manager.ts`). The move target is a +`.awf-sbx-cred-backup-` dir at the home root — never a mounted subdir — so +the secrets are absent from the VM while the benign tool state stays available. +This is the sbx analog of compose mode's `/dev/null` credential overlays, and the +per-parent list (`CREDENTIAL_PATHS_BY_PARENT` in +`services/agent-volumes/home-whitelist.ts`) is shared to prevent drift. The agent +receives whatever credentials it needs through the api-proxy or environment, not +by reading the host's on-disk auth store, so removing these paths is safe. A `seenPaths` set deduplicates so no path is mounted twice, and `execInSandbox(..., { workDir })` passes `--workdir` so commands run inside the @@ -188,7 +211,7 @@ mounted workspace. | System libraries | From the sbx `shell` **guest image** | Host `/usr`,`/bin`,`/lib`,`/lib64`,`/opt` mounted read-only | | Toolchain binaries | Host `/usr/local/bin` mounted in | `/usr` from the host or sysroot; optional `chroot.binariesSourcePath` overlay at `/host/tmp/awf-runner-bin` (ro) | | Workspace | `workspaceDir` positional (rw) | `:/host:rw` | -| Home | Whole `$HOME` mounted (rw) | Empty home volume with only whitelisted subdirs | +| Home | Curated `$HOME` tool-dir whitelist (rw), nested credential stores scrubbed before create | Empty home volume with only whitelisted subdirs | :::caution Toolchain portability Because host system libraries are **not** shared into the VM, a binary in diff --git a/src/sbx-manager.test.ts b/src/sbx-manager.test.ts index 3c1e5ec2a..ac0da9d7e 100644 --- a/src/sbx-manager.test.ts +++ b/src/sbx-manager.test.ts @@ -3,9 +3,11 @@ import { execInSandbox, isSbxAvailable, removeSandbox, + restoreHomeCredentials, sanitizeEnvForSbx, SBX_DEFAULT_NAME, } from './sbx-manager'; +import * as fs from 'fs'; import { mockExecaFn } from './test-helpers/mock-execa.test-utils'; import { logger } from './logger'; @@ -13,6 +15,22 @@ import { logger } from './logger'; jest.mock('execa', () => require('./test-helpers/mock-execa.test-utils').execaMockFactory()); // eslint-disable-next-line @typescript-eslint/no-require-imports jest.mock('./logger', () => require('./test-helpers/mock-logger.test-utils').loggerMockFactory()); +// Mock fs so home-mount curation and credential scrub/restore are deterministic. +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + existsSync: jest.fn(() => false), + readdirSync: jest.fn(() => []), + renameSync: jest.fn(() => undefined), + mkdirSync: jest.fn(() => undefined), + rmSync: jest.fn(() => undefined), + }; +}); + +const mockedExistsSync = fs.existsSync as jest.Mock; +const mockedReaddirSync = fs.readdirSync as jest.Mock; +const mockedRenameSync = fs.renameSync as jest.Mock; const mockedLogger = jest.mocked(logger); @@ -81,7 +99,24 @@ describe('sbx-manager', () => { }); describe('createSandbox', () => { + beforeEach(() => { + // Default: no host $HOME subdirs exist, so home-mount curation is a no-op + // unless a test opts in. Individual tests re-mock as needed. + mockedExistsSync.mockReset(); + mockedExistsSync.mockReturnValue(false); + mockedReaddirSync.mockReset(); + mockedReaddirSync.mockReturnValue([]); + mockedRenameSync.mockReset(); + mockedRenameSync.mockReturnValue(undefined); + // Ensure no scrubbed state leaks between tests. + restoreHomeCredentials(); + mockedRenameSync.mockReset(); + mockedRenameSync.mockReturnValue(undefined); + }); + it('uses shell agent, configured mounts, and sanitized env', async () => { + // No host $HOME subdirs exist → only workspace, extra mounts, /tmp and + // /usr/local/bin are mounted (the whole $HOME is never mounted). mockExecaFn .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // auth check .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); // sbx create @@ -101,7 +136,6 @@ describe('sbx-manager', () => { '/tmp/gh-aw:ro', '/tmp', '/usr/local/bin', - process.env.HOME || '/home/runner', ], expect.objectContaining({ input: 'y\n', })); @@ -112,6 +146,153 @@ describe('sbx-manager', () => { expect(sbxCreateCall.env).toBeUndefined(); }); + it('never mounts the whole $HOME (only whitelisted subdirs that exist)', async () => { + const homePath = process.env.HOME || '/home/runner'; + // Simulate a host home that contains both tool dirs AND credential stores. + mockedExistsSync.mockImplementation((p: fs.PathLike) => { + const s = String(p); + return ( + s === `${homePath}/.cache` || + s === `${homePath}/.config` || + s === `${homePath}/.copilot` || + s === `${homePath}/.aws` || + s === `${homePath}/.ssh` || + s === `${homePath}/.docker` + ); + }); + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); + + await createSandbox({ workspaceDir: '/workspace', squidIp: '172.30.0.10' }); + + const args: string[] = mockExecaFn.mock.calls[1][1]; + // The whole home is never mounted... + expect(args).not.toContain(homePath); + // ...whitelisted tool dirs that exist ARE mounted... + expect(args).toContain(`${homePath}/.cache`); + // ...and credential stores are NEVER mounted, even though they exist. + expect(args).not.toContain(`${homePath}/.aws`); + expect(args).not.toContain(`${homePath}/.ssh`); + expect(args).not.toContain(`${homePath}/.docker`); + }); + + it('mounts credential-nesting tool dirs wholesale and scrubs nested secrets before create', async () => { + const homePath = process.env.HOME || '/home/runner'; + const parents = [ + `${homePath}/.cargo`, + `${homePath}/.claude`, + `${homePath}/.copilot`, + `${homePath}/.gemini`, + ]; + const secrets = [ + `${homePath}/.cargo/credentials`, + `${homePath}/.cargo/credentials.toml`, + `${homePath}/.claude/.credentials.json`, + `${homePath}/.copilot/config.json`, + `${homePath}/.gemini/oauth_creds.json`, + `${homePath}/.gemini/google_accounts.json`, + ]; + mockedExistsSync.mockImplementation( + (p: fs.PathLike) => parents.includes(String(p)) || secrets.includes(String(p)), + ); + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); + + await createSandbox({ workspaceDir: '/workspace', squidIp: '172.30.0.10' }); + + const args: string[] = mockExecaFn.mock.calls[1][1]; + // Parents ARE mounted wholesale (as directories) so their loose files work. + for (const parent of parents) expect(args).toContain(parent); + // No individual file is ever passed as a positional mount. + for (const secret of secrets) expect(args).not.toContain(secret); + // Each nested credential path is moved aside on the host before create. + const movedOriginals = mockedRenameSync.mock.calls.map((c) => String(c[0])); + for (const secret of secrets) expect(movedOriginals).toContain(secret); + + restoreHomeCredentials(); + }); + + it('mounts ~/.config wholesale and scrubs nested credential dirs before create', async () => { + const homePath = process.env.HOME || '/home/runner'; + const secrets = [ + `${homePath}/.config/gh`, + `${homePath}/.config/gcloud`, + `${homePath}/.config/rclone`, + ]; + mockedExistsSync.mockImplementation( + (p: fs.PathLike) => + String(p) === `${homePath}/.config` || secrets.includes(String(p)), + ); + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); + + await createSandbox({ workspaceDir: '/workspace', squidIp: '172.30.0.10' }); + + const args: string[] = mockExecaFn.mock.calls[1][1]; + // The parent .config IS mounted wholesale so benign tool config still works. + expect(args).toContain(`${homePath}/.config`); + // Known credential subdirs are moved aside before create, not mounted. + for (const secret of secrets) expect(args).not.toContain(secret); + const movedOriginals = mockedRenameSync.mock.calls.map((c) => String(c[0])); + for (const secret of secrets) expect(movedOriginals).toContain(secret); + + restoreHomeCredentials(); + }); + + it('restores scrubbed credentials after the sandbox is removed', async () => { + const homePath = process.env.HOME || '/home/runner'; + const secret = `${homePath}/.copilot/config.json`; + mockedExistsSync.mockImplementation( + (p: fs.PathLike) => + String(p) === `${homePath}/.copilot` || + String(p) === secret || + String(p).includes('.awf-sbx-cred-backup'), + ); + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); + + await createSandbox({ workspaceDir: '/workspace', squidIp: '172.30.0.10' }); + + // The secret was moved to a backup during create. + const createMoves = mockedRenameSync.mock.calls.map((c) => [String(c[0]), String(c[1])]); + const scrubMove = createMoves.find(([from]) => from === secret); + expect(scrubMove).toBeDefined(); + const backupPath = scrubMove![1]; + + mockedRenameSync.mockClear(); + // removeSandbox: stop + rm both succeed, then restore runs. + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await removeSandbox(SBX_DEFAULT_NAME); + + // The backup is moved back to its original location after teardown. + const restoreMoves = mockedRenameSync.mock.calls.map((c) => [String(c[0]), String(c[1])]); + expect(restoreMoves).toContainEqual([backupPath, secret]); + }); + + it('skips whitelisted home subdirs that do not exist on the host', async () => { + const homePath = process.env.HOME || '/home/runner'; + mockedExistsSync.mockImplementation( + (p: fs.PathLike) => String(p) === `${homePath}/.npm`, + ); + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); + + await createSandbox({ workspaceDir: '/workspace', squidIp: '172.30.0.10' }); + + const args: string[] = mockExecaFn.mock.calls[1][1]; + expect(args).toContain(`${homePath}/.npm`); + expect(args).not.toContain(`${homePath}/.cache`); + expect(args).not.toContain(`${homePath}/.rustup`); + }); + it('uses SBX_DEFAULT_NAME when no name provided', async () => { mockExecaFn .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // auth check @@ -258,6 +439,9 @@ describe('sbx-manager', () => { it('skips system paths already in workspace or dedup list', async () => { const home = process.env.HOME || '/home/runner'; + mockedExistsSync.mockImplementation( + (p: fs.PathLike) => String(p) === `${home}/.cache`, + ); mockExecaFn .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }); @@ -274,7 +458,9 @@ describe('sbx-manager', () => { const tmpCount = args.filter(a => a === '/tmp').length; expect(tmpCount).toBe(1); expect(args).toContain('/usr/local/bin'); - expect(args).toContain(home); + // The whole $HOME is never mounted; only existing whitelisted subdirs are. + expect(args).not.toContain(home); + expect(args).toContain(`${home}/.cache`); }); }); diff --git a/src/sbx-manager.ts b/src/sbx-manager.ts index 417db434d..c10e43bae 100644 --- a/src/sbx-manager.ts +++ b/src/sbx-manager.ts @@ -23,7 +23,10 @@ */ import execa from 'execa'; +import * as fs from 'fs'; +import * as path from 'path'; import { logger } from './logger'; +import { HOME_TOOL_SUBDIRS, CREDENTIAL_PATHS_BY_PARENT } from './services/agent-volumes/home-whitelist'; /** Name prefix for AWF-managed sandboxes. */ const SBX_NAME_PREFIX = 'awf-agent'; @@ -83,6 +86,105 @@ export function sanitizeEnvForSbx( return { ...clean, ...overrides }; } +/** Records a credential path that was moved aside before `sbx create`. */ +interface ScrubbedCredential { + /** Original host path (inside a wholesale-mounted home dir). */ + original: string; + /** Backup location the path was moved to (outside any mount). */ + backup: string; +} + +/** + * Credential paths moved aside for the current sandbox, plus the temp backup + * root that holds them. Module-level because scrub happens in createSandbox and + * restore happens later in removeSandbox (after the live mount is gone). + */ +let scrubbedCredentials: ScrubbedCredential[] = []; +let credentialBackupRoot: string | undefined; + +/** + * Moves known credential stores out of the wholesale-mounted `$HOME` tool dirs + * before the sandbox is created, so they never enter the microVM. The paths are + * moved (not deleted) to a backup dir at the home root — which is NOT one of the + * mounted subdirs — and restored by {@link restoreHomeCredentials} after the + * sandbox is torn down. This is the sbx analog of compose mode's `/dev/null` + * credential overlays. + */ +function scrubHomeCredentials(homePath: string): void { + scrubbedCredentials = []; + credentialBackupRoot = undefined; + + for (const [parent, names] of Object.entries(CREDENTIAL_PATHS_BY_PARENT)) { + const parentPath = path.join(homePath, parent); + // Parent isn't mounted (doesn't exist) → nothing nested to hide. + if (!fs.existsSync(parentPath)) continue; + + for (const name of names) { + const original = path.join(parentPath, name); + if (!fs.existsSync(original)) continue; + + if (!credentialBackupRoot) { + // A dotted dir at the home ROOT is never in the mounted subdir set, so + // the backup itself can't leak into the VM. + credentialBackupRoot = path.join(homePath, `.awf-sbx-cred-backup-${process.pid}`); + try { + fs.mkdirSync(credentialBackupRoot, { recursive: true }); + } catch (err) { + logger.warn(`[sbx] Could not create credential backup dir: ${(err as Error).message}`); + credentialBackupRoot = undefined; + return; + } + } + + const backup = path.join(credentialBackupRoot, `${parent}__${name}`.replace(/\//g, '_')); + try { + fs.renameSync(original, backup); + scrubbedCredentials.push({ original, backup }); + logger.info(`[sbx] Hid credential path from sandbox: ${parent}/${name}`); + } catch (err) { + logger.warn(`[sbx] Could not hide credential path ${original}: ${(err as Error).message}`); + } + } + } + + if (scrubbedCredentials.length > 0 && credentialBackupRoot) { + logger.info( + `[sbx] Moved ${scrubbedCredentials.length} credential path(s) aside to ${credentialBackupRoot} for the duration of the sandbox`, + ); + } +} + +/** + * Restores any credential paths that {@link scrubHomeCredentials} moved aside. + * Idempotent and non-throwing; safe to call even when nothing was scrubbed. + * MUST run only after the sandbox is removed, because the home dirs are live + * mounts — restoring while the VM is running would re-expose the secrets. + */ +export function restoreHomeCredentials(): void { + for (const { original, backup } of scrubbedCredentials) { + try { + if (fs.existsSync(backup)) { + fs.renameSync(backup, original); + } + } catch (err) { + logger.warn( + `[sbx] Could not restore credential path ${original} from ${backup}: ${(err as Error).message}. ` + + `The original is preserved at ${backup}.`, + ); + } + } + scrubbedCredentials = []; + + if (credentialBackupRoot) { + try { + fs.rmSync(credentialBackupRoot, { recursive: true, force: true }); + } catch { + // best-effort cleanup of the (now-empty) backup dir + } + credentialBackupRoot = undefined; + } +} + /** * Creates a Docker sbx sandbox with workspace mounts. * Sets `DOCKER_SANDBOXES_PROXY` to chain all egress through AWF's Squid. @@ -144,19 +246,51 @@ export async function createSandbox(config: SbxConfig): Promise { } } - // Mount /tmp so agent runtime files (prompts, logs) are accessible. - // Mount /usr/local/bin for Copilot CLI and other installed tools. - // Mount $HOME for agent writable dirs (.cache, .config, .local, etc.) - const homePath = process.env.HOME || '/home/runner'; - for (const sysPath of ['/tmp', '/usr/local/bin', homePath]) { + // Mount /tmp so agent runtime files (prompts, logs) are accessible, and + // /usr/local/bin for Copilot CLI and other installed tools. + for (const sysPath of ['/tmp', '/usr/local/bin']) { if (!seenPaths.has(sysPath)) { seenPaths.add(sysPath); args.push(sysPath); } } + // SECURITY: never mount the whole $HOME into the microVM. sbx mounts are + // positional (host path == guest path) and cannot express the per-file + // /dev/null credential overlays that compose mode uses (see + // credential-hiding.ts), so the only way to keep host secrets out of the VM + // is to curate which $HOME subdirs are mounted. We share the same whitelist + // as the compose chroot home strategy (HOME_TOOL_SUBDIRS) plus the agent + // state dirs (.copilot, .gemini). Credential stores such as ~/.aws, ~/.ssh, + // ~/.docker, ~/.kube, ~/.azure, ~/.gnupg, ~/.netrc and ~/.gitconfig are never + // whitelisted, so they never enter the sandbox. Only paths that exist on the + // host are mounted, because sbx requires the mount source to exist. + // + // Each whitelisted parent is mounted WHOLESALE (as a directory): sbx mounts + // are positional, directory-granular virtiofs passthroughs and cannot mount an + // individual file, so child-by-child expansion would drop loose files the + // agent needs (e.g. ~/.copilot/mcp-config.json). Several of these dirs also + // nest a credential store — e.g. .config/gh, .cargo/credentials, + // .claude/.credentials.json, .copilot/config.json, .gemini/oauth_creds.json. + // Those specific paths are moved aside on the host BEFORE `sbx create` (see + // scrubHomeCredentials below) and restored after teardown, so the benign tool + // state stays available while the secrets never enter the microVM. + const homePath = process.env.HOME || '/home/runner'; + const homeSubdirs = ['.copilot', ...HOME_TOOL_SUBDIRS, '.gemini']; + for (const subdir of homeSubdirs) { + const hostSubdir = `${homePath}/${subdir}`; + if (seenPaths.has(hostSubdir)) continue; + if (!fs.existsSync(hostSubdir)) continue; + seenPaths.add(hostSubdir); + args.push(hostSubdir); + } + logger.info(`[sbx] Running: sbx ${args.join(' ')}`); + // Move known credential stores out of the wholesale-mounted home dirs before + // the sandbox exists, and remember them so they can be restored on teardown. + scrubHomeCredentials(homePath); + // Do NOT pass a custom `env` to sbx create. The sanitized env (which strips // vars matching TOKEN, SECRET, KEY, etc.) also strips variables the sbx CLI // needs internally for credential lookup against the daemon's auth store. @@ -196,6 +330,9 @@ export async function createSandbox(config: SbxConfig): Promise { const exitCode = createResult.exitCode ?? 1; if (exitCode !== 0 && !sbxSucceeded) { + // Sandbox never came up — restore the scrubbed credentials immediately so a + // failed run doesn't leave the user's home directory mutated. + restoreHomeCredentials(); // Log full debug output for diagnostics if (stdout) logger.info(`[sbx] create stdout: ${stdout.substring(0, 2000)}`); if (stderr) logger.info(`[sbx] create stderr: ${stderr.substring(0, 2000)}`); @@ -291,9 +428,15 @@ export async function removeSandbox(name: string): Promise { logger.warn( `Failed to remove sandbox "${name}" (exit ${(rmResult.exitCode ?? 1)}${stderr ? `: ${stderr}` : ''})` ); + // Still restore credentials — the sandbox is being torn down regardless, and + // leaving the user's home scrubbed would be worse than a stale sandbox. + restoreHomeCredentials(); return; } + // Sandbox is gone (mounts released) → safe to move credentials back. + restoreHomeCredentials(); + logger.info(`Sandbox "${name}" removed`); } diff --git a/src/services/agent-volumes/home-strategy.ts b/src/services/agent-volumes/home-strategy.ts index 60167c1b8..f3d84bf08 100644 --- a/src/services/agent-volumes/home-strategy.ts +++ b/src/services/agent-volumes/home-strategy.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { logger } from '../../logger'; import { resolveRunnerToolCachePath } from '../../runner-tool-cache'; import { WrapperConfig } from '../../types'; +import { HOME_TOOL_SUBDIRS } from './home-whitelist'; interface HomeMountsParams { config: WrapperConfig; @@ -42,22 +43,14 @@ function buildToolDirectoryMounts(params: HomeMountsParams): string[] { mounts.push(`${sessionStatePath}:/host${effectiveHome}/.copilot/session-state:rw`); mounts.push(`${agentLogsPath}:/host${effectiveHome}/.copilot/logs:rw`); - mounts.push(`${effectiveHome}/.cache:/host${effectiveHome}/.cache:rw`); - mounts.push(`${effectiveHome}/.config:/host${effectiveHome}/.config:rw`); - mounts.push(`${effectiveHome}/.local:/host${effectiveHome}/.local:rw`); - - mounts.push(`${effectiveHome}/.anthropic:/host${effectiveHome}/.anthropic:rw`); - mounts.push(`${effectiveHome}/.claude:/host${effectiveHome}/.claude:rw`); + for (const subdir of HOME_TOOL_SUBDIRS) { + mounts.push(`${effectiveHome}/${subdir}:/host${effectiveHome}/${subdir}:rw`); + } if (config.geminiApiKey || config.googleApiKey) { mounts.push(`${effectiveHome}/.gemini:/host${effectiveHome}/.gemini:rw`); } - mounts.push(`${effectiveHome}/.cargo:/host${effectiveHome}/.cargo:rw`); - mounts.push(`${effectiveHome}/.rustup:/host${effectiveHome}/.rustup:rw`); - mounts.push(`${effectiveHome}/.npm:/host${effectiveHome}/.npm:rw`); - mounts.push(`${effectiveHome}/.nvm:/host${effectiveHome}/.nvm:rw`); - const runnerToolCacheDir = resolveRunnerToolCachePath(config, effectiveHome); if (runnerToolCacheDir) { mounts.push(`${runnerToolCacheDir}:/host${runnerToolCacheDir}:ro`); diff --git a/src/services/agent-volumes/home-whitelist.test.ts b/src/services/agent-volumes/home-whitelist.test.ts new file mode 100644 index 000000000..cea4517f6 --- /dev/null +++ b/src/services/agent-volumes/home-whitelist.test.ts @@ -0,0 +1,49 @@ +import { + HOME_TOOL_SUBDIRS, + CREDENTIAL_PATHS_BY_PARENT, +} from './home-whitelist'; + +describe('home-whitelist', () => { + it('never whitelists a top-level credential store directory', () => { + const forbidden = [ + '.aws', + '.ssh', + '.docker', + '.kube', + '.azure', + '.gnupg', + '.netrc', + '.gitconfig', + '.git-credentials', + ]; + for (const dir of forbidden) { + expect(HOME_TOOL_SUBDIRS as readonly string[]).not.toContain(dir); + } + }); + + it('enumerates the known nested credential paths for each tool dir', () => { + // Compose blanks these via /dev/null overlays; sbx moves them aside before + // `sbx create` and restores them after teardown. + expect(CREDENTIAL_PATHS_BY_PARENT['.config']).toEqual( + expect.arrayContaining(['gh', 'gcloud']), + ); + expect(CREDENTIAL_PATHS_BY_PARENT['.cargo']).toContain('credentials'); + expect(CREDENTIAL_PATHS_BY_PARENT['.claude']).toContain('.credentials.json'); + expect(CREDENTIAL_PATHS_BY_PARENT['.copilot']).toContain('config.json'); + expect(CREDENTIAL_PATHS_BY_PARENT['.gemini']).toContain('oauth_creds.json'); + }); + + it('only nests credential paths under mounted home subdirs', () => { + // Every credential parent must itself be a mounted home subdir (whitelisted + // tool dir, or an agent-state dir the sbx path adds: .copilot / .gemini), + // otherwise scrubbing it would be pointless (the parent never enters the VM). + const mountedHomeSubdirs = new Set([ + '.copilot', + ...HOME_TOOL_SUBDIRS, + '.gemini', + ]); + for (const parent of Object.keys(CREDENTIAL_PATHS_BY_PARENT)) { + expect(mountedHomeSubdirs.has(parent)).toBe(true); + } + }); +}); diff --git a/src/services/agent-volumes/home-whitelist.ts b/src/services/agent-volumes/home-whitelist.ts new file mode 100644 index 000000000..bd72bfe64 --- /dev/null +++ b/src/services/agent-volumes/home-whitelist.ts @@ -0,0 +1,95 @@ +/** + * Canonical whitelist of `$HOME` subdirectories that agents legitimately need + * (tool caches, language toolchains, agent state). + * + * This list is the single source of truth shared by **both** sandbox backends + * so their home-directory exposure stays in sync: + * + * - **Compose / chroot mode** (`home-strategy.ts`) mounts an empty home volume + * and then bind-mounts these subdirs on top, and additionally blanks known + * credential files with `/dev/null` overlays (`credential-hiding.ts`). + * - **sbx microVM mode** (`sbx-manager.ts`) mounts these subdirs individually + * instead of the whole `$HOME`. sbx uses positional (host path == guest path) + * mounts and cannot express per-file `/dev/null` overlays, so directory + * curation is its only mechanism — which makes this whitelist the primary + * protection there. + * + * SECURITY: never add a directory whose primary purpose is storing credentials + * (for example `.aws`, `.ssh`, `.docker`, `.kube`, `.azure`, `.gnupg`). Any such + * store must stay OUT of the sandbox. Directories listed here can still contain + * stray secret files; compose mode masks the known ones via + * `buildCredentialHidingOverlays()`, but sbx cannot, so keep this list to + * genuinely non-credential tooling paths. + * + * `.gemini` is intentionally NOT included: compose mode mounts it only when a + * Gemini/Google API key is configured, so each caller handles it separately. + */ +export const HOME_TOOL_SUBDIRS = [ + '.cache', + '.config', + '.local', + '.anthropic', + '.claude', + '.cargo', + '.rustup', + '.npm', + '.nvm', +] as const; + +/** + * Credential/token stores that live *inside* an otherwise-whitelisted `$HOME` + * subdir, keyed by the parent subdir's basename. Each value lists the immediate + * child basenames (directories **or** files) that are credential stores. + * + * Whitelisted dirs such as `.config`, `.cargo`, `.claude`, `.copilot` and + * `.gemini` are needed for legitimate tool settings/state, but each also stashes + * secrets in a well-known child: + * + * - `.config/gh`, `.config/gcloud`, … — per-CLI token stores + * - `.cargo/credentials`, `.cargo/credentials.toml` — crates.io registry tokens + * - `.claude/.credentials.json` — Claude Code OAuth tokens + * - `.copilot/config.json` — Copilot CLI can persist its token here + * - `.gemini/oauth_creds.json`, `.gemini/google_accounts.json` — Gemini OAuth + * + * Compose mode blanks these individual paths with `/dev/null` overlays + * (`credential-hiding.ts`). sbx mounts are positional virtiofs passthroughs + * (host path == guest path, directory-granular) and cannot overlay or mask an + * individual nested path, nor mount a single file. So the sbx backend instead + * mounts these parents **wholesale** (so their required files still work) but + * temporarily **moves these credential paths aside on the host before + * `sbx create` and restores them after the sandbox is torn down** — keeping the + * secrets out of the VM without dropping the benign tool state the agent needs. + * + * SECURITY: entries here are credential-centric. The agent receives whatever + * credentials it legitimately needs through the API proxy or environment, not by + * reading the host's on-disk auth store, so hiding these paths is safe. + */ +export const CREDENTIAL_PATHS_BY_PARENT: Readonly> = { + '.config': [ + 'gh', // GitHub CLI: hosts.yml (oauth_token) + 'gcloud', // Google Cloud SDK: credentials.db, access_tokens.db, application_default_credentials.json + 'doctl', // DigitalOcean CLI: config.yaml (access token) + 'heroku', // Heroku CLI: credential store + 'hub', // legacy hub CLI: oauth token + 'rclone', // rclone.conf: remote credentials + 'containers', // containers/auth.json: registry credentials + 'pulumi', // Pulumi: credentials.json (access tokens) + 'op', // 1Password CLI state + 'helm', // repository auth (repositories.yaml can embed credentials) + ], + '.cargo': [ + 'credentials', // crates.io registry token + 'credentials.toml', // crates.io registry token (newer cargo) + ], + '.claude': [ + '.credentials.json', // Claude Code OAuth tokens + ], + '.copilot': [ + 'config.json', // Copilot CLI may persist its auth token here + ], + '.gemini': [ + 'oauth_creds.json', // Gemini CLI OAuth access/refresh tokens + 'google_accounts.json', // Gemini CLI account identity + 'access_tokens.json', // Gemini CLI cached access tokens + ], +};