diff --git a/docs/sbx-integration.md b/docs/sbx-integration.md index 882d59774..826ac98ad 100644 --- a/docs/sbx-integration.md +++ b/docs/sbx-integration.md @@ -177,18 +177,31 @@ 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 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` / @@ -196,10 +209,10 @@ them after the sandbox is torn down** (`scrubHomeCredentials` / `.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. +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 diff --git a/src/config/mount-policy.test.ts b/src/config/mount-policy.test.ts index c6aeefa4d..d5de47789 100644 --- a/src/config/mount-policy.test.ts +++ b/src/config/mount-policy.test.ts @@ -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); @@ -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'); @@ -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'); }); }); diff --git a/src/config/sandbox-mount-policy.json b/src/config/sandbox-mount-policy.json index b3c0be1f0..8a999c776 100644 --- a/src/config/sandbox-mount-policy.json +++ b/src/config/sandbox-mount-policy.json @@ -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", @@ -28,7 +29,6 @@ ".ssh", ".docker", ".kube", - ".azure", ".gnupg", ".netrc", ".gitconfig", @@ -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" }, diff --git a/src/sbx-manager.ts b/src/sbx-manager.ts index f356dcda7..05d0f893b 100644 --- a/src/sbx-manager.ts +++ b/src/sbx-manager.ts @@ -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. // diff --git a/src/services/agent-environment-credentials.test.ts b/src/services/agent-environment-credentials.test.ts index 13ade4471..2d890f2c8 100644 --- a/src/services/agent-environment-credentials.test.ts +++ b/src/services/agent-environment-credentials.test.ts @@ -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; + + 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'; diff --git a/src/services/agent-environment/core-environment.ts b/src/services/agent-environment/core-environment.ts index 9cb016e43..cc9a2ad90 100644 --- a/src/services/agent-environment/core-environment.ts +++ b/src/services/agent-environment/core-environment.ts @@ -21,6 +21,6 @@ export function buildCoreEnvironment(params: AgentEnvironmentParams): Record { 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 = {}; + const excludedEnvVars = new Set(); + + 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 = {}; + const excludedEnvVars = new Set(); + + 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'); + }); }); }); diff --git a/src/services/agent-environment/env-passthrough.ts b/src/services/agent-environment/env-passthrough.ts index 3773c9730..58aa1177b 100644 --- a/src/services/agent-environment/env-passthrough.ts +++ b/src/services/agent-environment/env-passthrough.ts @@ -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', diff --git a/src/services/agent-volumes/home-strategy.test.ts b/src/services/agent-volumes/home-strategy.test.ts index 669ef6832..6a3b38e14 100644 --- a/src/services/agent-volumes/home-strategy.test.ts +++ b/src/services/agent-volumes/home-strategy.test.ts @@ -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(); diff --git a/src/services/agent-volumes/home-whitelist.test.ts b/src/services/agent-volumes/home-whitelist.test.ts index 4bcd38cac..0e0ca57df 100644 --- a/src/services/agent-volumes/home-whitelist.test.ts +++ b/src/services/agent-volumes/home-whitelist.test.ts @@ -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', + ]), ); }); @@ -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']), ); }); });