CSIPE

Published

- 35 min read

CVE-2026-46331: How a Shared Filesystem Undoes an AI Agent Sandbox


The Digital Fortress: Your Everyday Guide to a Safer Digital Life

Stay Safe Online Without Making It Your Second Job

The Digital Fortress (Second Edition)

A warm, plain-English guide for people with real lives and finite patience. Learn the handful of habits that genuinely protect your money, accounts, and family, and get honest permission to ignore the rest.

Buy the book now
The Anonymity Playbook: Digital Survival for Whistleblowers, Journalists, Activists, and Everyone Else

For People Who Cannot Afford to Get Privacy Wrong

The Anonymity Playbook (Second Edition)

A practitioner’s field manual for journalists protecting sources, whistleblowers, and activists. It explains how the surveillance actually works, what each technique costs you, and exactly where it fails.

Buy the book now
Secure Software Development: Practical patterns for building secure software

Write, Ship, and Maintain Code Without Shipping Vulnerabilities

Secure Software Development

A hands-on security guide for developers and IT professionals who ship real software. Build, deploy, and maintain secure systems without slowing down or drowning in theory.

Buy the book now
The Secure Harness: Shipping Production Code with AI Coding Agents

Use AI Coding Agents Without Losing Control of Your Codebase

The Secure Harness

A calm, practical guide to letting agents do useful work inside boundaries you set, enforce, and audit. Ships with 15 copy-pasteable artifacts: hook scripts, permission configs, release gates, and MCP templates.

Buy the book now
The AI Native Engineer: Build, Evaluate, and Ship AI Systems That Work in Production

Stop Shipping Demos. Start Shipping Systems.

The AI Native Engineer

Sixteen hands-on chapters, one real product. Grow it from a single model call into a retrieved, tool-using, observable, production-grade system, with evaluation treated as a habit from the first feature.

Buy the book now

A sandbox that shared the house keys

On 24 July 2026, researchers at Accomplish AI published a flaw in the macOS build of Claude Cowork, tracked as CVE-2026-46331. The setup was ordinary. The agent ran inside a Linux virtual machine, which is exactly the isolation you would ask for, and the host filesystem was shared into that VM read-write so the agent could actually work on your files. An attacker who could get the agent to run their instructions could escalate to root inside the guest, and from there walk the shared mount straight into ~/.ssh, ~/.aws, and every other credential a developer keeps in a dotfile.

Anthropic’s response was to default new sessions to cloud execution. Local sessions stay exposed unless you restrict user namespaces, tighten seccomp, limit which kernel modules can autoload, and scope the host share or make it read-only.

That list of four mitigations is worth sitting with, because it tells you something the CVE number does not. Three of those four are about making guest root harder to reach. Only the last one is about making guest root less valuable. Most teams building agent infrastructure spend all their effort on the first three, then wonder why the incident review keeps arriving at the same paragraph.

There is a version of this article that is about one vendor’s patch cycle. That version is useless to you by September. The useful version asks why an isolation design that looked correct on a whiteboard produced a credential-theft primitive in practice, because that gap is going to keep opening under every agent runtime shipping this year.

What a VM boundary actually promises

A virtual machine gives you a hardware-enforced boundary between guest and host. Code in the guest cannot read host memory, cannot call host syscalls, and cannot see host processes. Compared to a container, which shares the host kernel and relies on namespaces and cgroups to keep tenants apart, a VM is a genuinely stronger wall. Teams reach for it precisely because agents run untrusted-ish code and the failure mode is ugly.

The promise has a footnote. A VM isolates the guest from the host except through the interfaces you deliberately open. Every shared folder, every forwarded port, every clipboard bridge, every socket you pass through is a hole you cut yourself. The hypervisor keeps enforcing its boundary perfectly while your data walks through the door you installed.

A read-write host mount of $HOME is the widest door available. It hands the guest a file-level API into the one directory that contains your SSH private keys, your cloud credential files, your .env files, your browser profile, and your password manager’s local database. No hypervisor escape required. No kernel exploit. The agent just opens the file.

The interfaces you forgot you opened

Mounts get attention because somebody has to type them into a config file. The rest of the door list is quieter, and it grows every time a workflow hits friction at 11pm.

Here is the set a typical agent container or VM has open on a developer laptop, with what each one is worth to code running inside:

InterfaceWhy it got addedWhat it hands the attacker
Host filesystem shareThe agent has to edit real filesRead and write on everything under the share
SSH_AUTH_SOCK forwarded ingit push over SSH stopped workingAuthentication as you to every host that trusts your key
Docker socket bind-mountedThe agent needs to build imagesRoot on the host daemon, which is root on the host
Host port forwardsThe dev server had to be reachable from the browserAccess to services bound to 127.0.0.1 that treat localhost as trusted
Environment passthroughAWS_PROFILE and friends were neededWhatever those variables name, including tokens a shell profile injected
Clipboard bridgeCopy and paste is table stakesAn exfiltration channel that no network policy inspects
Host DNS resolverCorporate hostnames must resolveInternal name enumeration, reconnaissance for free

The socket entries deserve a second look, because they are file-shaped and get filed mentally alongside harmless files. A forwarded SSH agent socket never exposes your private key; it exposes the ability to use it, for as long as the socket stays reachable. Code inside the guest can sign authentication challenges against any host that trusts that key, and those signatures look exactly like yours in the audit log. OpenSSH has carried the warning in its manual for years and the advice has never softened: forward the agent only to machines you would hand the key to outright.

The Docker socket is the worst entry on the list and the most common in build tooling. A process that can talk to /var/run/docker.sock can start a second container with --privileged and the host root filesystem mounted at /host, then write a systemd unit or edit sudoers. No kernel bug is involved anywhere in that sequence. The daemon is doing its documented job for whoever asks.

Auditing this on a running setup takes about one command per runtime:

   docker inspect --format '{{json .Mounts}}' agent-container | jq
docker inspect --format '{{.HostConfig.Privileged}} {{json .HostConfig.CapAdd}}' agent-container
docker inspect --format '{{json .Config.Env}}' agent-container \
  | jq -r '.[]' | grep -Ei 'token|key|secret|password|session'

The third line produces the surprises. Environment variables are the least visible credential store on a developer machine, because nothing about typing docker run prompts you to re-read what your shell exported half an hour earlier.

Every door on that list opens for ordinary code running as an ordinary user, which raises a fair question about the CVE at hand: if the filesystem was already readable, why did the researchers bother escalating at all?

Guest root was the whole game

Reaching root inside the guest matters here for a specific reason: the mount is usually squashed to the invoking user’s identity, so a normal guest user already sees the files. Root buys the attacker the ability to break out of whatever containment sits inside the VM, typically a container or a restricted user the agent runs as.

Unprivileged user namespaces are the usual path. They let a process become root inside a new namespace without any privilege on the host, which is a fine feature and also the entry point to a long list of kernel bugs. Combine that with a permissive seccomp profile that still allows unshare, clone with namespace flags, mount, and bpf, and combine that in turn with a kernel that will autoload an obscure filesystem or network module on demand, and you have a reachable attack surface measured in dozens of rarely-audited modules.

You can check the first of these in one line on any Linux guest:

   # 0 means unprivileged user namespaces are disabled. Anything else is a yes.
sysctl kernel.unprivileged_userns_clone
cat /proc/sys/user/max_user_namespaces

If that returns a non-zero value inside a VM whose only job is running an AI agent against your source tree, you are carrying risk you are not using. Agents need to read files and run builds. Almost none of them need to create user namespaces.

Written out as steps, the escalation is unglamorous, which is the point. Something has to get instructions into the agent, and in 2026 that something is usually content the agent was asked to read: a dependency’s README, a GitHub issue, a scraped page, a PDF dropped into the working directory. The instruction does not need to be clever. It needs to survive into the model’s context and be phrased as work.

From there the agent runs a command, because running commands is its job. The command creates a user namespace, gains root within it, and uses that root to load or abuse a kernel interface that the seccomp profile never contemplated. Now the process is outside whatever inner container the runtime set up. The shared mount is still sitting there, indexed and readable, and the last step is a single cat followed by an HTTPS request.

Notice how few of those steps involve breaking anything. One kernel bug, and the rest is documented behaviour used in an order nobody intended. Defences that assume an attacker must chain exploits at every stage will overestimate how much friction they are providing.

The chain as a diagram

Drawn out, the path from a poisoned README to a key in somebody else’s hands has seven hops, and exactly one of them requires a vulnerability.

Untrusted content enters context
dependency README, GitHub issue,
scraped page, PDF in the repo

Agent reads it as a task
and runs a command

unshare -Urm
root inside a new user namespace

Kernel interface reachable only
with namespaced root:
nf_tables, overlayfs, autoloaded module

Break out of the inner container
into the VM as real root

Walk the host share:
~/.ssh, ~/.aws, .env, browser profile

One HTTPS POST to an
attacker-controlled endpoint

Credential used from an IP
your logs have never seen

Hops three and six are where defence is cheap. Killing unprivileged user namespaces deletes hop three and makes hop four unreachable for most published exploit code, which almost universally begins with unshare -Urm because that is the only way an unprivileged process gets the capabilities the bug needs. Removing your home directory from the mount list makes hop six return nothing beyond the project you were already working on.

The pattern generalises past this one CVE. CVE-2026-23111 in the kernel’s nf_tables subsystem, disclosed earlier this year, is a use-after-free triggered by a single inverted character in nft_map_catchall_activate(), and the published write-ups all reach it the same way: unprivileged user namespace first, kernel primitive second, container escape third. Ubuntu’s AppArmor restriction on unprivileged namespaces blunts it, and researchers demonstrated that running the exploit under a permissive AppArmor profile via aa-exec was enough to sidestep that restriction on some configurations. Edera’s analysis of namespace attack surface put a number on the underlying problem, measuring roughly a 262% increase in the kernel code an unprivileged process can reach once user namespaces are available to it. Few teams would sign off on that as a change request. Most of them are running it as a distro default.

That is the argument for treating namespace access as a feature you switch off rather than a default you inherit. The agent workload needs to compile code and edit files. Whatever the next nf_tables bug turns out to be, an agent that cannot call unshare will not be the thing that reaches it.

The pattern, not the product

Treating this as a Claude Cowork bug misses the reusable lesson. The same shape shows up in tooling most engineering teams already run:

  • A VS Code dev container that bind-mounts the whole repo plus ~/.gitconfig and ~/.ssh so git push works inside the container.
  • A CI runner that mounts the Docker socket into a build container for “docker-in-docker”, handing the build root on the host daemon.
  • An MCP server given a filesystem tool rooted at / because scoping it to a project directory was fiddly during development.
  • A local coding agent run with --dangerously-skip-permissions against a home directory, on the reasoning that it is only a local machine.

Each of these is the same trade. Convenience says mount broadly, because narrow mounts break workflows in ways that are annoying to debug. Security says the blast radius of any code execution inside that box is now everything the mount can reach. The trade is usually made once, early, by whoever was trying to get the thing working, and then never revisited.

The credential inventory nobody wants to run

Revisiting that trade starts with knowing what it currently covers. Before changing a single mount, find out what sits inside the current one. The exercise takes twenty minutes and produces the only artefact strong enough to survive a sprint planning meeting: a list of named files with named consequences.

Enumerate in four passes. Well-known credential paths first, because they are the ones an attacker greps for by name. Then credential-shaped filenames anywhere under the mount, which is how you find the deploy-key.pem somebody dropped into a repo in 2023. Then live tokens in your shell environment and history. Finally, the things that are not files at all: agent sockets, browser session databases, and any daemon on localhost that accepts unauthenticated requests from anything that can reach the port.

   #!/usr/bin/env bash
# inventory.sh - what an agent with your home directory mounted can read.
ROOT="${1:-$HOME}"

echo "== well-known credential paths =="
for p in .ssh .aws .config/gcloud .azure .kube .docker/config.json \
         .netrc .npmrc .pypirc .gitconfig .git-credentials .gnupg \
         .config/gh/hosts.yml .terraform.d/credentials.tfrc.json \
         .config/op .cargo/credentials.toml; do
  [ -e "$ROOT/$p" ] && echo "FOUND  $ROOT/$p"
done

echo "== private key material anywhere under the mount =="
grep -rlI --exclude-dir=node_modules --exclude-dir=.git \
  -e 'BEGIN OPENSSH PRIVATE KEY' -e 'BEGIN RSA PRIVATE KEY' \
  -e 'BEGIN EC PRIVATE KEY' -e 'BEGIN PGP PRIVATE KEY' \
  "$ROOT" 2>/dev/null

echo "== env files with production-looking values =="
find "$ROOT" -name '.env*' -not -path '*/node_modules/*' 2>/dev/null \
  | xargs -r grep -lEi 'prod|live|secret|token|password|postgres://' 2>/dev/null

echo "== long opaque strings in shell history =="
grep -hoE '[A-Za-z0-9_-]{32,}' "$ROOT"/.*history 2>/dev/null | sort -u | head -20

echo "== unix sockets reachable from the mount =="
find "$ROOT" -type s 2>/dev/null

echo "== localhost listeners the guest may be able to reach =="
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -E '127\.0\.0\.1|\[::1\]'

Read the output as an attacker would rather than as its owner. The question against each line is not whether the file feels sensitive, but what one hour of holding it buys somebody.

Credential foundTypical lifetimeWhat a compromise costs
~/.ssh/id_ed25519 with no passphraseUntil you rotate it, so yearsPush access to every repository and shell on every host that trusts the key
~/.aws/credentials static access keyUntil revoked by handWhatever the IAM policy allows, mapped in minutes with sts get-caller-identity and a permissions enumerator
AWS SSO cache under ~/.aws/sso/cacheHours, refreshableThe same access, but expiring, and the refresh token can extend the window
~/.config/gh/hosts.yml tokenUntil revokedRepository write, workflow edit, and in many orgs the ability to push a workflow that mints stronger credentials
~/.npmrc publish tokenUntil revokedSupply chain write access to every package you can publish
.env with a production database URLEffectively foreverDirect read of customer data, with no audit trail separating you from whoever stole it
~/.kube/config contextVaries wildlyCluster access at whatever RBAC that context carries, often admin on a dev cluster that shares a VPC with production
Browser cookie or session databaseWeeksLive sessions to your identity provider, which is worse than any individual key
~/.gnupg signing keyYearsThe ability to sign commits and releases as you, which survives every credential rotation you perform afterwards

The shape of that table is that the damaging rows are the ones with no expiry. A stolen SSO cache entry is an incident with a deadline. A stolen SSH key is an incident with an anniversary.

Scoping the mount

The fix is not exotic. Mount the project, not the person. Here is the difference in a Docker invocation, which maps closely onto how most agent runtimes are configured underneath:

   # Wrong: the agent can read every credential you own.
docker run -it --rm \
  -v "$HOME:/workspace" \
  agent-image

# Better: only this project, and nothing above it.
docker run -it --rm \
  -v "$HOME/code/thisproject:/workspace:rw" \
  -v "$HOME/.gitconfig:/root/.gitconfig:ro" \
  --security-opt no-new-privileges \
  --cap-drop ALL \
  agent-image

Two details in the second command carry most of the weight. no-new-privileges stops setuid binaries from raising privilege inside the container, which removes a common escalation step. --cap-drop ALL takes away capabilities like CAP_SYS_ADMIN that mounting and namespace tricks depend on. Neither breaks a normal build.

For Lima and Colima, which back a lot of macOS container tooling, the equivalent lives in the VM config:

   # ~/.lima/agent/lima.yaml
mounts:
  - location: '~/code/thisproject'
    writable: true
  - location: '~/.cache/agent'
    writable: true
# Note what is absent: no "~" entry, no "/" entry.

The absence is the control. A mount list that does not include your home directory cannot leak your home directory, regardless of what the guest kernel does next.

Mount topology, before and after

Config diffs like that one lose arguments. Pictures of the same diff win them, because the shape of the exposure becomes obvious to people who never read lima.yaml.

Here is the common starting point, a machine set up for convenience:

Agent VM

macOS host

virtiofs share

$HOME

~/.ssh

~/.aws

Browser profiles

~/code/thisproject

/mnt/host read-write

Agent process

And here is the same setup with the share scoped to the work:

Agent VM

macOS host

virtiofs share

$HOME

~/.ssh not shared

~/.aws not shared

~/code/thisproject

/workspace read-write

/home/agent
ephemeral, lives in the VM

Agent process

One line crosses the boundary in the second picture instead of one line that carries everything. Guest root still gets guest root. It just inherits a directory you had already decided you could lose.

Mount strategyWhat the agent can readBlast radius after full guest compromise
$HOME read-writeEverything you ownTotal: keys, tokens, browser state, plus persistence via your shell profile
$HOME read-onlyEverything you ownTotal for exfiltration, no persistence on the host
/ read-writeEverything on the machineTotal, plus other users and system configuration
Project directory read-write, ~/.gitconfig read-onlyOne repositoryOne repository, plus whatever secrets that repository already contains
Project directory read-write, secrets injected at runtimeOne repository minus its secretsSource code, and short-lived tokens that expire on their own
Copy in, copy out, no bind mountA snapshotA snapshot, and nothing that survives the job

Choosing a row is mostly mechanical once you ask the questions in order:

No

Yes

Yes

Yes

No

No

No

Yes

Yes

No

Does the agent need this path
to finish the task?

Do not mount it

Does it contain secrets
or an unrelated project?

Can the secret be injected
at runtime instead?

Inject from the vault,
mount nothing

Split the directory and mount
only the part without secrets

Does the agent write to it?

Mount read-only

Is losing the contents survivable?

Mount read-write,
scoped to this path

Copy in, work on the copy,
review the diff before it lands

The bottom-right branch is the one most people have never tried, and it is how the more careful teams run agents against infrastructure repositories. The agent works on a copy, produces a patch, and a human applies it. You give up a little speed and you stop caring what a mistaken rm -rf does to your working tree.

A devcontainer that keeps its hands off your home directory

Most developers meet this problem through VS Code, where dev containers turn a bind mount into one line of JSON and offer no comment on what that line reaches. A hardened devcontainer.json is barely longer than the default.

   {
	"name": "agent-workspace",
	"image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04",

	"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached",
	"workspaceFolder": "/workspace",

	"mounts": [
		// One file, read-only. Not ~/.ssh, not the whole config tree.
		"source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,readonly",
		// Package caches live in named volumes, never on the host filesystem.
		"source=agent-npm-cache,target=/home/vscode/.npm,type=volume",
		"source=agent-pip-cache,target=/home/vscode/.cache/pip,type=volume"
	],

	"runArgs": [
		"--security-opt",
		"no-new-privileges",
		"--security-opt",
		"seccomp=/etc/docker/seccomp/agent.json",
		"--security-opt",
		"apparmor=docker-default",
		"--cap-drop",
		"ALL",
		"--pids-limit",
		"512",
		"--memory",
		"6g",
		"--network",
		"agent-net"
	],

	"containerEnv": {
		"HTTPS_PROXY": "http://egress-proxy:3128",
		"HTTP_PROXY": "http://egress-proxy:3128",
		"NO_PROXY": "localhost,127.0.0.1",
		"NODE_EXTRA_CA_CERTS": "/usr/local/share/ca-certificates/egress-proxy.crt"
	},

	"remoteUser": "vscode",
	"updateRemoteUserUID": true,

	// Deliberately absent: SSH_AUTH_SOCK forwarding, /var/run/docker.sock,
	// and a postCreateCommand that curls a script from the internet.
	"features": {}
}

The comment at the bottom carries as much weight as the settings above it. VS Code forwards your SSH agent into the container automatically when it finds one, which is convenient and is also the single fastest way to undo everything else in that file. Keep SSH_AUTH_SOCK out of the container environment and push from the host.

Seccomp is where you close the namespace path. Start from Docker’s published default profile and subtract, rather than writing a profile from scratch and discovering six months later which syscall your linter needed:

   curl -sL https://raw.githubusercontent.com/moby/moby/master/profiles/seccomp/default.json \
  -o /tmp/default.json

DENY='["unshare","setns","mount","umount2","pivot_root","bpf","keyctl",
       "add_key","request_key","open_by_handle_at","fsopen","fsconfig",
       "fsmount","move_mount","perf_event_open","process_vm_readv"]'

jq --argjson deny "$DENY" '
  .syscalls |= map(.names -= $deny)
  | .syscalls |= map(select((.names | length) > 0))
  | .syscalls += [{"names": $deny, "action": "SCMP_ACT_ERRNO", "errnoRet": 1}]
' /tmp/default.json | sudo tee /etc/docker/seccomp/agent.json > /dev/null

Docker’s default profile already returns ENOSYS for clone3, precisely because seccomp cannot inspect the flags inside its argument struct and glibc falls back to plain clone when it sees that error. Removing unshare and setns on top of that closes the ordinary route into namespace-dependent kernel bugs. A handful of tools genuinely need those calls, notably Bazel’s sandbox, pip with certain build isolation modes, and Podman running inside a container. Test against a real build before rolling it out. The failure mode is friendly: a clean EPERM visible in strace, not a mysterious hang.

Migrating one developer off the home-directory mount

Hardened config is easy to write and hard to adopt, because the first thing it does is break somebody’s Tuesday. The migration is worth doing as a scripted sequence with known failures and known fixes, so the developer spends fifteen minutes on it instead of reverting at the first error.

Start by copying the current mount list somewhere you can read it, then cut it to the project directory alone and restart the runtime. Do not try to predict what breaks. Run the developer’s actual first task of the day and collect errors.

The failures arrive in a predictable order:

What breaksWhyFix
git push fails with Permission denied (publickey)~/.ssh is gone and the agent socket is not forwardedPush from the host, or switch the remote to HTTPS with a scoped token injected at runtime
Commits are unsigned or rejected~/.gnupg is outside the mountSign on the host, or move to SSH signing with a hardware-backed key that never enters the container
npm install fails on a private registry~/.npmrc held the auth tokenInject NPM_TOKEN at runtime from the vault and reference it in a generated .npmrc inside the container
aws and kubectl report no credentials~/.aws and ~/.kube are outside the mountDecide whether the agent needs cloud access at all; if yes, inject a short-lived scoped role, never the profile directory
Builds are noticeably slowerThe bind mount lost its warm cachesNamed volumes for ~/.npm, ~/.cache/pip, ~/.cargo, and ~/.m2, all inside the container’s own storage
Files appear owned by root on the hostContainer UID differs from the host userupdateRemoteUserUID in dev containers, or --user "$(id -u):$(id -g)" on plain Docker
The shell feels wrong and aliases are missingDotfiles lived in the old mountBake the dotfiles into the image, or use the dev containers dotfiles repository setting, which clones them fresh
curl and package managers fail with TLS errorsThe egress proxy intercepts TLS and the container does not trust its CAInstall the proxy CA in the image and set NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, PIP_CERT, CARGO_HTTP_CAINFO
An OAuth login flow hangsThe callback expects a browser on 127.0.0.1Forward that single port explicitly, or complete the login on the host and inject the resulting token

Two entries on that list cause almost all the reverts. The TLS one looks like a network outage and gets blamed on the proxy, so install the CA in the base image before anybody touches the mount list. The cache one looks like the hardening made everything slow, which is the fastest way to lose an engineer’s support for a security change, so create the named volumes in the same commit that narrows the mount.

Give it a week before you judge the result. The genuine surprises show up on day four, when somebody needs to run a database migration against staging and discovers that the credentials they used to reach for are, correctly, no longer within arm’s reach of the agent.

Read-only helps less than you would hope

Making a share read-only is good advice with a real limit: the credentials you care about are readable files. An id_ed25519 private key, an ~/.aws/credentials file, and a .env holding a production database URL are all fully compromised by a read that succeeds. Write protection stops the agent from planting a backdoor in your shell profile, which is worth having, and does nothing about exfiltration.

Which means the durable answer is to stop keeping long-lived secrets in files the agent can reach at all. In practice that means a few habits:

  1. Move SSH keys behind a hardware token or an agent socket you forward selectively, so the private key never exists as a readable file in the mounted tree.
  2. Replace static cloud credentials with short-lived tokens from SSO or an instance role, so a stolen file expires in an hour instead of never.
  3. Keep .env files with production values out of the repo directory entirely, and inject them at runtime from a secret manager for the processes that genuinely need them.
  4. Run the agent as a user whose home directory is inside the sandbox, not a bind mount of yours.

The fourth is the one people skip, and it is the cheapest. If the agent’s $HOME is a scratch directory that lives and dies with the VM, then “the agent read its own home directory” stops being an incident.

Getting the keys out of the mount entirely

Those four habits need tooling behind them or they stay as intentions. Each one has a concrete implementation that takes an afternoon.

Start with SSH, because it is the credential developers hand over most casually. Forwarding SSH_AUTH_SOCK into an agent container is the default advice in half the dev container tutorials on the internet, and it gives any code in that container the ability to authenticate as you to every host in your known_hosts. Three alternatives, in increasing order of how much they inconvenience you:

   # 1. ProxyJump instead of agent forwarding. The connection to the target
#    is tunnelled through the jump host; your agent never reaches the jump host.
#    ~/.ssh/config
Host bastion
  HostName bastion.example.com
  User kubi
Host app-*
  ProxyJump bastion
  User deploy

# 2. Hardware-backed keys. The private half lives on the security key
#    and cannot be copied out of a mount, because it was never in one.
ssh-keygen -t ed25519-sk -O resident -O verify-required -f ~/.ssh/id_sk

# 3. If you must run an agent, constrain it: confirmation prompt on every
#    use, and a lifetime short enough that a stolen socket goes stale.
ssh-add -c -t 900 ~/.ssh/id_ed25519

The -c flag is the underrated one. It makes every signature request pop a confirmation dialog on the host, which turns a silent background exfiltration attempt into a prompt appearing while you are making coffee. Developers who forward agents into containers should be running with -c regardless of anything else in this article.

For cloud access, the goal is that nothing durable exists on disk. AWS SSO writes a cache under ~/.aws/sso/cache that expires; static keys in ~/.aws/credentials do not. Enumerate which one your team actually uses:

   grep -rl 'aws_secret_access_key' ~/.aws 2>/dev/null && echo "static keys present"
aws configure list-profiles | while read -r p; do
  aws configure get sso_start_url --profile "$p" >/dev/null 2>&1 \
    && echo "$p: SSO" || echo "$p: STATIC, fix this"
done

For application secrets, inject at process start rather than writing them anywhere the agent can read. Both major password managers and both major vault products support this shape:

   # 1Password: the .env file contains references, never values.
# .env  ->  DATABASE_URL=op://Engineering/staging-db/connection_string
op run --env-file=.env --no-masking=false -- npm run dev

# HashiCorp Vault: fetch a short-lived database credential at start.
export DATABASE_URL="$(vault read -field=connection_string \
  database/creds/app-staging)"

# SOPS with age: encrypted at rest in the repo, decrypted into the process.
sops exec-env secrets.staging.enc.yaml 'npm run dev'

The property worth protecting in all three is that the plaintext exists in process memory and nowhere on the filesystem. An agent that later reads .env finds op://Engineering/staging-db/connection_string, which is a pointer requiring an authenticated session the container does not hold.

There is a cost, and it is real. Every one of these adds a step between an engineer and the thing they were trying to do, and engineers route around steps. Pick the two that match how your team already works and drop the rest, because a credential policy that gets bypassed is worse than a narrower one that gets followed.

Egress is the other half of the boundary

Reading a secret and using a secret are separate steps, and the second one needs the network. An agent runtime that can open arbitrary outbound connections turns any file read into an exfiltration. The same runtime behind a default-deny egress policy turns that read into a failed connection and, if you are logging properly, an alert.

Default-deny outbound is more practical for agent workloads than for general developer machines, because the destination list is short and knowable: your model provider’s API, your package registry, your git host, and whatever internal services the task actually needs. Everything else can be refused.

   # Illustrative: allowlist egress for an agent container by DNS name,
# enforced at the proxy rather than in the container itself.
ALLOWED_HOSTS="api.anthropic.com,registry.npmjs.org,github.com"
docker run -it --rm \
  --network agent-net \
  -e HTTPS_PROXY="http://egress-proxy:3128" \
  -e NO_PROXY="" \
  -v "$HOME/code/thisproject:/workspace" \
  agent-image

Putting enforcement in a proxy outside the container matters, because a compromised container can rewrite its own environment variables and iptables rules. A control the attacker can edit is a suggestion.

Building the egress proxy for real

Putting the control outside the container means giving the container no route to the internet at all, which Docker expresses with an internal network. The proxy sits on two networks and is the only thing that bridges them.

   # docker-compose.yml
networks:
  agent-net:
    internal: true # no route off the host, at all
  egress-net: {}

services:
  egress-proxy:
    image: ubuntu/squid:latest
    networks: [agent-net, egress-net]
    volumes:
      - ./squid.conf:/etc/squid/squid.conf:ro
      - squid-logs:/var/log/squid

  agent:
    image: agent-image
    networks: [agent-net] # agent-net only. This is the whole trick.
    environment:
      HTTPS_PROXY: http://egress-proxy:3128
      HTTP_PROXY: http://egress-proxy:3128
    volumes:
      - ${HOME}/code/thisproject:/workspace

volumes:
  squid-logs:

The Squid config that goes with it stays short, because a long allowlist is a sign somebody stopped thinking about it:

   # /etc/squid/squid.conf
http_port 3128

acl allowed_hosts dstdomain \
  api.anthropic.com \
  .githubusercontent.com \
  github.com \
  registry.npmjs.org \
  pypi.org files.pythonhosted.org
acl SSL_ports port 443
acl CONNECT method CONNECT

http_access deny !allowed_hosts
http_access deny CONNECT !SSL_ports
http_access allow allowed_hosts
http_access deny all

# Every decision, allowed or denied, ends up here.
access_log stdio:/var/log/squid/access.log squid

Matching on the CONNECT hostname rather than intercepting TLS keeps this simple and avoids installing a CA everywhere, at the cost of trusting the hostname the client asks for. A determined attacker inside the container can request github.com and connect to an IP that is not GitHub, so pair the allowlist with DNS you control and refuse to let the container use its own resolver.

When you need to see inside the traffic, mitmproxy with a small addon gives you request-level rules and a readable log:

   # egress_filter.py  ->  mitmdump -s egress_filter.py --listen-port 8080
from mitmproxy import http

ALLOWED = {
    "api.anthropic.com": ["/v1/"],
    "github.com": ["/"],
    "registry.npmjs.org": ["/"],
}

def request(flow: http.HTTPFlow) -> None:
    host = flow.request.pretty_host
    paths = ALLOWED.get(host)
    if paths is None or not any(flow.request.path.startswith(p) for p in paths):
        flow.response = http.Response.make(403, b"egress denied\n")
        print(f"DENIED {flow.request.method} {host}{flow.request.path}")
        return
    # Bodies above a threshold to an allowed host are still worth flagging.
    if len(flow.request.content or b"") > 256_000:
        print(f"LARGE-UPLOAD {host}{flow.request.path} "
              f"{len(flow.request.content)} bytes")

That last check earns its place. Exfiltration through an allowed destination is the obvious next move once a plain allowlist is in the way, and a coding agent that suddenly POSTs 400KB to a paste service or an unusual GitHub Gist endpoint is a signal you can alert on without any understanding of what the payload contains. Log the denials somewhere a human reads. A default-deny policy nobody monitors catches the accident and misses the attack.

Three shapes, three different bets

The right configuration depends on where the agent runs, and the three common deployments fail differently enough that a single policy will not fit them.

The local developer machine is the hardest case and the most common. The whole appeal is that the agent sees your real project, with your real toolchain and your real credentials, which is also precisely the exposure. The honest position here is that a local agent with broad file access is a productivity tool you have decided to trust, not an isolated system, and it should be configured by someone who knows they made that choice. Scope the mount to one project directory, keep credentials out of that directory, and accept that the remaining risk is real.

The CI runner is easier and usually worse in practice. Ephemeral, disposable, no human dotfiles: excellent. Then someone mounts the Docker socket, or injects a deploy key with write access to forty repositories, or gives the job an OIDC role that can touch production. The container dies after ten minutes and the credentials it held do not. Short-lived tokens scoped to the single repository under test fix more here than any amount of sandbox hardening.

The hosted execution environment is where the isolation story is strongest, since the provider runs the workload nowhere near your laptop. What you give up is visibility. You are now trusting somebody else’s mount configuration, somebody else’s seccomp profile, and somebody else’s incident disclosure timeline. That trade is often correct. It is still a trade, and the question to ask a vendor is not whether they sandbox, but what specifically crosses the boundary and in which direction.

Controls by shape

Laid out side by side, the differences turn into a checklist rather than a philosophy:

ControlLocal developer machineCI runnerHosted execution
Mount scopeOne project directory, read-writeCheckout only, created by the jobProvider-managed, ask what it is
Agent $HOMEInside the sandbox, ephemeralEphemeral by constructionProvider-managed
Unprivileged user namespacesDisabled in the guestDisabled in the guestAsk the vendor, in writing
Capabilities--cap-drop ALL--cap-drop ALL, no --privilegedNot yours to set
Docker socketNever mountedRootless buildkit insteadNot applicable
CredentialsShort-lived, injected per taskOIDC token scoped to one repositoryScoped API keys, rotated on a schedule
EgressAllowlist proxy, loggedAllowlist proxy, loggedVendor policy, plus your own API key scoping
Retention after the jobSnapshot reset or fresh VMFresh runner, never reusedVendor policy on prompt and file retention
Detection signalProxy denials and unexpected file readsJob logs and token usage from unexpected IPsProvider audit log, if one exists

The column that reads worst is usually CI, and the reason is always the same: someone treated ephemerality as if it were isolation. A container that dies in ten minutes still held a deploy key with write access to forty repositories for those ten minutes.

Anti-patterns that keep showing up

Reviewing agent setups across teams, the same handful of mistakes account for most of the exposure, and none of them look like mistakes at the moment they are made.

Mounting $HOME because scoping was fiddly. The original sin, and usually traceable to a single evening where a narrower mount broke git and nobody had time. It survives because it never fails again.

Treating the VM as the control and the mount as plumbing. Teams that would refuse a --privileged container will cheerfully share a home directory into a VM, because the word “VM” carries a reassurance the configuration does not earn.

Trusting repository-local configuration. Agent runtimes read config from the working directory, which means a cloned repository can ship settings that change what the agent is allowed to do. This is not hypothetical: CVE-2025-59536 covered remote code execution through Claude Code project configuration files, CVE-2026-25725 covered sandbox escape through injected settings.json, and CVE-2026-48124 covered a Cursor workspace hook configuration that ran commands outside the sandbox. Treat .vscode/, .cursor/, .devcontainer/, and every agent config directory in a cloned repository as untrusted input that a human reads before the agent does.

Approving permissions once and forgetting the grant. Persistent allow-lists of shell commands accumulate. The developer who approved curl for one task has approved it permanently, and curl is a complete exfiltration toolkit.

Running --dangerously-skip-permissions on a real machine. The flag is honest about what it does. It is used anyway, because the prompts are annoying, and the reasoning is always that this is only a local machine. The local machine is the one holding production credentials.

Putting the egress control inside the sandbox. iptables rules and HTTPS_PROXY variables set inside the container are configuration a compromised container can rewrite. Enforce on the network, outside the workload.

Mounting the Docker socket for build tooling. Rootless BuildKit or a remote builder does the same job without handing over root on the host daemon. The socket mount is a habit inherited from CI templates written before anyone was running untrusted-ish content through them.

Sharing one long-lived VM across every project. Snapshot reuse means a compromise from Tuesday’s task is still resident during Thursday’s. A sandbox that never resets has become a second computer with worse hygiene than your laptop.

Assuming prompt injection needs to be sophisticated. The bar sits lower than that. Phrase the instruction as work, place it in content the agent was asked to read, and the runtime does the rest for you.

Score your own setup

Opinions about agent security get resolved fastest by a number. This script checks the controls that matter and prints a score, and it is deliberately short enough to read before running:

   #!/usr/bin/env bash
# agent-audit.sh - run inside the agent sandbox. 10 points available.
score=0; max=10
chk() { if eval "$2" >/dev/null 2>&1; then echo "PASS  $1"; score=$((score+1));
        else echo "FAIL  $1"; fi }

chk "unprivileged user namespaces disabled" \
    '[ "$(cat /proc/sys/user/max_user_namespaces 2>/dev/null || echo 0)" = "0" ]'
chk "no new privileges set" \
    'grep -q "NoNewPrivs:.*1" /proc/self/status'
chk "seccomp filter active (mode 2)" \
    'grep -q "Seccomp:.*2" /proc/self/status'
chk "no CAP_SYS_ADMIN in effective set" \
    '! capsh --print 2>/dev/null | grep -q cap_sys_admin'
chk "docker socket not present" \
    '[ ! -S /var/run/docker.sock ]'
chk "no host home directory mounted" \
    '! grep -qE " /(host|mnt/host|Users|home/[a-z]+) " /proc/mounts'
chk "no ssh private keys readable" \
    '! find / -xdev -name "id_*" ! -name "*.pub" -readable 2>/dev/null | grep -q .'
chk "no static cloud credentials readable" \
    '! find / -xdev -path "*/.aws/credentials" -readable 2>/dev/null | grep -q .'
chk "no ssh agent socket forwarded in" \
    '[ -z "$SSH_AUTH_SOCK" ]'
chk "egress blocked to an arbitrary host" \
    '! curl -s --max-time 5 https://example.com > /dev/null'

echo "---"
echo "score: $score / $max"
[ "$score" -ge 8 ] || echo "Below 8 means a single code execution inside this box is a credential incident."

Anything at seven or below is a setup where one successful prompt injection ends with somebody else holding your keys. Most first runs land between three and five, and the two cheapest points to recover are almost always the namespace check and the mount check.

After a suspected agent compromise

Assume it happens, because the detection signal is usually indirect: a proxy denial to a domain nobody recognises, a git push you did not make, a cloud API call from an unfamiliar region. The response differs from a normal workstation incident in one respect, which is that the agent has a transcript, and the transcript tells you what it was told to do.

Work in this order.

  1. Cut the network, keep the machine running. Pull the interface or the container off its network rather than powering down. Memory-resident tokens and the running process tree are evidence you lose on reboot.
  2. Rotate on the assumption of total loss. Every credential the inventory script found inside the mount is compromised until proven otherwise. SSH keys, cloud access keys, registry tokens, personal access tokens, session cookies. Rotation ordering matters: revoke the tokens that can mint other tokens first, which usually means the git host token and any cloud role with IAM write.
  3. Read the transcript backwards. Find the first tool call that does not correspond to something you asked for, then find the content that entered context immediately before it. That content is the injection vector, and it is probably still sitting in a repository, an issue thread, or a cached page that will re-infect the next session.
  4. Pull the proxy logs. A default-deny egress log turns “did anything leave” from speculation into a query. Look for denied destinations, large POST bodies to allowed hosts, and DNS queries for domains that resolve to nothing.
  5. Check for persistence on the host. If the mount was read-write, an attacker with guest root could write to your shell profile, ~/.ssh/authorized_keys, a launch agent under ~/Library/LaunchAgents, or a git hook in any repository under the mount. Git hooks are the sneakiest of these, because they execute on your next commit and live in .git/hooks, which nobody diffs.
  6. Audit what the credentials did, not just that they existed. CloudTrail, the git host audit log, and registry publish history for the window between first suspicious tool call and rotation.
  7. Destroy the sandbox, rebuild from image. No cleaning, no selective removal. If the VM has a snapshot from before the incident, that snapshot is also suspect unless you can point at when the injection arrived.

The step people skip is the third one, and it is the one that stops a repeat. Rotating keys without finding the poisoned README means the next session reads the same file and does the same thing to your fresh credentials.

What to actually do this week

If you run coding agents locally, the highest-value hour you can spend is auditing what your current setup mounts and what it can reach. Open the config for whatever runtime you use, find the mount list, and ask of each entry whether a full compromise of the guest would be survivable. Then check three things inside the guest: whether unprivileged user namespaces are available, whether the container drops capabilities, and whether outbound traffic is filtered at all.

Most teams find at least one entry that exists because of a workflow problem someone solved at 11pm six months ago. Narrowing it usually costs one broken command and one small fix.

A useful exercise, if you want a number rather than a feeling, is to write down what an attacker gets from a full compromise of the agent VM right now. Not what they might get in theory. What is actually reachable today, named file by named file. Teams that do this honestly tend to discover the answer is “everything”, and tend to fix it within the week, because “everything” is much harder to defer than “some residual risk”.

The uncomfortable part of CVE-2026-46331 is how little novelty it required. No exotic hypervisor escape, no chain of four zero-days. Somebody noticed that the strong wall had a door in it, and that the door led to the exact place where developers keep the keys to production. Every agent runtime you deploy will have that door somewhere. Your job is knowing where you put it, and how narrow you can make it before the work stops getting done.

For the adjacent problem of what happens when the agent itself decides the constraint is an obstacle rather than a boundary, the OpenAI sandbox escape disclosed the same week is the more unsettling case study.