Run OpenAI Codex CLI/ Claude Code safely inside a rootless Podman container, with explicit workspace access and host isolation.
  • Shell 99.6%
  • Dockerfile 0.3%
  • Makefile 0.1%
Find a file
2026-09-07 12:29:22 +02:00
.claude
.codex/agents
bin docs: name core-owned mcp_dir and the find wrapper inputs 2026-09-07 12:29:22 +02:00
clients refactor: let adapters reuse core diagnostics and directory checks 2026-09-07 12:04:44 +02:00
containers
docs docs: name core-owned mcp_dir and the find wrapper inputs 2026-09-07 12:29:22 +02:00
examples
scripts
tests fix: canonicalize paths directly under the root directory 2026-09-07 12:29:21 +02:00
.gitignore
AGENTS.md
CLAUDE.md
COPYRIGHT
install.sh refactor: table-drive the Podman package-manager fallback 2026-09-07 12:05:00 +02:00
Makefile
README.md perf: read export attributes without a subshell 2026-09-06 12:39:19 +02:00

azkaban

azkaban runs Codex CLI or Claude Code inside separate rootless Podman containers while sharing one audited host launcher. The public commands remain codex and claude; each keeps its own image, configuration, authentication, and persistent state.

Security model

The native clients run with their own approval checks disabled because the rootless container is the intended containment boundary. Those bypass options are not safe to use on the host. A rootless container reduces host access, but it is not a virtual machine and is not a defense against every kernel or container-runtime vulnerability.

By default the client can access its workspace, its client-specific persistent state, and the resources you explicitly expose. Extra mounts, SSH-agent forwarding, networks, and policy files expand or alter that boundary; review them as security-sensitive configuration. azkaban never mounts the Podman socket.

The launcher parses environment files as data: it does not source them or use eval. File-provided container variables are passed to Podman by name, so their values do not appear in the generated process argument list. Trusted adapters are loaded only from the installed runtime, never from a workspace, PATH, or configuration file.

Prerequisites

  • Bash 4.4 or newer
  • Rootless Podman 4.3 or newer
  • GNU or BSD userland tools used by the launcher
  • Network access while building the selected client image

The installer can offer a package-manager command when Podman is missing. It builds locally and does not publish images to a registry.

Install

Choose one client, both clients, or the all selector:

./install.sh codex
./install.sh claude
./install.sh codex claude
./install.sh all

Use an unqualified image name when exactly one client is selected:

./install.sh --image private/codex codex

For multiple clients, qualify each custom image and repeat --image:

./install.sh \
  --image codex=private/codex \
  --image claude=private/claude \
  codex claude

An image without a tag is built as :latest and, when native version detection succeeds, receives an additional version tag. An explicit tag is used as-is. --no-cache applies to every selected build, and --tz ZONE sets the image timezone.

Installing one client later does not remove or replace the other client's public link, image, configuration, or state. The shared runtime release is updated and includes both trusted adapters, while only selected public links are published.

Add the user-local binary directory to PATH if it is not already present:

export PATH="$HOME/.local/bin:$PATH"

Put that line in your shell startup file to make it persistent.

Commands and dispatch

Use the preserved commands directly:

codex .
claude .

The first positional argument is the workspace and must precede native-client arguments. Use -- to stop wrapper parsing explicitly:

codex ./project -- --native-option
claude ./project -- --native-option

The installer publishes a stable internal dispatcher at ~/.local/libexec/azkaban/azkaban. The ~/.local/bin/codex and ~/.local/bin/claude links both target it. It selects the trusted adapter from the invoked basename after checking that name against clients/registry.sh, the runtime's closed client list, so only codex and claude belong on PATH; direct azkaban invocation is intentionally unsupported.

Adapters under the installed runtime's clients/ directory are trusted installation code. They are not workspace plugins and cannot be supplied by a project. See Adding a client for the extension contract.

Common wrapper options

These options are accepted by both commands:

Option Purpose
-h, --help Show command help.
--with-ssh-agent Forward the available host SSH agent.
--network NAME Attach to an existing Podman network.
--mount SRC:DST[:OPTIONS] Add a bind mount; repeatable.
--mcp-image REF Extract an MCP image entrypoint; repeatable.
--ignore-file PATH Select an ignore file.
--max-memory VALUE Set the container memory limit.
--image NAME Override the client image.
--container-workdir PATH Set the workspace path inside the container.
--policy PATH Use an external policy environment file.
--cidfile PATH Write the container ID to a host file outside the workspace.
--no-tty Disable TTY allocation.
--dry-run Print one shell-escaped Podman command without launching.
-- Forward all remaining arguments to the native client.

Claude additionally accepts --session ID and --all-sessions.

Environment configuration

Settings shared by every client go in one host file. Per-workspace settings go in a workspace file:

mkdir -p ~/.azkaban && cp examples/env.azkaban.example ~/.azkaban/env
cp examples/env.codex.example .env.codex
cp examples/env.claude.example .env.claude

Each client retains its original launcher namespace. The complete controls are *_JAIL_IMAGE, *_JAIL_WORKSPACE, *_JAIL_NETWORK, *_JAIL_USE_SSH, *_JAIL_MOUNTS, *_JAIL_MCP_IMAGES, *_JAIL_IGNORE, and *_JAIL_NO_TTY, where * is CODEX or CLAUDE. Claude also retains CLAUDE_JAIL_SESSION. CONTAINER_MAX_MEMORY remains common.

Seven of those controls also have a client-neutral spelling so one line can serve every client: AZKABAN_WORKSPACE, AZKABAN_NETWORK, AZKABAN_USE_SSH, AZKABAN_MOUNTS, AZKABAN_MCP_IMAGES, AZKABAN_IGNORE, and AZKABAN_NO_TTY. Both spellings are accepted in every layer. The nearer layer wins, and inside one layer the client-prefixed spelling wins. The image and Claude's session have no neutral spelling. The whole AZKABAN_ prefix is reserved for launcher controls and never reaches a container.

Keys belonging to another registered client are consumed and dropped in every file layer: its *_JAIL_* controls and its API-key variable. A shared file may therefore hold both OPENAI_API_KEY and ANTHROPIC_API_KEY, and each client receives only its own.

Resolution order, highest priority first, is:

wrapper CLI > exported host environment > workspace environment file
> client env.default > shared ~/.azkaban/env > built-in default

The workspace files are .env.codex and .env.claude. Per-client defaults live at ~/.codex-jail/env.default and ~/.claude-jail/env.default; Claude's file is optional and user-created. Settings common to both clients belong in the shared ~/.azkaban/env, which the launcher reads but never creates. The first valid definition in a file wins. Values are literal except that one matching outer pair of single or double quotes is removed. This is not shell syntax.

Launcher controls are consumed by the wrapper and not forwarded. Other safe keys are ordinary container variables. Exported host values take precedence, including an explicitly exported empty value. API keys may be exported as OPENAI_API_KEY or ANTHROPIC_API_KEY; do not commit credentials to a workspace file.

Policy mode

--policy PATH selects one external environment file and skips the workspace environment file, client env.default, and the shared ~/.azkaban/env:

codex ./project --policy /etc/azkaban/team.policy
claude ./project --policy /etc/azkaban/team.policy

The policy must be a readable regular file outside the workspace. The launcher checks every path component and fails closed on symlink or traversal attempts. The policy-selected ignore file is combined with the ordinary workspace ignore file: workspace rules may hide more paths but cannot unhide policy-hidden content.

Ignore rules

By default, Codex reads <workspace>/.codexignore and Claude reads <workspace>/.claudeignore. Missing default files are harmless. Select a different readable file with --ignore-file or the active client's *_JAIL_IGNORE variable; a missing explicit file is an error.

Rules use the preserved glob-style syntax, including rooted paths, **, directory-only patterns, character classes, comments, and blank lines. Matches are hidden by overlays placed after the workspace mount. Symlinks are hidden, not followed outside the workspace.

MCP binaries and images

Place manually managed executable MCP binaries in ~/.codex-jail/mcp/ or ~/.claude-jail/mcp/. The active directory is exposed read-only at /opt/mcp.

--mcp-image REF or the comma-separated *_JAIL_MCP_IMAGES control pulls an OCI image, inspects its entrypoint (falling back to Cmd), and copies that single binary from an unstarted container. The MCP image itself is never run or started. Cached binaries are keyed by image ID; manually managed files are not overwritten.

Extra mounts, networks, and SSH

Extra mounts deliberately expand client access:

codex . --mount "$HOME/.gitconfig:/home/codex/.gitconfig:ro"
claude . --mount "$HOME/.gitconfig:/home/claude/.gitconfig:ro"

A destination of ~ or ~/... expands to the active client's container home, so one shared line covers both clients:

# in ~/.azkaban/env
AZKABAN_MOUNTS=~/.gitconfig:~/.gitconfig:ro

--mount is repeatable and takes precedence over both comma-separated *_JAIL_MOUNTS and AZKABAN_MOUNTS, following the same neutral-versus-prefixed layer rule as other controls: the nearer layer wins, and inside one layer *_JAIL_MOUNTS wins over AZKABAN_MOUNTS. A source must exist, and only a leading ~, ~/, or a literal $HOME is expanded on the source side; $HOME is not expanded on the destination side because it would be ambiguous. A destination such as ~user/path is not expanded either and is rejected as a non-absolute destination. Use :ro wherever write access is unnecessary.

Use --network NAME only when the client must reach services on that Podman network. --with-ssh-agent forwards SSH_AUTH_SOCK when available and warns without failing when it is absent. Neither option is enabled by default.

Dry run

Inspect the exact launch without starting Podman or creating persistent state:

codex . --dry-run
claude . --dry-run

Standard output contains one shell-escaped command. Diagnostics go to standard error. A first-time Claude session dry-run warns that its printed bind sources do not exist yet; run once without --dry-run to initialize them safely before reusing the printed command.

Headless contract

Supervisors driving non-interactive runs (--no-tty, piped stdin) can rely on these launcher guarantees:

  • Exit-code propagation. After validation the launcher replaces itself with podman run via exec, so the process a supervisor waits on exits with the native client's own status. Launcher-side failures are reported before that boundary with a nonzero status and no container.
  • Signal forwarding and cleanup. Every launch uses --rm --init: the in-container init forwards termination signals to the native client, and the container is removed when it exits. Sending podman run a catchable signal terminates the workload.
  • Stderr-only diagnostics. Launcher diagnostics — workspace, policy, cidfile, ignore, and MCP lines, plus warnings — go to standard error. Standard output belongs to the native client or, under --dry-run, carries exactly one shell-escaped Podman command.
  • SIGKILL and conmon orphaning. podman run is a client attached to a container supervised by conmon. If a supervisor SIGKILLs the launcher process, the container keeps running detached. --cidfile PATH passes through to podman run so the container ID lands in PATH, letting the supervisor recover with podman stop or podman rm -f on that ID.

--cidfile requires an existing parent directory and refuses any path inside the workspace for the same reason as --policy: the workspace is container-writable, and a container-rewritable cidfile could redirect a supervisor's podman stop to an arbitrary container. Podman itself fails to start when the cidfile already exists, so remove the previous file between runs.

Separate image builds

The images share a base stage but remain independent final targets:

make build-codex
make build-claude
make build

Defaults are codex-cli:latest and claude-code:latest. Override them with CODEX_IMAGE, CODEX_TAG, CLAUDE_IMAGE, and CLAUDE_TAG when invoking make. Building one target installs only that native client.

The image entrypoints disable native approval checks only inside their rootless containers. Do not reuse those entrypoint flags for host execution.

Codex authentication and state

Codex reuses ~/.codex-jail across all workspaces and mounts it at /home/codex/.codex. Authentication, configuration, history, and logs remain where existing codex-jail installations stored them. On the first real run, the launcher creates the root, env.default, and mcp/ if absent; it never overwrites existing contents.

Authenticate interactively through the native client or export OPENAI_API_KEY. The writable state mount is followed by protective masks for env.default and the MCP storage subtree so the client cannot modify inputs that control a later launch.

Claude authentication and sessions

Claude stores workspace sessions under ~/.claude-jail/sessions. Without an explicit selection, the launcher derives a stable eight-character ID from the canonical workspace path and reuses it on later runs. Select an existing session or list sessions with:

claude . --session SESSION_ID
claude --all-sessions

CLAUDE_JAIL_SESSION supplies the same selection at lower precedence than the CLI. Explicit session IDs must already exist. A first real run creates the session's config/settings.json and .claude.json transactionally; listing is read-only. Authentication may be interactive within that state or use an exported ANTHROPIC_API_KEY.

Troubleshooting

  • command not found: add $HOME/.local/bin to PATH and start a new shell.
  • Podman version failure: install or upgrade rootless Podman to 4.3 or newer.
  • No TTY or noisy terminal control: pass --no-tty or set the active *_JAIL_NO_TTY=1 control.
  • Memory detection failure: pass --max-memory 2g or export CONTAINER_MAX_MEMORY.
  • Missing mount or ignore source: use an existing host path; explicit sources fail closed.
  • Stale MCP lock: confirm no refresh is active before manually removing the exact lock named by the diagnostic.
  • Installer lock or recovery warning: preserve the reported path and inspect it before cleanup; the installer intentionally avoids deleting state whose ownership changed.

Use codex --help, claude --help, or ./install.sh --help for the current command surface.

Migration

There is no state conversion. Installing azkaban replaces only selected legacy public wrappers with links to the shared runtime, while both original state trees and configuration names remain in place. Review the intentional security tightenings and rollback guidance in the migration guide.

Development and verification

Run the dependency-free fast suite and syntax checks with:

make test
make lint

make lint always runs Bash syntax checks and runs ShellCheck when installed. The networked, real-Podman release gate is separate:

make smoke-images

It builds unique temporary tags for both targets, verifies UID/GID, username, home, executable, entrypoint, and native --version, then removes only those tags. See the compatibility fixture inventory for the source-to-current test map.

Historical provenance

This repository consolidates independently rooted projects at pinned commits:

  • codex-jail, 48fa586bb7c1a841adc366bcf092ed7c040ec017
  • claude-jail, 24dab3ad94975f754f851dfc7124b6fae8a5b183

Their histories are linked as provenance rather than grafted together. License and attribution details are in COPYRIGHT.