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
27 changes: 20 additions & 7 deletions docs/sbx-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,29 +177,42 @@ What `createSandbox()` shares, in order:
- **`/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`,
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`,
`.ssh`, `.docker`, `.kube`, `.azure` and `.gnupg` are **never** whitelisted,
`.ssh`, `.docker`, `.kube`, and `.gnupg` are **never** whitelisted,
so they never enter the VM. Each whitelisted dir is mounted **wholesale** (as
Comment on lines +180 to 184
a directory — sbx positional mounts cannot target an individual file, so its
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,
its live token caches (`msal_token_cache.bin`, `msal_token_cache.json`,
`accessTokens.json`, `service_principal_entries.json`) are treated as
credential stores and scrubbed before sandbox creation (sbx) or masked with
`/dev/null` overlays (compose). Agents cannot read host Azure auth tokens
directly. Azure authentication must be obtained at runtime via OIDC
(`ACTIONS_ID_TOKEN_REQUEST_URL`/`TOKEN`, already forwarded) or via the
`ADO_MCP_AUTH_TOKEN` environment variable.
:::

**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`,
`.gemini/oauth_creds.json`. Because the parent is mounted
`.gemini/oauth_creds.json`, and the Azure CLI token caches under `.azure`
(`msal_token_cache.bin`, `msal_token_cache.json`, `accessTokens.json`,
`service_principal_entries.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.
central credential list in `sandbox-mount-policy.json` is shared between backends
to prevent drift. The agent receives whatever credentials it needs through the
api-proxy or environment (e.g. `ADO_MCP_AUTH_TOKEN`, OIDC tokens), 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 Down
14 changes: 12 additions & 2 deletions src/config/mount-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ describe('mount-policy', () => {
// dir entry expanded
expect(files).toContain('.config/gh/hosts.yml');
expect(files).toContain('.config/gcloud/credentials.db');
// Azure CLI token caches are masked as file entries
expect(files).toContain('.azure/msal_token_cache.bin');
expect(files).toContain('.azure/msal_token_cache.json');
expect(files).toContain('.azure/accessTokens.json');
// dir entry with no known files is omitted (compose can't mask a dir)
expect(files).not.toContain('.config/heroku');
expect(files.some((f) => f.startsWith('.config/heroku'))).toBe(false);
Expand All @@ -111,13 +115,18 @@ describe('mount-policy', () => {

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

expect(paths).toContain('.config/gh');
expect(paths).toContain('.cargo/credentials');
expect(paths).toContain('.claude/.credentials.json');
// .azure token caches are masked when .azure is mounted
expect(paths).toContain('.azure/msal_token_cache.bin');
expect(paths).toContain('.azure/msal_token_cache.json');
expect(paths).toContain('.azure/accessTokens.json');
expect(paths).toContain('.azure/service_principal_entries.json');
// Never-mounted parents are excluded.
expect(paths).not.toContain('.ssh/id_rsa');
expect(paths).not.toContain('.aws/credentials');
Expand Down Expand Up @@ -157,8 +166,9 @@ describe('mount-policy', () => {
expect(mountPolicy.credentials).toBe(CREDENTIAL_ENTRIES);
});

it('includes .copilot and .gemini in home.toolSubdirs', () => {
it('includes .copilot, .gemini, and .azure in home.toolSubdirs', () => {
expect(HOME_TOOL_SUBDIRS).toContain('.copilot');
expect(HOME_TOOL_SUBDIRS).toContain('.gemini');
expect(HOME_TOOL_SUBDIRS).toContain('.azure');
});
});
8 changes: 6 additions & 2 deletions src/config/sandbox-mount-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
"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.",
"$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 auth must come via OIDC (ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN) or the ADO_MCP_AUTH_TOKEN env var.",
"toolSubdirs": [
".cache",
".config",
".local",
".azure",
".anthropic",
".claude",
".cargo",
Expand All @@ -28,7 +29,6 @@
".ssh",
".docker",
".kube",
".azure",
".gnupg",
".netrc",
".gitconfig",
Expand All @@ -49,6 +49,10 @@
{ "path": ".aws/config", "type": "file", "reason": "AWS config (may embed SSO/credentials)" },
{ "path": ".kube/config", "type": "file", "reason": "Kubernetes cluster credentials" },
{ "path": ".azure/credentials", "type": "file", "reason": "Azure credentials" },
{ "path": ".azure/msal_token_cache.bin", "type": "file", "reason": "Azure CLI MSAL token cache (live bearer tokens)" },
{ "path": ".azure/msal_token_cache.json", "type": "file", "reason": "Azure CLI MSAL token cache JSON (live bearer tokens)" },
{ "path": ".azure/accessTokens.json", "type": "file", "reason": "Azure CLI legacy access tokens" },
{ "path": ".azure/service_principal_entries.json", "type": "file", "reason": "Azure CLI service principal credentials" },
{ "path": ".cargo/credentials", "type": "file", "reason": "crates.io registry token" },
{ "path": ".cargo/credentials.toml", "type": "file", "reason": "crates.io registry token (newer cargo)" },
{ "path": ".claude/.credentials.json", "type": "file", "reason": "Claude Code OAuth tokens" },
Expand Down
2 changes: 1 addition & 1 deletion src/sbx-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ export async function createSandbox(config: {
// is to curate which $HOME subdirs are mounted. The central mount policy
// (HOME_TOOL_SUBDIRS) lists the allowed tool-state dirs including agent-state
// dirs (.copilot, .gemini). Credential stores such as ~/.aws, ~/.ssh,
// ~/.docker, ~/.kube, ~/.azure, ~/.gnupg, ~/.netrc and ~/.gitconfig are never
// ~/.docker, ~/.kube, ~/.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.
//
Expand Down
7 changes: 7 additions & 0 deletions src/services/agent-environment-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ describe('agent environment: credentials', () => {
expect(env.AWF_ONE_SHOT_TOKENS).toContain('ANTHROPIC_AUTH_TOKEN');
});

it('should include ADO_MCP_AUTH_TOKEN in AWF_ONE_SHOT_TOKENS', () => {
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
const env = result.services.agent.environment as Record<string, string>;

expect(env.AWF_ONE_SHOT_TOKENS).toContain('ADO_MCP_AUTH_TOKEN');
});

it('should pass through GITHUB_TOKEN when present in environment', () => {
const originalEnv = process.env.GITHUB_TOKEN;
process.env.GITHUB_TOKEN = 'ghp_testtoken123';
Expand Down
2 changes: 1 addition & 1 deletion src/services/agent-environment/core-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ export function buildCoreEnvironment(params: AgentEnvironmentParams): Record<str
} : {
NO_COLOR: '1',
}),
AWF_ONE_SHOT_TOKENS: 'COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,CLAUDE_API_KEY,CODEX_API_KEY,COPILOT_PROVIDER_API_KEY,OTEL_EXPORTER_OTLP_HEADERS,OTEL_EXPORTER_OTLP_TRACES_HEADERS,OTEL_EXPORTER_OTLP_METRICS_HEADERS,OTEL_EXPORTER_OTLP_LOGS_HEADERS',
AWF_ONE_SHOT_TOKENS: 'COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,CLAUDE_API_KEY,CODEX_API_KEY,COPILOT_PROVIDER_API_KEY,ADO_MCP_AUTH_TOKEN,OTEL_EXPORTER_OTLP_HEADERS,OTEL_EXPORTER_OTLP_TRACES_HEADERS,OTEL_EXPORTER_OTLP_METRICS_HEADERS,OTEL_EXPORTER_OTLP_LOGS_HEADERS',
};
}
30 changes: 30 additions & 0 deletions src/services/agent-environment/env-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,35 @@ describe('passthroughHostEnvironment', () => {
expect(environment).not.toHaveProperty('GH_TOKEN');
expect(environment).not.toHaveProperty('GITHUB_PERSONAL_ACCESS_TOKEN');
});

it('forwards AZURE_CONFIG_DIR when it is NOT in the exclusion set', () => {
const environment: Record<string, string> = {};
const excludedEnvVars = new Set<string>();

withEnv({ AZURE_CONFIG_DIR: '/home/runner/.azure' }, () => {
passthroughHostEnvironment({
config: makeConfig({ enableApiProxy: true }),
environment,
excludedEnvVars,
});
});

expect(environment).toHaveProperty('AZURE_CONFIG_DIR', '/home/runner/.azure');
});

it('forwards ADO_MCP_AUTH_TOKEN when it is NOT in the exclusion set', () => {
const environment: Record<string, string> = {};
const excludedEnvVars = new Set<string>();

withEnv({ ADO_MCP_AUTH_TOKEN: 'ado-auth-token' }, () => {
passthroughHostEnvironment({
config: makeConfig({ enableApiProxy: true }),
environment,
excludedEnvVars,
});
});

expect(environment).toHaveProperty('ADO_MCP_AUTH_TOKEN', 'ado-auth-token');
});
});
});
2 changes: 2 additions & 0 deletions src/services/agent-environment/env-passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export function passthroughHostEnvironment(params: EnvPassthroughParams): void {
'GITHUB_API_URL',
'ACTIONS_ID_TOKEN_REQUEST_URL',
'ACTIONS_ID_TOKEN_REQUEST_TOKEN',
'AZURE_CONFIG_DIR',
'ADO_MCP_AUTH_TOKEN',
'DOCKER_HOST',
'DOCKER_TLS',
'DOCKER_TLS_VERIFY',
Expand Down
8 changes: 8 additions & 0 deletions src/services/agent-volumes/home-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ describe('buildHomeMounts', () => {
jest.restoreAllMocks();
});

it('includes ~/.azure in the mounted tool directories', () => {
(fs.existsSync as jest.Mock).mockImplementation(() => false);

const mounts = buildHomeMounts(makeParams());

expect(mounts).toContain('/home/runner/.azure:/host/home/runner/.azure:rw');
});

describe('~/.copilot access error handling', () => {
it('includes error.message in warning when accessSync throws an Error instance', () => {
mockExistsForCopilot();
Expand Down
13 changes: 11 additions & 2 deletions src/services/agent-volumes/home-whitelist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ import { HOME_TOOL_SUBDIRS, HOME_FORBIDDEN_SUBDIRS } from './home-whitelist';
describe('home-whitelist (mount-policy shim)', () => {
it('re-exports the shared home allow list', () => {
expect(HOME_TOOL_SUBDIRS).toEqual(
expect.arrayContaining(['.cache', '.config', '.local', '.cargo', '.npm', '.copilot', '.gemini']),
expect.arrayContaining([
'.cache',
'.config',
'.local',
'.azure',
'.cargo',
'.npm',
'.copilot',
'.gemini',
]),
);
});

Expand All @@ -15,7 +24,7 @@ describe('home-whitelist (mount-policy shim)', () => {

it('lists the well-known top-level credential store dirs as forbidden', () => {
expect(HOME_FORBIDDEN_SUBDIRS).toEqual(
expect.arrayContaining(['.aws', '.ssh', '.docker', '.kube', '.azure', '.gnupg']),
expect.arrayContaining(['.aws', '.ssh', '.docker', '.kube', '.gnupg']),
);
});
});
Loading