- Shell 99.5%
- Dockerfile 0.3%
- Makefile 0.2%
| .claude | ||
| .codex/agents | ||
| bin | ||
| clients | ||
| containers | ||
| docs | ||
| examples | ||
| lib | ||
| scripts | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| COPYRIGHT | ||
| install.sh | ||
| Makefile | ||
| README.md | ||
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 machineon 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/projectis mounted as/private/tmp/project, and refuses any mount source outside the shared volumes rather than letting Podman mount an empty directory. Add volumes withpodman machine init --volume /path:/path. --with-ssh-agentis 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-workdirwhen 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 runviaexec, 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. Sendingpodman runa 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 runis a client attached to a container supervised byconmon. If a supervisor SIGKILLs the launcher process, the container keeps running detached.--cidfile PATHpasses through topodman runso the container ID lands inPATH, letting the supervisor recover withpodman stoporpodman rm -fon 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/bintoPATHand 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-ttyor set the active*_JAIL_NO_TTY=1control. - Memory detection failure: pass
--max-memory 2gor exportCONTAINER_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/bashonPATH.no Podman machine exists,the Podman machine is not running, orthe Podman machine is rootful: run thepodman machinecommand 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 inspectmust 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.