Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 6 additions & 2 deletions docs/mount-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ exercised only by its own tests.
| `system.directories.default` / `.sysroot` | allow (dirs) | compose (Docker + gVisor) | `system-mounts.ts` |
| `system.etc` | allow (files) | compose (Docker + gVisor) | `etc-mounts.ts` |
| `home.toolSubdirs` | allow (dirs) | compose + sbx | `home-strategy.ts`, `sbx-manager.ts` |
| `home.narrowPaths` | narrow allow override | compose + sbx | `home-strategy.ts`, `sbx-manager.ts` |
| `home.forbiddenSubdirs` | deny guard | compose + sbx | invariant tests |
| `credentials.entries` | deny (files/dirs) | compose + sbx | `credential-hiding.ts`, `sbx-manager.ts` |

Expand All @@ -42,8 +43,11 @@ mechanisms, but from the **same list**:
then blanks each credential **file** with a `/dev/null` bind overlay
(`credential-hiding.ts`). For a `dir` entry it masks the enumerated `files`;
for a `file` entry it masks the path itself.
- **sbx microVM** mounts the `toolSubdirs` (plus `.copilot`/`.gemini`) wholesale,
because sbx positional mounts are directory-granular and can't overlay
- **Compose (Docker / gVisor)** and the **sbx microVM** replace parents listed in
`narrowPaths` with their explicit descendants. In particular, they mount
rootless tool directories under `~/.local` but never `~/.local` or
`~/.local/state` wholesale, keeping sandboxd's private CA and backing store
outside the guest. Sbx positional mounts are directory-granular and can't overlay
`/dev/null` onto a nested path. Before `sbx create` it **moves** each credential
`path` aside on the host (to a backup dir at the home root, never itself
mounted) and **restores** it after teardown. It only touches entries whose
Expand Down
24 changes: 14 additions & 10 deletions docs/sbx-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,9 @@ Three capability queries drive the rest of the codebase:
`sbx daemon status` for diagnostics), then runs `sbx create --name <n> shell
<workspace> [mounts...]`. It translates AWF's Docker-style
`host:container:mode` mount strings into sbx's positional `path[:ro]` form,
deduplicates paths, and always adds `/tmp`, `/usr/local/bin`, and `$HOME` so
agent runtime files and installed CLIs (e.g. Copilot) are reachable.
deduplicates paths, and always adds `/tmp`, `/usr/local/bin`, and curated
`$HOME` tool paths so agent runtime files and installed CLIs are reachable
without exposing host-private state.
- **`execInSandbox(name, cmd, opts)`** — runs `sbx exec` with optional
`--workdir`, `--tty`, and `--env` flags, streams stdout/stderr, maps timeouts
to exit code `124`, and returns the command's exit code.
Expand Down Expand Up @@ -185,7 +186,7 @@ mounts where the host path maps to the identical path inside the VM**
The generated command is:

```text
sbx create --name <name> shell <workspaceDir> [extraMount...] /tmp /usr/local/bin $HOME
sbx create --name <name> shell <workspaceDir> [extraMount...] /tmp /usr/local/bin [homeToolPath...]
```

(`shell` is sbx's generic agent image, which supplies the guest base OS.)
Expand All @@ -205,14 +206,17 @@ What `createSandbox()` shares, in order:
*narrow*: only `/usr/local/bin`, **not** `/usr`, `/lib`, `/lib64`, or `/opt`.
- **`/tmp`** — agent runtime files (rendered prompts, logs).
- **`$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`, `.azure`,
`.anthropic`, `.claude`, `.cargo`, `.rustup`, `.npm`, `.nvm`) plus the agent
state dirs `.copilot` and `.gemini`. Credential-store dirs such as `.aws`,
the whole home directory. The manager resolves `HOME_TOOL_PATHS` from the
central policy and mounts only paths that exist on the host. Most entries
are top-level dirs (`.cache`, `.config`, `.azure`, `.anthropic`, `.claude`,
`.cargo`, `.rustup`, `.npm`, `.nvm`, `.copilot`, `.gemini`), while `.local`
is narrowed to rootless tool paths such as `.local/bin`, `.local/lib`, and
`.local/share`. `.local/state` is never mounted because it can contain
sandboxd's private CA and microVM backing store. Credential-store dirs such as `.aws`,
`.ssh`, `.docker`, `.kube`, 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).
so they never enter the VM. Each resolved path is mounted as a directory;
sbx positional mounts cannot target an individual file, so loose files like
`~/.copilot/mcp-config.json` are preserved.

:::note `.azure` is a credential-bearing exception
`.azure` is mounted to provide Azure CLI config and account metadata. However,
Expand Down
38 changes: 23 additions & 15 deletions src/chroot-home-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { logger } from './logger';
import { getSafeHostUid, getSafeHostGid, getRealUserHome } from './host-env';
import { assertRealDirectory, createMissingOwnedDirectorySegments } from './fs-utils';
import { resolveRunnerToolCachePath } from './runner-tool-cache';
import { HOME_TOOL_PATHS } from './config/mount-policy';

// Prepare a nested bind-mount destination inside the empty chroot home before
// Docker sees it. Without this, Docker may create intermediate parents such as
Expand Down Expand Up @@ -61,24 +62,31 @@ export function prepareChrootHomeMounts(config: WrapperConfig): void {
fs.chownSync(emptyHomeDir, uid, gid);
logger.debug(`Created chroot home directory: ${emptyHomeDir} (${uid}:${gid})`);

// Ensure source directories for home subdirectory mounts exist with correct ownership.
const hostHomeMountSourceDirs = [
'.copilot', '.cache', '.config', '.local',
'.anthropic', '.claude', '.cargo', '.rustup', '.npm', '.nvm',
...(config.geminiApiKey || config.googleApiKey ? ['.gemini'] : []),
];
for (const dir of hostHomeMountSourceDirs) {
const dirPath = path.join(effectiveHome, dir);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
fs.chownSync(dirPath, uid, gid);
logger.debug(`Created host home subdirectory: ${dirPath} (${uid}:${gid})`);
} else if (dir === '.gemini') {
// Ensure source directories and nested chroot mountpoints exist before Docker
// sees them, so it cannot create either side as root-owned.
const hostHomeMountSourcePaths = HOME_TOOL_PATHS.filter(
(toolPath) =>
toolPath !== '.gemini' || Boolean(config.geminiApiKey || config.googleApiKey),
);
for (const toolPath of hostHomeMountSourcePaths) {
const toolPathSource = path.join(effectiveHome, toolPath);
if (!fs.existsSync(toolPathSource)) {
createMissingOwnedDirectorySegments(toolPathSource, uid, gid);
logger.debug(`Created host home tool path: ${toolPathSource} (${uid}:${gid})`);
} else if (toolPath === '.gemini') {
// Repair existing .gemini ownership for Gemini/Vertex runs where prior
// root-owned bind mounts can break atomic writes in the CLI.
fs.chownSync(dirPath, uid, gid);
logger.debug(`Fixed host home subdirectory ownership: ${dirPath} (${uid}:${gid})`);
fs.chownSync(toolPathSource, uid, gid);
logger.debug(`Fixed host home tool path ownership: ${toolPathSource} (${uid}:${gid})`);
}

const chrootToolPath = prepareChrootHomeMountpoint(
emptyHomeDir,
toolPath,
uid,
gid,
);
logger.debug(`Prepared chroot home tool mountpoint: ${chrootToolPath} (${uid}:${gid})`);
}

// Source-side prep: this only applies when the config file explicitly names
Expand Down
33 changes: 33 additions & 0 deletions src/config/mount-policy-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const validJson = {
},
home: {
toolSubdirs: ['.cache', '.config'],
narrowPaths: {},
forbiddenSubdirs: ['.ssh', '.aws'],
},
credentials: {
Expand Down Expand Up @@ -231,6 +232,38 @@ describe('mount-policy validate – assertHomeSubdirArray', () => {
});
});

describe('mount-policy validate – narrowPaths', () => {
it('throws when a narrowed parent is not in toolSubdirs', async () => {
const bad = {
...validJson,
home: { ...validJson.home, narrowPaths: { '.local': ['.local/bin'] } },
};
await expect(loadModule(bad)).rejects.toThrow(
'home.narrowPaths key must also appear in home.toolSubdirs',
);
});

it('throws when a narrowed path is not a descendant of its parent', async () => {
const bad = {
...validJson,
home: { ...validJson.home, narrowPaths: { '.cache': ['.config/tool'] } },
};
await expect(loadModule(bad)).rejects.toThrow(
'home.narrowPaths[".cache"] entries must be descendants of .cache',
);
});

it('throws when a narrowed path list is empty', async () => {
const bad = {
...validJson,
home: { ...validJson.home, narrowPaths: { '.cache': [] } },
};
await expect(loadModule(bad)).rejects.toThrow(
'home.narrowPaths[".cache"] must not be empty',
);
});
});

// ─── parseCredentials ─────────────────────────────────────────────────────────

describe('mount-policy validate – parseCredentials', () => {
Expand Down
33 changes: 28 additions & 5 deletions src/config/mount-policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {
mountPolicy,
HOME_TOOL_SUBDIRS,
HOME_TOOL_PATHS,
HOME_FORBIDDEN_SUBDIRS,
CREDENTIAL_ENTRIES,
credentialFilesToHide,
credentialEntriesUnderMountedParents,
credentialEntriesUnderMountedPaths,
systemDirectories,
etcAllowlist,
} from './mount-policy';
Expand Down Expand Up @@ -48,6 +49,20 @@ describe('mount-policy', () => {
expect(new Set(HOME_TOOL_SUBDIRS).size).toBe(HOME_TOOL_SUBDIRS.length);
});

it('narrows .local mounts to tool paths outside .local/state', () => {
expect(HOME_TOOL_PATHS).toEqual(
expect.arrayContaining([
'.local/bin',
'.local/lib',
'.local/lib64',
'.local/share',
'.local/pipx',
]),
);
expect(HOME_TOOL_PATHS).not.toContain('.local');
expect(HOME_TOOL_PATHS.some((entry) => entry.startsWith('.local/state'))).toBe(false);
});

it('home.forbiddenSubdirs entries are simple relative names without traversal', () => {
for (const dir of HOME_FORBIDDEN_SUBDIRS) {
expect(dir).not.toBe('');
Expand Down Expand Up @@ -113,10 +128,10 @@ describe('mount-policy', () => {
});
});

describe('credentialEntriesUnderMountedParents', () => {
it('includes only entries whose top-level parent is mounted', () => {
describe('credentialEntriesUnderMountedPaths', () => {
it('includes only entries that overlap mounted paths', () => {
const mounted = new Set(['.config', '.cargo', '.claude', '.copilot', '.gemini', '.azure']);
const entries = credentialEntriesUnderMountedParents(mounted);
const entries = credentialEntriesUnderMountedPaths(mounted);
const paths = entries.map((e) => e.path);

expect(paths).toContain('.config/gh');
Expand All @@ -134,8 +149,16 @@ describe('mount-policy', () => {
expect(paths).not.toContain('.npmrc');
});

it('excludes credential entries under narrowed-away paths', () => {
const entries = credentialEntriesUnderMountedPaths(
new Set(['.local/bin', '.local/lib', '.local/share']),
);

expect(entries.map((entry) => entry.path)).not.toContain('.local/state/sandboxes');
});

it('returns nothing when no parents are mounted', () => {
expect(credentialEntriesUnderMountedParents(new Set())).toHaveLength(0);
expect(credentialEntriesUnderMountedPaths(new Set())).toHaveLength(0);
});
});

Expand Down
76 changes: 68 additions & 8 deletions src/config/mount-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface MountPolicy {
};
readonly home: {
readonly toolSubdirs: readonly string[];
readonly narrowPaths: Readonly<Record<string, readonly string[]>>;
readonly forbiddenSubdirs: readonly string[];
};
readonly credentials: readonly CredentialEntry[];
Expand Down Expand Up @@ -82,6 +83,47 @@ function assertHomeSubdirArray(value: unknown, label: string): readonly string[]
return arr;
}

function parseNarrowPaths(
value: unknown,
toolSubdirs: readonly string[],
): Readonly<Record<string, readonly string[]>> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
fail('home.narrowPaths must be an object');
}

const result: Record<string, readonly string[]> = {};
for (const [parent, rawPaths] of Object.entries(value)) {
if (!toolSubdirs.includes(parent)) {
fail(`home.narrowPaths key must also appear in home.toolSubdirs: ${parent}`);
}
const label = `home.narrowPaths[${JSON.stringify(parent)}]`;
const paths = assertStringArray(rawPaths, label);
if (paths.length === 0) {
fail(`${label} must not be empty`);
}
const seen = new Set<string>();
for (const child of paths) {
if (
child.length === 0 ||
child.startsWith('/') ||
child.startsWith('~') ||
child.includes('..') ||
!child.startsWith(`${parent}/`)
) {
fail(
`${label} entries must be descendants of ${parent}: ${child}`,
);
}
if (seen.has(child)) {
fail(`${label} has duplicate entry: ${child}`);
}
seen.add(child);
}
result[parent] = paths;
}
return result;
}

/**
* Validates an array of absolute host paths: each entry must start with `/`,
* must not be empty, must not contain `..`, and must be unique.
Expand Down Expand Up @@ -172,6 +214,7 @@ function validate(input: unknown): MountPolicy {
fail('home is required');
}

const toolSubdirs = assertHomeSubdirArray(home.toolSubdirs, 'home.toolSubdirs');
return {
system: {
directories: {
Expand All @@ -181,7 +224,8 @@ function validate(input: unknown): MountPolicy {
etc: assertAbsolutePathArray(system.etc, 'system.etc'),
},
home: {
toolSubdirs: assertHomeSubdirArray(home.toolSubdirs, 'home.toolSubdirs'),
toolSubdirs,
narrowPaths: parseNarrowPaths(home.narrowPaths, toolSubdirs),
forbiddenSubdirs: assertHomeSubdirArray(home.forbiddenSubdirs, 'home.forbiddenSubdirs'),
},
credentials: parseCredentials(p.credentials),
Expand All @@ -197,6 +241,14 @@ export const mountPolicy: MountPolicy = validate(rawPolicy);
*/
export const HOME_TOOL_SUBDIRS: readonly string[] = mountPolicy.home.toolSubdirs;

/**
* `$HOME`-relative directories exposed to agents. Policy overrides replace
* sensitive wholesale parents with explicit safe descendants.
*/
export const HOME_TOOL_PATHS: readonly string[] = HOME_TOOL_SUBDIRS.flatMap(
(subdir) => mountPolicy.home.narrowPaths[subdir] ?? [subdir],
);

/**
* `$HOME` subdirectories whose primary purpose is storing credentials. These
* must NEVER appear in {@link HOME_TOOL_SUBDIRS}; the export exists so tests can
Expand Down Expand Up @@ -229,16 +281,24 @@ export function credentialFilesToHide(): string[] {

/**
* Credential entries the sbx backend should move aside before `sbx create`:
* those whose top-level parent directory is one of the wholesale-mounted home
* dirs in {@link mountedTopLevelParents}. Entries under never-mounted dirs (e.g.
* `.ssh`, `.aws`) are excluded because they never enter the microVM anyway.
* those whose path overlaps one of the mounted home paths. Entries under
* never-mounted or narrowed-away paths are excluded because they never enter
* the microVM.
*/
export function credentialEntriesUnderMountedParents(
mountedTopLevelParents: ReadonlySet<string>,
export function credentialEntriesUnderMountedPaths(
mountedPaths: ReadonlySet<string>,
): CredentialEntry[] {
return mountPolicy.credentials.filter((entry) => {
const top = entry.path.split('/')[0];
return mountedTopLevelParents.has(top);
for (const mountedPath of mountedPaths) {
if (
entry.path === mountedPath ||
entry.path.startsWith(`${mountedPath}/`) ||
mountedPath.startsWith(`${entry.path}/`)
) {
return true;
}
}
return false;
});
}

Expand Down
12 changes: 11 additions & 1 deletion src/config/sandbox-mount-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"etc": ["/etc/ssl", "/etc/ca-certificates", "/etc/pki/ca-trust/extracted", "/etc/pki/tls/certs", "/etc/alternatives", "/etc/ld.so.cache", "/etc/nsswitch.conf"]
},
"home": {
"$comment": "Agent $HOME exposure. `toolSubdirs` is the ALLOW list: tool caches, language toolchains and agent state the agent legitimately needs. `forbiddenSubdirs` is a DENY guard: dirs whose primary purpose is storing credentials and which must NEVER be added to the allow list. Compose mounts an empty home + binds toolSubdirs on top; sbx mounts toolSubdirs wholesale instead of the whole $HOME. EXCEPTION: `.azure` is credential-bearing — it is intentionally mounted to provide Azure CLI config and account metadata, but its live token caches (msal_token_cache.bin, msal_token_cache.json, accessTokens.json, service_principal_entries.json) are masked by the credentials deny list so agents cannot read host auth tokens directly. Azure API auth must use the api-proxy's sidecar-only OIDC exchange or another trusted external service; ADO MCP may use its separate ADO_MCP_AUTH_TOKEN env var.",
"$comment": "Agent $HOME exposure. `toolSubdirs` is the ALLOW list: tool caches, language toolchains and agent state the agent legitimately needs. `narrowPaths` replaces a wholesale mount with explicit safe descendants for every runtime; this keeps `.local/state`, including sandboxd's private runtime state, outside the agent while retaining rootless tools. `forbiddenSubdirs` is a DENY guard: dirs whose primary purpose is storing credentials and which must NEVER be added to the allow list. Compose mounts an empty home + binds the resolved tool paths on top. EXCEPTION: `.azure` is credential-bearing — it is intentionally mounted to provide Azure CLI config and account metadata, but its live token caches (msal_token_cache.bin, msal_token_cache.json, accessTokens.json, service_principal_entries.json) are masked by the credentials deny list so agents cannot read host auth tokens directly. Azure API auth must use the api-proxy's sidecar-only OIDC exchange or another trusted external service; ADO MCP may use its separate ADO_MCP_AUTH_TOKEN env var.",
"toolSubdirs": [
".cache",
".config",
Expand All @@ -24,6 +24,15 @@
".copilot",
".gemini"
],
"narrowPaths": {
".local": [
".local/bin",
".local/lib",
".local/lib64",
".local/share",
".local/pipx"
]
},
"forbiddenSubdirs": [
".aws",
".ssh",
Expand Down Expand Up @@ -59,6 +68,7 @@
{ "path": ".gemini/oauth_creds.json", "type": "file", "reason": "Gemini CLI OAuth tokens" },
{ "path": ".gemini/google_accounts.json", "type": "file", "reason": "Gemini CLI account identity" },
{ "path": ".gemini/access_tokens.json", "type": "file", "reason": "Gemini CLI cached access tokens" },
{ "path": ".local/state/sandboxes", "type": "dir", "reason": "sandboxd private CA keys and microVM backing store" },
{ "path": ".config/gh", "type": "dir", "files": ["hosts.yml"], "reason": "GitHub CLI OAuth token" },
{ "path": ".config/gcloud", "type": "dir", "files": ["credentials.db", "access_tokens.db", "application_default_credentials.json"], "reason": "Google Cloud SDK credentials" },
{ "path": ".config/doctl", "type": "dir", "files": ["config.yaml"], "reason": "DigitalOcean CLI token" },
Expand Down
Loading
Loading