- Shell 98.3%
- Dockerfile 0.9%
- JavaScript 0.5%
- Makefile 0.3%
The README explains how one identity works with several client logins through --account or AZKABAN_ACCOUNT, where an account's state lives, and that an account is shared, hooks and settings included, by every identity that names it. The adapter guide, the headless contract, and the identity env example follow. |
||
|---|---|---|
| .agents | ||
| .claude | ||
| .codex | ||
| .opencode | ||
| bin | ||
| clients | ||
| containers | ||
| docs | ||
| examples | ||
| lib | ||
| tests | ||
| .gitignore | ||
| .prettierrc | ||
| .shellcheckrc | ||
| AGENTS.md | ||
| COPYRIGHT | ||
| install.sh | ||
| Makefile | ||
| README.md | ||
azkaban
azkaban runs Codex CLI, Claude Code, or OpenCode inside separate
containers, rootless Podman on Linux and Apple container on macOS,
while sharing one audited host launcher. The public commands are
codex, claude, and opencode; each keeps its own image,
configuration, authentication, and persistent state.
Every client image builds on Ubuntu 26.04 LTS with git, Node.js,
ShellCheck, Prettier, and common command-line tools, plus the forge CLIs
gh and fj that bot accounts use,
and bubblewrap and socat for nested sandboxes;
containers/Dockerfile lists the rest.
Security model
Codex and Claude run with their native approval checks disabled because
the 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. On Linux a rootless container reduces host access, but it is
not a virtual machine. On macOS each container runs in its own
lightweight virtual machine without a user namespace, so root in the
container is root in that machine. Neither is 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. It sees the
whole workspace, so keep any file it must not read outside that folder.
Everything the launcher keeps on the host lives below ~/.azkaban, and
the client's state directory, ~/.azkaban/state/<identity>/<client> for
the identity the launch runs as, or
~/.azkaban/accounts/<account>/<client> for an account it
names, is the only part of it the client can write. Extra mounts, an
identity's SSH key or
host agent, and networks expand
or alter that boundary; review them as security-sensitive configuration.
A workspace Dockerfile.<client> or Dockerfile.azkaban changes the
image only after you approve its content (see
Custom images). azkaban never mounts the engine's
socket. When agents need to sign and push, give them a bot account of
their own, as described in
Run agents as a bot account.
The launcher parses environment files as data: it does not source them
or use eval. File-provided container variables reach the container
through a private env file that the engine reads, so their values never
enter the environment of a program the launcher runs on the host, nor
any process argument list. The workspace .env.<client>, which the
client can write, holds container variables only (see
Environment configuration). Trusted
adapters are loaded only from the installed runtime, never from a
workspace, PATH, or configuration file.
Nested sandboxes
Inside a client container on Linux, a tool can isolate folders with
bwrap, which runs unprivileged through user namespaces: it can bind
folders read-only or writable, hide others, and unshare the network and
process namespaces. The container refuses a fresh /proc mount, so a
sandbox binds the container's /proc instead, and commands in it still
see the container's processes. When you turn on Claude Code's own
sandbox, which also uses socat, also set
sandbox.enableWeakerNestedSandbox to true in its
settings. Codex's sandbox stays off, since its image starts
it with --dangerously-bypass-approvals-and-sandbox. Ubuntu 26.04's
bwrap carries the unfixed CVE-2026-87766, so a nested sandbox keeps a
cooperative command to its folders but cannot hold one crafted to escape
it; the container stays the boundary toward the host.
Prerequisites
- Bash 4.4 or newer (macOS ships 3.2; see macOS setup)
- Rootless Podman 4.3 or newer on Linux; on macOS, an Apple silicon Mac
with macOS 26 and Apple
container1.5.0 or newer - GNU or BSD userland tools used by the launcher
- Network access while building the selected client image
When the engine is missing, the installer installs it: Podman with the
first package manager it finds (apt-get, dnf, or pacman through sudo, or
Homebrew), Apple container with Homebrew. It builds locally and does
not publish images to a registry.
macOS setup
macOS support has not yet been verified on a real Mac. The macOS section describes how launches differ there.
The launcher runs with Homebrew's Bash and Apple container:
brew install bash container
container system start --enable-kernel-install
The first container system start downloads the Linux kernel that
containers boot; --enable-kernel-install accepts it without a prompt.
Put Homebrew's bin ahead of /bin on PATH; the scripts refuse the
system Bash 3.2 with a message. The installer checks the version and
that the service is running before it builds, and prints the start
command when it is down. It never starts the service.
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
Each selected client is built as its default image (codex-cli:latest,
claude-code:latest, or opencode-cli:latest), which receives an
additional version tag when native version detection succeeds.
--no-cache applies to every selected build, and --tz ZONE sets the
image timezone (Etc/UTC by default):
./install.sh --no-cache --tz Europe/Rome all
Images under other names come from the make targets in
Separate image builds.
Installing one client later does not remove or replace another client's
public link, image, configuration, or state. The installer builds every
selected image before it changes the installation, so a failed build
leaves the current one in place. The folders it writes under ~/.local
must be real directories: a symlink among them stops the installation
before any build. An interrupted installation is completed by running
the installer again.
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
Run the commands directly:
codex .
claude .
opencode ./project
opencode ./project -- run --auto "Explain the test suite"
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
Common wrapper options
Every command accepts these options:
| Option | Purpose |
|---|---|
-h, --help |
Show command help. |
--network NAME |
Attach to an existing network. |
--mount SRC:DST[:OPTIONS] |
Add a bind mount; repeatable. |
--max-memory VALUE |
Set the container memory limit; without one, Podman sets none; on macOS see macOS. |
--image NAME |
Override the client image. |
--skip-custom-images |
Run the image without building a workspace Dockerfile (Custom images). |
--cidfile PATH |
Write the container ID to a host file outside the workspace. |
--identity NAME |
Run as an identity; default when omitted. |
--account NAME |
Keep the client's state in an account instead of the identity's. |
--no-tty |
Disable TTY allocation; without it, a TTY is allocated when standard input and output are terminals. |
--dry-run |
Print one shell-escaped launch command without launching. |
-- |
Forward all remaining arguments to the native client. |
Scripts and supervisors that launch clients headless can rely on the guarantees of the Headless contract.
Authentication and state
Each client keeps its authentication, settings, and history in the state
folder of the identity a launch runs as,
~/.azkaban/state/<identity>/<client>, such as
~/.azkaban/state/default/claude, or of the account it
names. The launcher creates what a client needs there on its first real
run and never overwrites what exists.
That state is shared across every workspace that uses it, so treat those
projects as mutually trusted: a hook, setting, plugin, cache, or
credential one session writes there affects later runs in another
project. Give projects that must stay apart identities of their own,
with no account in common. Every workspace appears at /workspace, so
Claude and OpenCode file the history of all your projects under that one
path.
Codex
The state folder mounts at /home/codex/.codex, where the native client
keeps authentication, configuration, history, and logs. Authenticate
interactively through the native client or put OPENAI_API_KEY in the
identity's env.
Claude
The state folder's config directory mounts at /home/claude/.claude
and its .claude.json at /home/claude/.claude.json. On the first real
run, the launcher creates config/settings.json, trusting /workspace,
and an empty .claude.json, each only when absent. Authenticate
interactively through the native client or put ANTHROPIC_API_KEY in
the identity's env.
OpenCode
The state folder holds config, data, cache, and state, mapped to
the native XDG locations below /home/opencode; the native client
stores provider credentials in data/auth.json. Authenticate, inspect
configured providers, and list native sessions with:
opencode . -- auth login
opencode . -- auth list
opencode . -- session list
The launcher does not copy credentials from a host OpenCode installation, and native project grouping stays under OpenCode's control. Browser-based web, desktop, and remote-server integration, including OAuth flows that require a callback into the container, are out of scope.
Accounts
An account is a client state folder of its own,
~/.azkaban/accounts/<name>/<client>, that a launch uses instead of its
identity's, so one identity can work with several logins of a client,
such as a personal and a company Claude subscription. Name it for one
launch with --account NAME, or for an identity with AZKABAN_ACCOUNT
in its env.<client> or env, as
Environment configuration describes; an
empty AZKABAN_ACCOUNT= keeps the identity's own state. The first real
launch with a new name creates its folder, and you log in there once:
claude . --account personal
claude . --account work
An account holds what the client stores, such as its login; an API key
in the identity's env reaches the launches of every account, so a key
that serves one account only goes in an identity of its own.
Every identity that names an account shares its whole state, settings,
hooks, plugins, and history included, so a session of one identity can
change what the next sessions of another run. Give identities that must
stay apart different accounts, such as work and work-2. A launch
with an account names it on standard error, as in
claude: account: work: state /home/you/.azkaban/accounts/work/claude.
Identities
An identity is what a launch runs as; its folder below
~/.azkaban/identities/ holds its credentials, settings, and goals. The
identity's credentials set what it can do, because the forge enforces
them; its goals tell its sessions what to work toward.
A launch selects an identity with --identity NAME; a shell alias keeps
a frequent one short. With no name, the launch runs as default, which
needs no folder. No env file selects an identity, because the identity
decides which files load. Any other name without a folder fails. The
identity line a launch prints on standard error names the identity
first, for example codex: identity: triage: home …, goals …, so a
supervisor's logs show which bot ran.
Identities, their client state, and the trees they share lie below
~/.azkaban. Each piece of an identity's folder is optional and used
when it exists:
~/.azkaban/
├── identities/NAME/ what you write for one identity
│ ├── env settings and container variables for every client
│ ├── env.<client> settings and container variables for one client
│ ├── key the bot's SSH private key
│ ├── home/ files for the container's home
│ └── goals.md the bot's goals (below)
├── state/NAME/<client>/ what that client's sessions write
├── accounts/NAME/<client>/ what that client's sessions write in an account
├── mcp/ MCP binaries
└── approved/ custom image approvals
Run agents as a bot account sets up the key
and the SSH, git, and forge CLI files in home/.
A present piece must be the file or folder itself, not a symlink, and lie outside the workspace, or the launch stops before the engine starts; an extra mount onto the goals stops it as well.
Each file below home/ mounts read-only at the same path under the
container's home, so any tool's configuration goes in by copying it
there: home/.config/gh/hosts.yml appears at ~/.config/gh/hosts.yml.
Every entry below home/ must be a readable plain folder or file whose
path holds no :, and no file may land on or inside another mount, such
as the client's state or an extra mount, or the launch stops before the
engine starts. A launch mounts the files present when it starts, so a
file added, removed, or replaced while it runs counts from the next
launch. Podman creates a folder the image lacks, such as ~/.config/gh,
owned by root, so a tool cannot add files beside its mounted ones there.
An identity reads its configuration from its own folder only, so no key
or token in that folder crosses from one bot to another, while an
account shares its login with every identity that names it;
copy the settings every identity needs into each identity's files, such
as a network into its env or a client's image into its env.<client>.
Those files load above the workspace .env.<client>, so a project
cannot replace the identity's credentials, and an empty value such as
GH_TOKEN= keeps a project's value out.
Each identity has a state folder of its own, so a hook or setting one
bot's session writes stays out of another bot's sessions, and removing
~/.azkaban/state/NAME/ resets the bot; an account is the
exception, shared by every identity that names it. Log each identity
into each client it uses once, for example claude . --identity coder,
or put the client's API key in its env. The MCP directory and custom
image approvals stay shared, since both record your decisions and
containers only read them.
goals.md mounts read-only as the client's user-level instructions,
~/.claude/CLAUDE.md, ~/.codex/AGENTS.override.md, or
~/.config/opencode/AGENTS.md, which every session reads alongside the
project's own AGENTS.md, interactive and headless alike (on macOS
goals are not served; see macOS). Codex reads up to 32 KiB of
the goals and skips an empty file, so keep goals.md non-empty. A
session can add instructions of its own in its state, but cannot remove
the goals.
The workspace is container-writable for every identity, so point identities of different trust at different checkouts: a git hook or project setting that one bot plants would otherwise run in the other bot's session.
Environment configuration
Every launch runs as an identity, default unless you
name another, and reads its settings from that identity's folder: env
for every client and env.<client> for one. A workspace may add
container variables of its own in .env.codex, .env.claude, or
.env.opencode:
mkdir -p -m 700 ~/.azkaban/identities ~/.azkaban/identities/default
cp examples/env.azkaban.example ~/.azkaban/identities/default/env
cp examples/env.codex.example ~/.azkaban/identities/default/env.codex
printf 'PROJECT_MODE=development\n' >.env.codex
The caller's environment supplies no settings and no container
variables, only the terminal type the launcher passes on as TERM and,
for an identity that sets AZKABAN_SSH_AGENT=host, the agent socket
SSH_AUTH_SOCK names, so a launch's command line and these files
describe it completely, interactive or headless. A proxy the container
needs, such as HTTPS_PROXY, goes in the identity's env like any
other variable.
Every launcher control is named AZKABAN_<NAME>, the same for every
client. The controls are IMAGE, NETWORK, MOUNTS, MAX_MEMORY,
SKIP_CUSTOM_IMAGES (see Custom images), SSH_AGENT
(see Your own agent), and
ACCOUNT (see Accounts). A control takes its value from
the nearest layer that sets it, highest priority first:
command-line option > the identity's env.<client> > the identity's env
> built-in default
A line in the identity's env serves every client; the same line in its
env.<client> scopes the setting to that client and wins over env for
it. For example, AZKABAN_IMAGE=private/claude in env.claude changes
Claude's image and leaves the other clients on their own. The whole
AZKABAN_ prefix is reserved for launcher controls and never reaches a
container; any other AZKABAN_ name in the identity's files, such as
AZKABAN_SESSION, is dropped with a warning.
The workspace file holds container variables only. The client inside the
container can write it, so any AZKABAN_ name in it stops the launch
before the engine starts; set launcher controls with command-line
options or the identity's files.
Other keys are ordinary container variables, taken from these files only, highest priority first:
the identity's env.<client> > the identity's env > the workspace .env.<client>
The launcher drops, with a warning, names that configure the host side
of a launch, such as LD_*, PODMAN_*, SSH_AUTH_SOCK, TMPDIR, and
TERM, and the client's workspace marker, such as CODEX_WORKSPACE,
which it sets itself.
API keys belong in the identity's env, as OPENAI_API_KEY or
ANTHROPIC_API_KEY; keep credentials out of workspace files. Another
registered client's API-key variable is consumed and dropped in every
file layer. An identity's env may therefore hold both OPENAI_API_KEY
and ANTHROPIC_API_KEY; Codex and Claude receive only their own, and
OpenCode receives neither. 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.
The launcher reads these files but never creates them. 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.
Extra mounts and networks
Extra mounts deliberately expand client access, for example to share a folder of reference material read-only:
claude . --mount "$HOME/reference:/reference:ro"
A destination of ~ or ~/... expands to the active client's container
home, so one shared line covers all clients:
# in ~/.azkaban/identities/default/env
AZKABAN_MOUNTS=~/reference:~/reference:ro
--mount is repeatable and takes precedence over the comma-separated
AZKABAN_MOUNTS control. 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
network.
MCP binaries
Place executable MCP server binaries in ~/.azkaban/mcp/. Every client
shares that directory and sees it read-only at /opt/mcp; the launcher
creates it, private, on the first real launch of any client. Register a
server in the client's own configuration by its path there, such as
/opt/mcp/search.
A server published as a single-binary OCI image can be copied out of a
container that is created but never started. The binary is the image's
entrypoint; for an image without one, read .Config.Cmd instead. Run
the same commands again to update it:
mkdir -p -m 700 ~/.azkaban/mcp
ref=registry.example/mcp/search:latest
podman pull "$ref"
bin="$(podman image inspect --format '{{index .Config.Entrypoint 0}}' "$ref")"
id="$(podman create "$ref")"
podman cp "${id}:${bin}" ~/.azkaban/mcp/search
podman rm "$id"
The commands use Podman; on macOS, put a Linux arm64 build of the server
in ~/.azkaban/mcp by other means, such as its release downloads.
Custom images
A workspace that needs more tools than the client image ships can carry
its own layer as Dockerfile.codex, Dockerfile.claude, or
Dockerfile.opencode at the workspace root. When that file is present
and no image is named with --image or the IMAGE control, every
launch builds it and runs the result in place of the default image.
Start it from the installed client image by its localhost/ name, which
on macOS the installer adds beside the plain one:
# azkaban-image: website
FROM localhost/claude-code:latest
USER root
RUN apt-get update && apt-get install -y --no-install-recommends ruby-full \
&& rm -rf /var/lib/apt/lists/*
USER claude
A Dockerfile.azkaban serves every client that has no file of its own:
each launch builds it on the installed image of the client it runs,
replacing the file's first FROM, so one file gives every client an
image of its own. That FROM can name any client image, such as
localhost/claude-code:latest, so the file also builds by itself; only
the first FROM changes. Return to the client's user with USER 1000,
the ID that user has in every client image under a different name.
The agent can write to its workspace, so the launcher builds only content you approved. A launch that finds a new or changed file asks on the terminal, defaulting to no:
claude: Dockerfile.claude is new. Build it as localhost/claude-code-website:latest and run it? [y/N]
Answering y stores a copy of the file as the workspace's approval
record in ~/.azkaban/approved/<client>/, outside anything a container
sees, and the build reads that copy rather than the workspace file. The
shared file's record lies in ~/.azkaban/approved/azkaban/ and serves
every client, as its prompt says. Any other answer runs the default
image for that launch and asks again next time. Later launches with
unchanged content build without asking. A launch without a terminal
skips a file that is not approved, with a warning, and runs the default
image. Each workspace's record is named by the first 12 hexadecimal
characters of the SHA-256 of the path on the launch's Workspace: line,
as printf '%s' /path/to/workspace | sha256sum | cut -c1-12 prints it,
and deleting the record revokes the approval.
The image is named localhost/<client image>-<name>:latest. A
# azkaban-image: <name> line chooses the name from lowercase letters
and digits joined by single ., _, or -; the first such line
counts, and an invalid name stops the launch. Without the line, the name
comes from the workspace folder name. The client image prefix keeps any
name from replacing the installed image. Workspaces that resolve to the
same name share one image, which each launch rebuilds before it runs.
The engine's layer cache keeps an unchanged build short, while an edited
file, a changed file the build copies, or a client image refreshed by
install.sh rebuilds the affected layers on the next launch. A failed
build stops the launch before the container starts. Each rebuild leaves
the previous image untagged, and renaming leaves the old image in place;
podman image prune and podman rmi reclaim them on Linux, and
container image prune and container image delete on macOS.
The build runs on the host's engine with the workspace as its context
and on the engine's default network, so its network follows the engine
rather than the NETWORK control. Copy only the files the image needs:
the approved COPY lines are the guard that holds, since an ignore file
sits in the workspace, where the agent can change it without a new
approval.
AZKABAN_SKIP_CUSTOM_IMAGES=1 in the identity's env turns custom
images off, and --skip-custom-images turns them off for one launch. A
dry run neither builds nor records; it says on standard error what a
real launch would build or ask, and names the custom image in the
printed command. Without a terminal, a dry run with an unapproved file
prints the real launch's warning and keeps the default image.
Dry run
Inspect the exact launch without starting the engine 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. File-provided container variables reach a real launch
through a private env file that exists only while it starts, so the
printed command leaves them out and standard error names them. Before
the first real Claude launch of an identity or account, a Claude dry run
warns that its printed state bind sources do not exist yet; run once
without --dry-run to create them before reusing the printed command.
macOS
Each launch runs container run instead of podman run, so a few
things differ from Linux. macOS setup installs and
starts the engine.
- Mounts. Any host path mounts. The launcher resolves the workspace
to its physical path, so
/tmp/projectis mounted as/private/tmp/project. A mount of anything that is not a directory, such as a file or/dev/null, whose destination lies inside another mount stops the launch, because container would silently drop the enclosing mount (apple/container#2148). Mount such a file at a path outside the other mounts. A single-file extra mount shares the file's whole parent directory with the container's virtual machine; only the container's mount hides the siblings, so keep such files in a folder of their own. - Identities. An identity's
key, host agent, andgoals.mdare not served. A launch goes ahead without them and says so on standard error. Signing and pushing over SSH need an agent, so run work that pushes from a Linux host. - Memory. Without
--max-memory, container gives each container 1 GiB. Pass--max-memory 4g, or raisememoryunder[container]in container'sconfig.toml. - Terminal. An interactive launch clears the terminal's newline
translation (ONLCR) around the client, because container leaves it on
and garbles full-screen clients (apple/container#2299), and restores
the saved settings afterwards.
--no-ttyskips this. - Service. Every container command needs the service, so each launch
checks it,
--dry-runincluded. - Stopping. A supervisor stops a headless launch differently; see the Headless contract.
Known issue: Claude Code walking a large tree in a container has exhausted the host's file descriptors and crashed other applications (apple/container#1097). No workaround is known.
Troubleshooting
command not found: add$HOME/.local/bintoPATHand start a new shell.- Podman version failure (Linux): install or upgrade rootless Podman to 4.3 or newer.
- No TTY or noisy terminal control: pass
--no-tty. building Dockerfile.<client> failedorbuilding Dockerfile.azkaban failed: read the build output above the message and fix the file, or run without it: launch with--skip-custom-images, or setAZKABAN_SKIP_CUSTOM_IMAGES=1in the identity'senv.'NAME' in '.../.env.<client>' is a launcher setting: move the line to the identity'senvorenv.<client>, or use a command-line option.- Missing mount source: use an existing host path.
Bash 4.4 or newer is required: install Bash with Homebrew and put it ahead of/bin/bashonPATH.container system service is not running; start it with: container system start --enable-kernel-install: run that command, then rerun.Apple container 1.5.0 or newer is required (found: ...): the installer found an older release; upgrade withbrew upgrade container, then rerun the installer.mount '...' at '...' lies inside the mount of '...' at '...', which container would drop: move the extra mount's destination outside the other mount, as macOS explains.
Use codex --help, claude --help, opencode --help, or
./install.sh --help for the current command surface.
Development
Working on azkaban itself starts at Development, which covers the runtime layout, the image builds, and the checks every change passes; Adding a client holds the contract a new client follows.
License
COPYRIGHT holds the license and attribution details.