A coding agent reads source code, instructions and web pages and acts on them. It edits files, runs commands and calls services on the network. Any text it reads can therefore influence what it does, including a comment in a dependency or a prepared web page. This risk is known as prompt injection (OWASP LLM01 ). It becomes critical when an agent reads private data, is exposed to untrusted content and can communicate externally at the same time (Willison ). On a developer’s machine, all three conditions apply.
Goal and scope
I built cjx , a tool that runs an
agent in a container and restricts three things. The project is read-only by
default and becomes writable only with --rw. Network connections are possible
only to hosts on an allowlist. The container runs without privileges.
cjx supports Claude Code, OpenAI Codex and pi.dev, and further agents can be
added through a configuration file.
The sandbox is a practical boundary for an agent’s behaviour. It does not protect against a kernel vulnerability that breaks out of a container.
Architecture
Each run creates an agent container with all Linux capabilities dropped. It
runs with no-new-privileges, a read-only root file system and limits on CPUs,
memory and processes. Only ephemeral tmpfs directories such as /tmp, the home
volume and, with --rw, the project and directories added with --add-dir are
writable. The home volume belongs to one agent and mode and holds its login and
history. It persists across sessions and lives in the engine’s volume store, not
in a directory of the user’s.
The images are built in layers. A base image contains Debian, Node.js and a
user for the agent, and an image per agent installs that agent with its
official install script on top. For Claude Code, the base image also adds a
managed policy that forbids Claude Code to read .env files and secrets/
directories (Claude Code permissions ).
It covers Claude Code’s own tools, not scripts the agent runs. A hash over the
build files and the agent definition, stored as an image label, determines when
an image is rebuilt.
A firewall per session
Each run has a network and a Squid container of its own. The network is internal and has no route to the outside. Under Podman it also has no DNS server, and since a security fix Docker no longer forwards DNS queries from internal networks to the outside (CVE-2024-29018 ). The agent can therefore neither resolve names on the internet nor open connections to it. Squid is attached both to this network and to the engine’s default network and serves as the agent’s proxy.
I configured Squid to allow only the CONNECT method
(RFC 9110 ) on port 443,
and only to destinations on the allowlist.
acl allowed_dst dstdomain "/etc/cjx/allowlist"
http_access allow CONNECT SSL_ports allowed_dst
http_access deny all
The allowlist combines the agent’s hosts with the user’s entries. Squid sees only the destination name and leaves the content of the TLS connection untouched. If a proxy is set in the shell and reachable from the host, Squid forwards through it. At the end of a session, both containers, the network and the session data are removed.
Signing in inside the sandbox
The agents sign in to their provider with OAuth. On a desktop, the agent opens the browser and receives the response through a local callback server (RFC 8252 ). In the sandbox, this server runs inside the container, where the host’s browser cannot reach it.
The vendors provide their own ways for this case. Claude Code and pi.dev show
the sign-in link and then accept the code or the final URL as input. Codex
offers device code sign-in (RFC 8628 ),
which has to be enabled once in the ChatGPT security settings. A code is then
entered in the browser. The login is stored in the home
volume and thus applies to every later session. cjx passes the variables that
describe the terminal, such as TERM_PROGRAM and COLORTERM, into the
container, so an agent uses clickable links and colours as it would outside.
No other shell variables reach the built-in agents.
Podman and Docker
cjx is meant for rootless Podman and also supports a Docker daemon with root privileges. I prefer rootless Podman, since the container runtime has no privileges beyond the user’s. Under Docker, the daemon runs as root, and anyone who can reach it gains root privileges on the host (Docker security ).
The agent has to read and write the mounted project under the user’s identity.
Podman maps the user’s uid to the user in the container with --userns keep-id
(podman run ),
and under Docker cjx passes the uid and gid with --user. cjx refuses two
Docker setups. Rootless Docker maps the user to root in the container and all
other identities into a range of subordinate IDs
(rootless mode ).
With userns-remap, the daemon shifts every identity of every container, root
included, into such a range
(userns-remap ). In
both cases, the mounted project does not belong to the agent inside the
container.
Limitations
The allowlist restricts destinations, not data. An allowed host remains a
possible channel to the outside, including the model provider’s own API. The
policy against reading .env files covers only Claude Code’s own tools. Scripts
an agent runs, and the other agents, read whatever the mount exposes. With
--no-firewall, the network restriction is lifted entirely. Under Docker, the
daemon with root privileges remains part of the trusted base, and Codex runs
there without the seccomp filter, since its bubblewrap sandbox needs a user
namespace.
Conclusion
cjx separates what an agent does from the machine it runs on. Writing requires permission, the network is limited to named hosts, and the login stays in the container. The agent keeps working as usual, and its reach does not extend beyond these permissions.