Shell Execution and Blast Radius: What an Agent Can Actually Reach
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 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:
| Surface | Typically holds |
|---|---|
~/.ssh/ | Private keys for every host you connect to |
~/.aws/, ~/.config/gcloud/ | Long-lived cloud credentials |
~/.npmrc, ~/.pypirc | Registry publish tokens |
~/.gitconfig, credential helper | Git host tokens |
~/.kube/config | Cluster admin, frequently |
| Docker socket | Root on the host, in practice |
| Other repositories on disk | Source for projects unrelated to this task |
| Outbound network | Any 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.
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.
| Allowed | Reaches |
|---|---|
npm test | Arbitrary code, via test files and lifecycle scripts |
make | Anything in the Makefile |
python -m pytest | Arbitrary code, via conftest.py |
git | Arbitrary code, via hooks and aliases |
docker | Root 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 fix | What 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".
| Setup | Worst case |
|---|---|
| Shell on your laptop, as you | Every credential you own, exfiltrated |
| Separate user, no egress limits | Repository contents exfiltrated |
| Container, repo mounted, egress denied | Repository modified locally; nothing leaves |
| Disposable VM, egress denied | A 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.
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.