Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions docs/sbx-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<pid>` 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
Expand All @@ -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) | `<workspaceDir>:/host<workspaceDir>: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
Expand Down
190 changes: 188 additions & 2 deletions src/sbx-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,34 @@ 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';

// eslint-disable-next-line @typescript-eslint/no-require-imports
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<typeof import('fs')>('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);

Expand Down Expand Up @@ -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
Expand All @@ -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',
}));
Expand All @@ -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
Expand Down Expand Up @@ -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: '' });
Expand All @@ -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`);
});
});

Expand Down
Loading
Loading