- Shell 98.3%
- Dockerfile 1.4%
- Makefile 0.3%
| .ai | ||
| .claude | ||
| docs/superpowers | ||
| tests | ||
| .dir-locals.el | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| codex.sh | ||
| COPYRIGHT | ||
| Dockerfile | ||
| install.sh | ||
| Makefile | ||
| README.md | ||
Codex Jail
Run the OpenAI Codex CLI inside a rootless Podman container.
Codex needs broad filesystem access to be useful. Running it in a container gives you the convenience of a fully capable coding agent without exposing your entire home directory, system binaries, or credentials beyond what you explicitly mount.
Why a jail?
Codex CLI ships with its own sandbox and approval system, but those primitives still run on top of your real user account. A misconfigured prompt or a sandbox bypass means the agent can touch anything you can. A container inverts the model: Codex sees an empty Debian system, plus only the paths you mount in. The host filesystem, your credentials, and unrelated projects stay out of reach.
Inside the container we pass --dangerously-bypass-approvals-and-sandbox
because the container itself is the sandbox — Codex can only act on the
explicitly mounted workspace and its own state directory.
Prerequisites
- Podman 4.3 or newer (installed automatically by the install script, or bring your own)
- A valid Codex account or OpenAI API key
Install
git clone https://git.unitoo.it/claudiomaradonna/codex-jail
cd codex-jail
./install.sh
The installer:
- Installs Podman if it is not present (apt/dnf/pacman/brew).
- Builds the
codex-cli:latestimage and dual-tags it with the detected Codex CLI version (e.g.codex-cli:0.45.0). - Drops the
codexwrapper into~/.local/bin/.
Make sure ~/.local/bin is in your PATH:
export PATH="${HOME}/.local/bin:${PATH}"
Installer options
./install.sh --image my-codex:dev # custom image name (skips version dual-tag)
./install.sh --no-cache # full rebuild bypassing layer cache
Usage
codex . # mount current directory
codex . --model gpt-5-codex # pass native codex flags
codex --with-ssh-agent . exec "fix" # SSH agent + non-interactive exec mode
# Headless: no TTY is allocated when stdin or stdout is redirected
codex . exec "run the tests" > report.log 2>&1
codex --image my-image . # use a custom image
codex . --mount /data:/data:ro # mount /data as read-only
codex . --mcp-image ghcr.io/mudler/mcps/localrecall:latest # MCP server from an image
codex . --dry-run # print the podman command, run nothing
codex --help # full option list
Anything the wrapper does not recognise is forwarded verbatim to codex, so
all native subcommands (exec, resume, login, …) and flags (--model,
--profile, --config, --cd, …) work as usual.
Where the directory may appear: before the first argument bound for
codex. Wrapper flags such as --with-ssh-agent may precede it, but once
something the wrapper doesn't recognise is seen, every later path belongs to
codex. That is what stops an option value from being mounted by accident —
in codex . -p docs, the docs is -p's value and reaches codex, rather
than becoming the directory the container sees. Use -- to place the
boundary yourself:
codex . -- --model gpt-5-codex # everything after -- goes to codex untouched
A -- marks the wrapper's boundary only while nothing bound for codex has
been seen yet. Once codex's arguments have started it is codex's own and
is forwarded along with everything after it — so
codex . mcp add my-server --env KEY=value -- /path/to/server reaches the
CLI intact.
Passing two directories is an error rather than a silent choice between them. The resolved workspace is printed to stderr at startup, so a wrong mount is visible immediately.
Authentication
Codex CLI supports two auth modes:
- API key — export
OPENAI_API_KEYin your shell or set it in.env.codex. The wrapper forwards it into the container. - ChatGPT sign-in — run
codex logininside the container; the resulting~/.codex/auth.jsonis persisted on the host and reused on every run.
Unlike Claude Code, Codex CLI keeps a single login per host: splitting
state across multiple sessions would force you to log in again for each
one. The wrapper therefore mounts ~/.codex-jail/ directly as
/home/codex/.codex inside the container, so auth, config, conversation
history and logs are shared across every workspace — the same way the
native codex CLI behaves on your host.
The wrapper maps the invoking host user to the image's codex user (UID/GID
1000) through Podman's keep-id user namespace. The shared state mount
therefore remains writable without Podman's :U option, which would
recursively change ownership across the state tree and can make container
startup increasingly slow as history accumulates.
Configuration: .env.codex
Drop a .env.codex file in your workspace and the wrapper loads it. Two
categories of variables are recognised:
Script-level (consumed by codex.sh, not forwarded into the container):
| Variable | Default | CLI override |
|---|---|---|
CODEX_JAIL_IMAGE |
codex-cli |
--image |
CODEX_JAIL_WORKSPACE |
/workspace |
--container-workdir |
CODEX_JAIL_NETWORK |
(none) | --network |
CODEX_JAIL_USE_SSH |
0 |
--with-ssh-agent |
CODEX_JAIL_MOUNTS |
(none) | --mount (repeats) |
CODEX_JAIL_MCP_IMAGES |
(none) | --mcp-image (repeats) |
CODEX_JAIL_IGNORE |
.codexignore |
--ignore-file |
CODEX_JAIL_NO_TTY |
0 |
--no-tty |
CONTAINER_MAX_MEMORY |
host RAM | --max-memory |
Container-level (forwarded as env vars inside the container):
| Variable | Notes |
|---|---|
OPENAI_API_KEY |
Codex API authentication |
| anything else | Custom vars defined in .env.codex are forwarded |
Precedence order: CLI flag → host shell env → .env.codex →
~/.codex-jail/env.default → built-in default.
Under --policy <path>, file precedence is intentionally narrower: CLI flag →
host shell env → policy file → built-in default. Neither .env.codex nor
env.default is read. See Broker jobs.
Copy .env.example as a starting point:
cp .env.example .env.codex
Values are passed by name, never by value
Container-level variables reach Podman as -e KEY, never as -e KEY=value.
Podman reads each value from the environment it inherits from the wrapper.
This matters because process arguments are world-readable on Linux: any user
on the host can run ps aux or read /proc/<pid>/cmdline. Passing
-e OPENAI_API_KEY=sk-… would put your key there for the lifetime of the
container. The wrapper's own environment is readable only by you and root.
Two consequences:
- A forwarded variable must be a real exported variable. If a key in
.env.codexorenv.defaultcollides with one of the wrapper's internal names (workspace,network,max_memory, …) it cannot be forwarded; the wrapper prints a warning and skips it. - When a variable is set in both your shell and
.env.codex, the container receives the shell value — matching the documented precedence.
This hides secrets from other users on the host. It does not encrypt
.env.codex at rest; keep that file out of version control.
Shared defaults: ~/.codex-jail/env.default
env.default in the jail directory holds settings shared across all
projects. Same format as .env.codex, same quoting rules, same script-level
vs. container-level split. The wrapper loads .env.codex first, then
env.default; for each key the first file to define it wins:
- Put what every project needs (preferred image, default mounts,
CODEX_JAIL_USE_SSH) in~/.codex-jail/env.default. - Keep only the per-project deltas in each workspace's
.env.codex. - Host environment variables still override both.
# ~/.codex-jail/env.default
CODEX_JAIL_USE_SSH=1
CODEX_JAIL_IMAGE=my-custom-image
CODEX_JAIL_MOUNTS=$HOME/.gitconfig:/home/codex/.gitconfig:ro
A commented template is created on first run. The file is entirely optional — without it the wrapper behaves exactly as before.
Because ~/.codex-jail/ is mounted read-write into the container, the wrapper
mounts /dev/null read-only over env.default inside the jail. Codex sees an
empty file and cannot rewrite the defaults that every future run in every
workspace depends on. The host file is untouched.
Custom MCP servers: ~/.codex-jail/mcp
The mcp directory in the jail (~/.codex-jail/mcp, created on first run)
holds custom MCP server binaries shared across all projects. It is
mounted read-only at /opt/mcp inside every container:
# one-time setup on the host
cp localrecall-mcp ~/.codex-jail/mcp/
chmod +x ~/.codex-jail/mcp/localrecall-mcp
Then register the server from inside a session:
codex mcp add localrecall \
--env LOCALRECALL_URL=http://localrecall:8080 \
-- /opt/mcp/localrecall-mcp
The -- and everything after it reach the codex CLI intact (see the
argument-parsing note above), and /opt/mcp/localrecall-mcp resolves in
every session because the mount point is fixed. Registration lands in the
shared ~/.codex-jail/config.toml, so like the rest of Codex state it
applies to every workspace.
Filling the directory from OCI images: --mcp-image <ref> (repeatable,
or comma-separated in CODEX_JAIL_MCP_IMAGES) names an image whose
entrypoint is a single static binary — the ghcr.io/mudler/mcps/* collection
fits. The image is pulled on every run so a :latest tag tracks upstream;
the entrypoint binary is extracted to ~/.codex-jail/mcp/<name> and
refreshed only when the image actually changed. A failed pull falls back to
the cached copy, and a hand-dropped binary with the same name is never
clobbered. Put CODEX_JAIL_MCP_IMAGES in ~/.codex-jail/env.default to
refresh the same servers for every session.
Things to know:
- The server must be able to reach its backend. A server that talks to
another container (like
http://localrecall:8080) needs the jail attached to that network:--network <name>orCODEX_JAIL_NETWORK=<name>inenv.default. - Binaries must run on the image. The container is Debian-based with Node.js 22; a native binary must be Linux, match the host architecture, and be statically linked or have its libraries present in the image. The mount is read-only and the exec bit comes from the host file.
- Prefer this over
CODEX_JAIL_MOUNTSfor MCP servers.CODEX_JAIL_MOUNTSis a single key, so a project's.env.codexthat defines its own mounts replaces the shared value entirely — and your MCP binary would silently vanish in that project. Themcpdirectory is mounted unconditionally and cannot be overridden away. - The read-write jail view is masked.
~/.codex-jail/is mounted read-write as/home/codex/.codex, which would let Codex rewrite a server binary that every future session executes. The wrapper therefore hides/home/codex/.codex/mcpbehind an empty tmpfs, the same way it masksenv.default; the binaries are only reachable through the read-only/opt/mcp.
Inspecting the command: --dry-run
codex . --dry-run prints the exact podman run invocation to stdout and
exits without running it. Diagnostics go to stderr, so stdout is precisely the
command.
It is meant for inspection, not replay. The -e KEY arguments resolve against
whatever shell you paste them into, and a plain shell has not loaded
.env.codex — so variables like OPENAI_API_KEY would silently be missing
from the container.
Broker jobs: --policy
.env.codex lives in the read-write workspace. Any CODEX_JAIL_* key it
defines configures the next launch, so an agent could otherwise broaden the
next container's access:
# .env.codex, written during one job and consumed by the next
CODEX_JAIL_MOUNTS=/:/host
For interactive use this is intentional: the project and host are both yours.
For a broker launching Codex against untrusted repositories, --policy moves
all file-sourced settings to one host-controlled file:
codex --policy /etc/codex-jail/jobs/1234.env --no-tty \
/srv/jobs/1234/repo exec "implement the requested change"
The policy uses the same KEY=value format as .env.codex. The flag is
CLI-only; there is deliberately no CODEX_JAIL_POLICY environment variable.
Under --policy:
- The workspace's
.env.codexis ignored silently. ~/.codex-jail/env.defaultis not read, so one policy file determines all file-sourced configuration.- Host environment values still override matching policy keys, allowing a broker to inject per-job secrets without writing them to disk.
- The workspace's
.codexignoreis unioned with the policy-selected ignore file. Workspace patterns can hide more paths but cannot unhide a policy-hidden path. - A missing or unreadable policy, or a policy path inside or traversing the workspace, is fatal before Podman starts.
The containment check is path-based. It catches symlinks and .. traversal,
but cannot identify hardlinks, bind-mounted aliases of the workspace, or a
policy stored elsewhere that the container can modify. In particular, do not
store policies under ~/.codex-jail/, which is mounted read-write into every
Codex container. Brokers should keep policy files on a host filesystem path
that is never mounted into the job container.
.codexignore
To hide files or directories from the container, drop a .codexignore in
the workspace root. Patterns are one per line, glob-style; # starts a
comment.
# Secrets
*.pem
.env
**/*.key
# Whole directories (trailing slash)
secrets/
node_modules/
One glob pattern per line: simple globs (*.secret), recursive (**/*.key),
directory-only (trailing /). A leading / is stripped (patterns are always
relative to the workspace root), a pattern may contain spaces (the whole line
is one pattern), and negation patterns (!pattern) are not supported.
For each match the wrapper stacks a tmpfs (directories) or a read-only
/dev/null (files) on top of the workspace bind mount. The host files are
untouched; Codex just sees emptiness where they would be.
A matched directory is replaced wholesale, so nothing inside it is scanned or
mounted separately — hiding node_modules/ costs exactly one mount no matter
how large it is, and the walk skips that subtree entirely. Conversely, a
pattern that matches many individual files costs one mount each; something
like **/*.js across a large dependency tree can slow Podman down badly or
exceed the kernel's argument-size limit outright. Hide the enclosing
directory instead.
Limitation: the scan does not descend into symlinked directories, so a pattern segment cannot reach through one (
*/*.keywill not matchlink-to-dir/secret.key). The file is still hidden by any pattern matching its real path, which is how patterns are normally written. A symlink that matches a pattern directly is hidden like the thing it points at.
Mounts beyond your project
Only five host paths are exposed by default:
- the workspace itself (read/write)
~/.codex-jail/→/home/codex/.codex(read/write, mapped through thekeep-iduser namespace), minusenv.default, which is masked with a read-only/dev/null, andmcp/, which is masked with an empty tmpfs~/.codex-jail/mcp→/opt/mcp(read-only, MCP server binaries)- the SSH agent socket →
/ssh-agent(only with--with-ssh-agent) - anything you add via
--mountorCODEX_JAIL_MOUNTS
Nothing else from the host is reachable.
Troubleshooting
The container has exhausted its PID limit
If commands fail with fork: Resource temporarily unavailable and process
inspection shows large numbers of git, awk, or other children, stop the
current wrapper with Ctrl-C. Processes in state Z or shown as <defunct> are
zombies and cannot be killed directly; stopping the container removes its PID
namespace while preserving the mounted workspace and shared Codex state.
The wrapper starts Podman's init process to forward signals and reap orphaned descendants. This prevents abandoned grandchildren from accumulating under container PID 1, but it cannot reap a zombie while that zombie is still owned by a running parent that has not waited for it. If the problem recurs, identify and stop or fix that parent process.
Build manually
make build # podman build -t codex-cli:latest .
podman build -t my-codex . # equivalent direct invocation
The CACHEBUST build arg forces npm to re-resolve @openai/codex, so a
plain make build always picks up the latest published Codex CLI.
Tests
make test # or: bash tests/run.sh
No dependencies beyond bash. A stub podman earlier on PATH records the
argv and environment the real one would have received, then exits without
starting a container — which is what lets the suite assert that a secret is
present in Podman's environment and absent from its argv.
License
BSD 3-Clause — see COPYRIGHT.