fix(sbx): stop mounting the whole $HOME into the microVM (protect host secrets)#6336
Conversation
The sbx microVM path mounted the entire host $HOME (rw) into the sandbox, exposing any host credential files — ~/.aws, ~/.ssh, ~/.docker/config.json, ~/.kube, ~/.azure, ~/.npmrc, ~/.gitconfig, ~/.config/gh, etc. — to the agent. Unlike compose mode, sbx cannot express per-file /dev/null credential overlays (its mounts are positional, host path == guest path), so directory curation is the only available protection. Replace the blanket $HOME mount with a curated whitelist of tool/cache and agent-state subdirs, mounting only those that exist on the host. Credential stores are never whitelisted, so they no longer enter the VM. To keep the two backends from drifting, extract the shared tool-subdir whitelist into src/services/agent-volumes/home-whitelist.ts and consume it from both the compose chroot home strategy and the sbx mount builder. Tests: add sbx cases asserting the whole $HOME is never mounted, that whitelisted dirs are included only when they exist, and that credential stores stay out. Full suite green (3817 tests). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23717692-af7a-4e03-a156-5b696c3f01bd
|
| Metric | Base | PR | Delta |
|---|---|---|---|
| Lines | 99.01% | 99.05% | 📈 +0.04% |
| Statements | 98.95% | 98.97% | 📈 +0.02% |
| Functions | 99.35% | 99.35% | ➡️ +0.00% |
| Branches | 95.17% | 95.14% | 📉 -0.03% |
📁 Per-file Coverage Changes (2 files)
| File | Lines (Before → After) | Statements (Before → After) |
|---|---|---|
src/sbx-manager.ts |
100.0% → 100.0% (+0.00%) | 100.0% → 99.1% (-0.87%) |
src/log-directory-setup.ts |
96.2% → 100.0% (+3.78%) | 96.3% → 100.0% (+3.71%) |
✨ New Files (1 files)
src/services/agent-volumes/home-whitelist.ts: 100.0% lines
Coverage comparison generated by scripts/ci/compare-coverage.ts
There was a problem hiding this comment.
Pull request overview
Restricts sbx home-directory exposure by replacing the whole-home mount with curated subdirectory mounts.
Changes:
- Adds a shared home tool-directory whitelist.
- Uses existing whitelisted paths in sbx and compose.
- Adds sbx mount-curation tests.
Show a summary per file
| File | Description |
|---|---|
src/services/agent-volumes/home-whitelist.ts |
Defines the shared whitelist. |
src/services/agent-volumes/home-strategy.ts |
Reuses the shared list in compose mode. |
src/sbx-manager.ts |
Replaces the whole-home mount with existing subdirectories. |
src/sbx-manager.test.ts |
Tests curated sbx home mounts. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Medium
| '.claude', | ||
| '.cargo', |
There was a problem hiding this comment.
Good catch — fixed in d07a927. .cargo and .claude are now credential-nesting parents: sbx mounts them child-by-child and excludes the secret files (.cargo/credentials, .cargo/credentials.toml, .claude/.credentials.json) rather than mounting the dir wholesale. See CREDENTIAL_EXCLUSIONS_BY_PARENT in home-whitelist.ts, now covered by unit + sbx tests.
| // whitelisted, so they never enter the sandbox. Only paths that exist on the | ||
| // host are mounted, because sbx requires the mount source to exist. | ||
| const homePath = process.env.HOME || '/home/runner'; | ||
| const homeSubdirs = ['.copilot', ...HOME_TOOL_SUBDIRS, '.gemini']; |
There was a problem hiding this comment.
Fixed in d07a927. .copilot and .gemini are no longer appended wholesale — they are now credential-nesting parents, mounted child-by-child with config.json (Copilot) and oauth_creds.json/google_accounts.json (Gemini) excluded. Only their safe children (session-state, logs, tmp, settings, etc.) reach the VM. New sbx test asserts the token files never appear in the mount args.
Whitelisted home dirs like ~/.config are needed for tool settings but also nest credential stores (.config/gh/hosts.yml, .config/gcloud/*, etc.). Compose mode blanks those individual files with /dev/null overlays; sbx cannot mask a nested path once its parent is mounted. Mount credential-nesting parents (currently ~/.config) child-by-child and skip any child whose basename is a known credential store (CREDENTIAL_SUBDIR_NAMES: gh, gcloud, doctl, heroku, hub, rclone, containers, pulumi, op, helm). sbx creates the parent mount point for the surviving children, so the excluded credential dirs never exist inside the microVM while benign tool config still works. The deny-list and the credential-nesting set live in the shared home-whitelist module so both backends stay in sync. Adds unit tests for the whitelist invariants and a sbx case asserting .config is mounted child-by-child with credential subdirs excluded. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23717692-af7a-4e03-a156-5b696c3f01bd
|
✅ Copilot review passed with no inline comments. @lpcox Add the |
|
| Metric | Base | PR | Delta |
|---|---|---|---|
| Lines | 99.01% | 99.05% | 📈 +0.04% |
| Statements | 98.95% | 98.95% | ➡️ +0.00% |
| Functions | 99.35% | 99.35% | ➡️ +0.00% |
| Branches | 95.17% | 95.12% | 📉 -0.05% |
📁 Per-file Coverage Changes (2 files)
| File | Lines (Before → After) | Statements (Before → After) |
|---|---|---|
src/sbx-manager.ts |
100.0% → 100.0% (+0.00%) | 100.0% → 98.4% (-1.57%) |
src/log-directory-setup.ts |
96.2% → 100.0% (+3.78%) | 96.3% → 100.0% (+3.71%) |
✨ New Files (1 files)
src/services/agent-volumes/home-whitelist.ts: 100.0% lines
Coverage comparison generated by scripts/ci/compare-coverage.ts
Address review feedback: whitelisted tool dirs themselves hold credential files that mounting them wholesale would expose to the agent (sbx cannot overlay them): - .cargo/credentials, .cargo/credentials.toml (crates.io tokens) - .claude/.credentials.json (Claude Code OAuth) - .copilot/config.json (Copilot CLI may persist its token) - .gemini/oauth_creds.json, .gemini/google_accounts.json (Gemini OAuth) Generalize the deny model from credential *subdir names* to a per-parent map of credential child basenames (directories OR files), CREDENTIAL_EXCLUSIONS_BY_PARENT, and expand each of those parents child-by-child in sbx, skipping the listed secrets. sbx recreates the parent mount point for the surviving children, so the excluded secrets never exist inside the microVM while benign tool state still works. Update whitelist + sbx tests accordingly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23717692-af7a-4e03-a156-5b696c3f01bd
|
| Metric | Base | PR | Delta |
|---|---|---|---|
| Lines | 99.01% | 99.05% | 📈 +0.04% |
| Statements | 98.95% | 98.96% | ➡️ +0.01% |
| Functions | 99.35% | 99.35% | ➡️ +0.00% |
| Branches | 95.17% | 95.12% | 📉 -0.05% |
📁 Per-file Coverage Changes (2 files)
| File | Lines (Before → After) | Statements (Before → After) |
|---|---|---|
src/sbx-manager.ts |
100.0% → 100.0% (+0.00%) | 100.0% → 98.4% (-1.56%) |
src/log-directory-setup.ts |
96.2% → 100.0% (+3.78%) | 96.3% → 100.0% (+3.71%) |
✨ New Files (1 files)
src/services/agent-volumes/home-whitelist.ts: 100.0% lines
Coverage comparison generated by scripts/ci/compare-coverage.ts
|
✅ Smoke Claude passed |
|
✅ Contribution Check completed successfully! |
|
✅ Smoke Copilot BYOK completed. Copilot BYOK mode operational. 🔓 |
|
✅ Smoke Copilot BYOK AOAI (api-key) completed. Copilot AOAI BYOK (api-key) mode operational. 🔓 |
|
📰 VERDICT: Smoke Copilot has concluded. All systems operational. This is a developing story. 🎤 |
|
🚀 Security Guard has started processing this pull request |
|
✅ Smoke Gemini completed. All facets verified. 💎 |
|
🛡️ Smoke Copilot Network Isolation confirmed the egress allowlist is enforced. ✅ |
|
🔌 Smoke Services — All services reachable! ✅ |
|
✅ Build Test Suite completed successfully! |
|
📡 Smoke OTel Tracing completed. All tracing scenarios validated. ✅ |
|
📰 DEVELOPING STORY: Smoke Docker Sbx reports failed. Our correspondents are investigating the incident... |
|
🔑 Smoke Copilot PAT PAT auth validated. All systems operational. ✅ |
|
✨ The prophecy is fulfilled... Smoke Codex has completed its mystical journey. The stars align. 🌟 |
|
✅ Smoke Copilot BYOK AOAI (Entra) completed. Copilot AOAI BYOK (Entra) mode operational. 🔓 |
|
Chroot tests passed! Smoke Chroot - All security and functionality tests succeeded. |
|
Thanks — the fix and tests look solid. One CONTRIBUTING.md item still seems unmet: Documentation asks contributors to update docs for behavior changes. This PR changes the sandbox home-mount policy in a way users may need to know about, but I don’t see a documentation update in the changed files. If there’s a related issue, linking it in the PR description would also align with the Pull Request Process guidance. Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
This comment has been minimized.
This comment has been minimized.
|
📰 VERDICT: Smoke Copilot has concluded. All systems operational. This is a developing story. 🎤 |
|
✅ Build Test Suite completed successfully! |
|
🔌 Smoke Services — All services reachable! ✅ |
|
✅ Smoke Gemini completed. All facets verified. 💎 |
Smoke Test: Claude Engine Validation
Overall result: PASS Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
Smoke Test: Copilot BYOK (Direct) Mode ✅ PASS
Running in direct BYOK mode via api-proxy → api.githubcopilot.com Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
🔬 Smoke Test Results
Author: @lpcox Overall: PASS Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
|
Smoke test: Copilot network-isolation egress EGRESS_RESULT allow=pass deny=pass ✅ Test 1 (allowed domain): Overall: PASS — @lpcox Warning Firewall blocked 2 domainsThe following domains were blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"
- "example.com"See Network Configuration for more information.
|
Smoke Test: Copilot PAT Auth
Overall: PARTIAL — pre-step data not injected into prompt. Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
|
Smoke test summary: merged PRs checked, homepage title verified, temp file wrote/read, discussion commented, and build passed. Overall: PASS. Warning Firewall blocked 2 domainsThe following domains were blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"
- "registry.npmjs.org"See Network Configuration for more information.
|
Smoke Test: Services Connectivity
Overall: FAIL — service containers are not reachable from this runner environment. Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
Smoke Test Results
Overall Status: PASS Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "localhost"See Network Configuration for more information.
|
|
Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
Chroot Version Comparison Results
Overall: ❌ Not all tests passed. Python and Node.js versions differ between host and chroot environments. Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
🏗️ Build Test Suite Results
Overall: 8/8 ecosystems passed — ✅ PASS Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
🧪 Smoke Test: Docker Sbx Validation
Overall Status: INCONCLUSIVE — pre-agent step outputs were not interpolated into the prompt (template variables like
|
Smoke Test: API Proxy OpenTelemetry Tracing
Overall: 3 pass, 2 expected-pending (development in progress) Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
Problem
The sbx microVM path mounted the entire host
$HOME(read-write) into the sandbox, exposing any credential files there (~/.aws,~/.ssh,~/.docker/config.json,~/.kube,~/.azure,~/.npmrc,~/.gitconfig,~/.config/gh/hosts.yml,~/.config/gcloud/*, …) to the agent. Compose mode already defends against this with an empty home volume + a tool-subdir whitelist +/dev/nulloverlays on known credential files (credential-hiding.ts); the sbx path had none of that — it only sanitizes env vars, not files.Why not just port the
/dev/nulloverlay?sbx can't. Its mounts are positional and directory-granular (host path == guest path) — there's no way to map
/dev/nullonto a specific destination file, and an individual file can't be a positional mount at all. So the compose overlay trick isn't expressible; the only levers are which directories get mounted and what those directories contain on the host at create time.Fix
Two layers, both driven by the shared
home-whitelist.tsso the sbx and compose paths can't drift:Top-level whitelist.
sbx-manager.tsstops mounting the whole$HOME. It mounts only a curated whitelist of tool/cache + agent-state subdirs (HOME_TOOL_SUBDIRS+.copilot/.gemini), and only those that exist on the host. Top-level credential stores (.aws,.ssh,.docker,.kube,.azure,.gnupg,.netrc, …) are never whitelisted, so they never enter the VM. Each whitelisted dir is mounted wholesale (as a directory) so its required loose files — e.g.~/.copilot/mcp-config.json,~/.copilot/settings.json— still work.Scrub nested credential stores. Several whitelisted dirs legitimately hold tool settings but also stash a secret in a well-known child:
.config/gh,.config/gcloud, …;.cargo/credentials[.toml];.claude/.credentials.json;.copilot/config.json;.gemini/oauth_creds.json/google_accounts.json. Since the parent is mounted wholesale and sbx can't overlay/mask a nested path, the manager moves those credential paths aside on the host beforesbx create(into a.awf-sbx-cred-backup-<pid>dir at the home root — never a mounted subdir) and restores them after the sandbox is torn down (scrubHomeCredentials/restoreHomeCredentials). This is the sbx analog of compose mode's/dev/nulloverlays. The credential list lives in the sharedCREDENTIAL_PATHS_BY_PARENT.Agent credentials come from the api-proxy or environment, not from the host's on-disk auth store, so hiding these paths is safe.
Why this replaces the earlier child-by-child approach
The previous revision expanded these parents child-by-child, skipping credential children. That broke the
Smoke Docker SbxCI: expansion tried to mount loose files (e.g.~/.copilot/mcp-config.json) as sbx positional mounts, which must be directories —sbx create failed: workspace path exists but is not a directory. Wholesale-mount-plus-scrub preserves those required files while still keeping secrets out.Review feedback
Both Copilot review comments (credential files in
.cargo/.claudeand in.copilot/.gemini) are addressed: those paths are inCREDENTIAL_PATHS_BY_PARENTand scrubbed before create.Verification
$HOMEnever mounted; whitelisted dirs mount only when they exist; top-level credential stores stay out;.config/.cargo/.claude/.copilot/.geminimounted wholesale with their nested token stores moved aside before create; scrubbed credentials restored afterremoveSandbox; whitelist-invariant guards.npm test: 242 suites / 3823 tests pass.npm run buildclean. ESLint: 0 errors.Notes
.cargo/credentialsetc. via/dev/null, but does not yet mask.claude/.copilot/.geminitoken files — a separate compose gap worth closing in the deferred centralized mount-policy refactor.docs/sbx-integration.mdis updated to the wholesale + scrub model.--keep-containers, restore is deferred until the sandbox is eventually removed; the backup location is logged so a scrubbed home is always recoverable.