Security Engineering

Shell Execution and Blast Radius: What an Agent Can Actually Reach

David Guzenburg/ / 8 min read

An allow-list containing 'npm test' permits arbitrary code execution. So does 'make', 'pytest' and 'git'. Containment has to happen somewhere other than the command name.

shell accesssandboxingcontainersegress filtering

Shell access is the whole machine

Granting an agent the ability to run shell commands is not adding one capability. It is adding every capability the shell can reach, which on a developer laptop is essentially everything: the filesystem, the network, the SSH keys, the cloud CLI already authenticated from last Tuesday, the container runtime, the package manager.

Reviews of agent security consistently list shell execution alongside repository permissions, prompt injection and credential leaks as the dimensions that matter. It deserves that placement, because it is the capability that turns every other weakness into a bigger problem — an injected instruction that could otherwise only misuse one API can, with shell access, do anything the user can do.

Enumerate what is actually reachable

Most people underestimate this, because the reachable set is not the set of things they think about. On a typical developer machine, an agent with shell access can reach:

SurfaceTypically holds
~/.ssh/Private keys for every host you connect to
~/.aws/, ~/.config/gcloud/Long-lived cloud credentials
~/.npmrc, ~/.pypircRegistry publish tokens
~/.gitconfig, credential helperGit host tokens
~/.kube/configCluster admin, frequently
Docker socketRoot on the host, in practice
Other repositories on diskSource for projects unrelated to this task
Outbound networkAny destination, unrestricted

The last row is the one that converts a read into a breach. Reading a file is a local event. Reading a file and being able to POST it somewhere is exfiltration. If you restrict one thing, restrict egress.

The Docker socket

Mounting /var/run/docker.sock into anything an agent can reach is equivalent to giving it root on the host. A container can be started with the host filesystem mounted. Treat socket access as the highest privilege on the machine, because it is.

Containment, in increasing order of strength

1. A separate user account

Run agent sessions as a user that is not you. It gets its own home directory, without your SSH keys or cloud config. Cheap, and it removes the largest category of reachable secrets in one step.

2. A container

Mount only the repository being worked on. No socket, no home directory, no host network. The agent gets a filesystem containing exactly the code it needs and nothing else.

#!/bin/bash
docker run --rm -it \
  --mount type=bind,src="$PWD",dst=/work \
  --workdir /work \
  --network agent-net \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --memory 4g --cpus 2 \
  --user "$(id -u):$(id -g)" \
  agent-sandbox:latest

Note what is absent: no -v /var/run/docker.sock, no --network host, no home directory mount. Every one of those is a common convenience that reopens the boundary you just built.

3. Egress filtering

The single highest-value control, and the most often skipped. Default-deny outbound, with an allow-list for what the work actually requires: your package registry, your git host, your model provider.

allow:
  - registry.npmjs.org:443
  - github.com:443
  - api.your-model-provider.com:443
deny: *

With this in place, a session that has read every secret on the mounted volume still cannot send them anywhere. That is a qualitative change in outcome, not an incremental one.

4. A disposable VM

For genuinely untrusted work — evaluating an unknown dependency, running an unreviewed MCP server — a VM discarded afterwards is the strongest practical option. Slower to start, and the only one that survives a container escape.

Command allow-lists rarely survive contact

A tempting middle path is to permit only certain commands. It is weaker than it looks, for a reason worth internalising: most permitted commands can run other commands.

AllowedReaches
npm testArbitrary code, via test files and lifecycle scripts
makeAnything in the Makefile
python -m pytestArbitrary code, via conftest.py
gitArbitrary code, via hooks and aliases
dockerRoot on the host

An allow-list containing any build or test command is not a meaningful restriction on what can execute. It is a speed bump. Useful for preventing mistakes, not for containing an adversary — and worth being honest about which of those you are buying.

The convenience mounts that undo everything

Containment usually fails through a mount added to fix a real annoyance rather than through a decision anyone would defend in a review. Four in particular are worth naming, because each is a reasonable-sounding fix that reopens the boundary entirely.

Added to fixWhat it grants
-v ~/.ssh:/root/.ssh — "git clone fails" Every host your keys reach
-v ~/.aws:/root/.aws — "needs cloud access" Your whole cloud account, at your permissions
--network host — "can't reach the dev server" Every service bound to localhost, unauthenticated
-v ~:/host — "needs a file from downloads" Everything

Row three deserves particular attention. Services bound to 127.0.0.1 are frequently unauthenticated precisely because they are local-only — a database with no password, an admin panel with no login, a debug endpoint. --network host hands all of them over at once.

Each has a narrower alternative. A deploy key scoped to one repository instead of your SSH directory. A short-lived, read-only cloud credential injected as an env var instead of the config folder. A user-defined bridge network with only the containers you meant to expose. Copying one file in instead of mounting your home directory.

CI is a different problem

Everything above assumes a developer machine. An agent running in CI inverts several of the assumptions, mostly in your favour and in one respect not.

Better: the environment is already ephemeral, already isolated, and already has no personal credentials in it. A CI runner is closer to the disposable-VM model than to a laptop, for free.

Worse: CI runners frequently hold deployment credentials, registry publish tokens and production access, because that is what CI is for. An agent running there inherits the most powerful credential set in the organisation, in an environment with no human watching in real time.

The resolution is to give agent jobs their own runner and their own identity, rather than reusing the deployment pipeline's. Same container, same ephemerality, none of the credentials that make CI attractive to attack.

Blast radius as a design question

The productive framing is not "is this safe" but "what is the worst outcome, and can I live with it".

SetupWorst case
Shell on your laptop, as youEvery credential you own, exfiltrated
Separate user, no egress limitsRepository contents exfiltrated
Container, repo mounted, egress deniedRepository modified locally; nothing leaves
Disposable VM, egress deniedA VM you throw away

Row one is where most people are, and it is defensible for a solo developer working on their own code. It stops being defensible the moment the session reads anything from outside — a fetched page, a public issue, a third-party dependency — because at that point the direction of the session is no longer solely yours.

Takeaway

Shell access grants everything the shell can reach, which on a laptop is every credential you have. Run as a separate user at minimum, containerise without the Docker socket, and above all filter egress — a session that cannot send data out cannot exfiltrate what it read. Command allow-lists are speed bumps, not boundaries.

Keep reading
Tooling & Integration

Sandboxing Agent Environments for Reproducibility, Not Just Safety

The operational case for containerising agent sessions: environment drift, parallel work and cleanup. A working setup, the mounts to refuse, and how to make the sandbox the easy path.

Codex vs Claude

Host Administration by Agent: Sorting Changes by How Badly They Undo

An agent is genuinely good at diagnosis and command composition, and a host has no git. Reversible versus restorable versus unrecoverable, the docker prune that eats your local database, and scheduling the revert before you touch the firewall.

Codex vs Claude

Local Execution: It Worked in the Session Is the New Works on My Machine

The correctness half of running an agent in your own shell: undeclared toolchains, aliases that rewrite commands, interactive prompts with no answer, and accumulated session state. What to declare in the repository and what to leave ambient.

Codex vs Claude

Below the Prompt: What a Kernel Sandbox Actually Constrains

Seatbelt, bwrap and seccomp enforce policy that injected text cannot argue with. What a profile can express, why a workspace-write policy still permits everything inside your repository including .git/hooks, and a self-test that proves the policy is on.

← AI Gateways and Data Retention: Enforcing Policy in the Request Path  ·  Credential Boundaries: What an Agent Should Never Be Able to See →

All security engineering articles  ·  Every article