Replaced by: https://git.unitoo.it/claudiomaradonna/azkaban Run OpenAI Codex CLI safely inside a rootless Podman container, with explicit workspace access and host isolation.
This repository has been archived on 2026-09-03. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
  • Shell 98.3%
  • Dockerfile 1.4%
  • Makefile 0.3%
Find a file
2026-08-31 16:59:43 +02:00
.ai chore(policy): adopt latest Agent Foundry policy 2026-08-31 16:58:05 +02:00
.claude init from claude-jail :) 2026-05-11 12:59:23 +02:00
docs/superpowers docs(plan): adopt Agent Foundry policy only 2026-08-31 16:53:29 +02:00
tests fix: reap orphaned container processes 2026-08-31 16:59:43 +02:00
.dir-locals.el init from claude-jail :) 2026-05-11 12:59:23 +02:00
.dockerignore fix: avoid recursive Podman state chowns 2026-08-27 19:40:08 +02:00
.env.example feat(codex.sh): align policy and headless behavior with claude-jail 2026-08-27 01:38:24 +02:00
.gitignore fix: avoid recursive Podman state chowns 2026-08-27 19:40:08 +02:00
AGENTS.md chore(policy): adopt latest Agent Foundry policy 2026-08-31 16:58:05 +02:00
codex.sh fix: reap orphaned container processes 2026-08-31 16:59:43 +02:00
COPYRIGHT chore(policy): adopt latest Agent Foundry policy 2026-08-31 16:58:05 +02:00
Dockerfile chore: catch up image, env template and gitignore with claude-jail 2026-08-25 15:02:19 +02:00
install.sh fix: avoid recursive Podman state chowns 2026-08-27 19:40:08 +02:00
Makefile feat: name-only env forwarding, shared env.default, --dry-run, test suite 2026-07-28 14:30:29 +02:00
README.md fix: reap orphaned container processes 2026-08-31 16:59:43 +02:00

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:

  1. Installs Podman if it is not present (apt/dnf/pacman/brew).
  2. Builds the codex-cli:latest image and dual-tags it with the detected Codex CLI version (e.g. codex-cli:0.45.0).
  3. Drops the codex wrapper 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_KEY in your shell or set it in .env.codex. The wrapper forwards it into the container.
  • ChatGPT sign-in — run codex login inside the container; the resulting ~/.codex/auth.json is 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.codex or env.default collides 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> or CODEX_JAIL_NETWORK=<name> in env.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_MOUNTS for MCP servers. CODEX_JAIL_MOUNTS is a single key, so a project's .env.codex that defines its own mounts replaces the shared value entirely — and your MCP binary would silently vanish in that project. The mcp directory 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/mcp behind an empty tmpfs, the same way it masks env.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.codex is ignored silently.
  • ~/.codex-jail/env.default is 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 .codexignore is 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 (*/*.key will not match link-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 the keep-id user namespace), minus env.default, which is masked with a read-only /dev/null, and mcp/, 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 --mount or CODEX_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.