Run OpenAI Codex CLI/ Claude Code safely inside a rootless Podman container, with explicit workspace access and host isolation.
  • Shell 99.5%
  • Dockerfile 0.3%
  • Makefile 0.2%
Find a file
2026-09-21 11:46:40 +02:00
.claude
.codex/agents
bin feat: allow clients without an automatic API key 2026-09-18 12:53:08 +02:00
clients feat: add the OpenCode client and isolated state 2026-09-18 13:09:39 +02:00
containers fix: give OpenCode ownership of its cache parent 2026-09-21 11:46:40 +02:00
docs fix: give OpenCode ownership of its cache parent 2026-09-21 11:46:40 +02:00
examples docs: document and verify OpenCode support 2026-09-21 10:16:06 +02:00
lib
scripts
tests fix: give OpenCode ownership of its cache parent 2026-09-21 11:46:40 +02:00
.gitignore
AGENTS.md docs: document and verify OpenCode support 2026-09-21 10:16:06 +02:00
CLAUDE.md
COPYRIGHT
install.sh
Makefile feat: add the OpenCode container target 2026-09-18 13:17:41 +02:00
README.md docs: document and verify OpenCode support 2026-09-21 10:16:06 +02:00

azkaban

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

Security model

Codex and Claude run with their native approval checks disabled because the rootless container is the intended containment boundary. Those bypass options are not safe to use on the host. OpenCode keeps its native permission rules by default; pass run --auto explicitly for unattended work. OpenCode's --auto approves requests while preserving explicit deny rules. 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 (macOS ships 3.2; see macOS)
  • Rootless Podman 4.3 or newer (a rootless podman machine on macOS)
  • 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 or more clients, or use the all selector:

./install.sh codex
./install.sh claude
./install.sh opencode
./install.sh codex claude opencode
./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 \
  --image opencode=private/opencode \
  codex claude opencode

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 another client's public link, image, configuration, or state. The shared runtime release is updated and includes all three 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.

macOS

macOS support is implemented and tested with Darwin stubbed on Linux; the design spec records the verification round on a real Mac.

The launcher runs on the latest macOS with Homebrew's Bash and Podman:

brew install bash podman
podman machine init
podman machine start

Put Homebrew's bin ahead of /bin on PATH; the scripts refuse the system Bash 3.2 with a message. The installer checks that a rootless machine is running before it builds and prints the exact podman machine command to run when one is missing, stopped, or rootful. It never creates a machine.

Containers run inside the machine, so a few things differ from Linux:

  • Only host paths under the machine's shared volumes (by default /Users, /private, and /var/folders) can be mounted. The launcher resolves the workspace to its physical path, so /tmp/project is mounted as /private/tmp/project, and refuses any mount source outside the shared volumes rather than letting Podman mount an empty directory. Add volumes with podman machine init --volume /path:/path.
  • --with-ssh-agent is unsupported: the agent socket cannot cross the machine boundary. Mount a read-only key or use HTTPS remotes.
  • The default memory limit is the machine's memory, not the Mac's. Resize the machine with podman machine set --memory.
  • Claude session IDs hash the physical workspace path on macOS.
  • OpenCode native sessions record container paths. The default is /workspace; use a stable, distinct --container-workdir when separate host projects need separate directory identities.
  • The launcher queries the machine on every launch, including --dry-run, so a dry-run needs a running machine.

Commands and dispatch

Use the preserved commands directly:

codex .
claude .
opencode ./project
opencode ./project -- auth login
opencode ./project -- auth list
opencode ./project -- run --auto "Explain the test suite"
opencode ./project -- session list
opencode ./project -- --session SESSION_ID

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
opencode ./project -- --native-option

The installer publishes a stable internal dispatcher at ~/.local/libexec/azkaban/azkaban. The ~/.local/bin/codex, ~/.local/bin/claude, and ~/.local/bin/opencode links 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 those three commands belong on PATH; direct azkaban invocation is intentionally unsupported.

Adapters under the installed runtime's clients/ directory are trusted installation code. The shared host library under the runtime's lib/ directory is trusted the same way: it holds the portable host-tool shims and Podman probes, is staged with every release, and is never loaded from a workspace. Adapters 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 all three 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
cp examples/env.opencode.example .env.opencode

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, CLAUDE, or OPENCODE. 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 registered API-key variable. A shared file may therefore hold both OPENAI_API_KEY and ANTHROPIC_API_KEY; Codex and Claude receive only their own, and OpenCode receives neither automatically. This is not a universal credential filter: ordinary provider variables intentionally configured under other names still follow the normal forwarding rules, and the client can read credential files inside its mounted workspace.

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, .env.claude, and .env.opencode. Per-client defaults live at ~/.codex-jail/env.default, ~/.claude-jail/env.default, and ~/.opencode-jail/env.default; the latter two are optional and user-created. Settings common to all 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
opencode ./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, Claude reads <workspace>/.claudeignore, and OpenCode reads <workspace>/.opencodeignore. 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/, ~/.claude-jail/mcp/, or ~/.opencode-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"
opencode . --mount "$HOME/.gitconfig:/home/opencode/.gitconfig:ro"

A destination of ~ or ~/... expands to the active client's container home, so one shared line covers all 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
opencode . --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-opencode
make build

Defaults are codex-cli:latest, claude-code:latest, and opencode-cli:latest. Override them with the matching CODEX_*, CLAUDE_*, or OPENCODE_* image and tag variables when invoking make. Building one target installs only that native client. The aggregate make build contains the release-accepted targets; run make build-opencode independently until its real-image release gate has passed.

The Codex and Claude image entrypoints disable native approval checks only inside their rootless containers. Do not reuse those entrypoint flags for host execution. OpenCode's entrypoint is the native opencode command and retains native permissions.

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.

OpenCode authentication and state

OpenCode keeps separate host directories below ~/.opencode-jail: config, data, cache, and state map to the native XDG locations below /home/opencode. Provider credentials are stored by the native client in ~/.opencode-jail/data/auth.json. Authenticate, inspect configured providers, and list native sessions with:

opencode . -- auth login
opencode . -- auth list
opencode . -- session list

OpenCode state and credentials are shared across every workspace that uses this launcher. Treat all such projects as mutually trusted: native configuration, plugins, caches, and credentials can affect later runs in another project. The launcher does not copy credentials from a host OpenCode installation.

Native session records see container paths. All default workspaces appear at /workspace; choose a stable, distinct --container-workdir for each host project when directory identity matters. This changes path identity only: state and credentials remain shared, and native project grouping stays under OpenCode's control.

For unattended work, opt in to native automatic permissions explicitly:

opencode ./project -- run --auto "Explain the test suite"

--auto preserves explicit deny rules. Browser-based web, desktop, and remote-server integration, including OAuth flows that require a callback into the container, are outside this release's networking integration.

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.
  • Bash 4.4 or newer is required: install Bash with Homebrew and put it ahead of /bin/bash on PATH.
  • no Podman machine exists, the Podman machine is not running, or the Podman machine is rootful: run the podman machine command the message names, then rerun.
  • Podman engine is unavailable; start the machine with: podman machine start: start the machine, then rerun. Podman's own message follows on the next line.
  • cannot read the Podman machine's shared volumes: the machine must be running and reachable; podman machine inspect must list its mounts.
  • host path ... is not shared with the Podman machine: move the workspace under a shared volume or recreate the machine with --volume.
  • --with-ssh-agent is unsupported on macOS: mount a read-only key or use HTTPS remotes.

Use codex --help, claude --help, opencode --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 requires a real rootless engine, builds unique temporary tags for all three targets, and verifies UID/GID, username, home, executable, entrypoint, and native --version. OpenCode checks additionally exercise native help, authentication and session listing, run help, writable native state paths, and state reuse across containers without provider credentials. The gate removes only its own tags and temporary state. 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.