Replaced by: https://git.unitoo.it/claudiomaradonna/azkaban Run Claude Code 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.5%
  • Dockerfile 1.2%
  • Makefile 0.3%
Find a file
2026-08-31 15:26:49 +02:00
.ai chore(policy): adopt latest Agent Foundry 2026-08-28 21:03:29 +02:00
.claude
docs chore(policy): adopt latest Agent Foundry 2026-08-28 21:03:29 +02:00
scripts chore(policy): adopt latest Agent Foundry 2026-08-28 21:03:29 +02:00
tests fix: reap orphaned container processes 2026-08-31 15:26:49 +02:00
.claudeignore
.dir-locals.el
.dockerignore fix: avoid recursive Podman session chowns 2026-08-27 18:22:40 +02:00
.env.example docs: document --policy for broker jobs 2026-08-26 21:57:18 +02:00
.gitignore chore(policy): adopt latest Agent Foundry 2026-08-28 21:03:29 +02:00
AGENTS.md chore(policy): adopt latest Agent Foundry 2026-08-28 21:03:29 +02:00
CLAUDE.md fix: avoid recursive Podman session chowns 2026-08-27 18:22:40 +02:00
claude.sh fix: reap orphaned container processes 2026-08-31 15:26:49 +02:00
COPYRIGHT chore(policy): adopt latest Agent Foundry 2026-08-28 21:03:29 +02:00
Dockerfile feat(image): install Node.js 22 LTS via NodeSource 2026-06-03 15:39:00 +02:00
install.sh fix: avoid recursive Podman session chowns 2026-08-27 18:22:40 +02:00
Makefile test: add dependency-free harness with a podman stub 2026-07-27 11:35:38 +02:00
README.md fix: reap orphaned container processes 2026-08-31 15:26:49 +02:00

Claude Jail

Run Claude Code inside a rootless Podman container instead of directly on your host machine. Your project files are bind-mounted into the container, so Claude can read and edit them while everything else stays sandboxed.

Why

Claude Code 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.

Prerequisites

  • Podman 4.3 or newer (installed automatically by the install script, or bring your own)
  • A valid Claude Code account (you will authenticate on first run)

Quick start

git clone <repo-url> && cd claude-jail
./install.sh

The install script will:

  1. Install Podman if it is not already present (supports apt, dnf, pacman, brew)
  2. Build the claude-code container image from the included Dockerfile
  3. Place a claude wrapper script in ~/.local/bin/

If ~/.local/bin is not in your PATH, the script will tell you what to add. Example:

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

Add it to your shell rc file (.bashrc, .zshrc, etc.) to make it permanent.

Usage

claude <directory> [options]

If omitted, the wrapper prompts to use the current directory (or auto-accepts it when stdin is not a TTY, so non-interactive callers don't hang). All arguments that are not recognized by the wrapper are forwarded directly to the claude CLI inside the container, so every native flag works as expected.

Where the directory may appear: before the first argument bound for claude. Wrapper flags such as --with-ssh-agent may precede it, but once something the wrapper doesn't recognize is seen, every later path belongs to claude. That is what stops an option value from being mounted by accident — in claude . -p docs, the docs is -p's value and reaches claude, rather than becoming the directory the container sees. Use -- to place the boundary yourself:

claude . -- --model sonnet    # everything after -- goes to claude untouched

A -- marks the wrapper's boundary only while nothing bound for claude has been seen yet. Once claude's arguments have started it is claude's own and is forwarded along with everything after it — so claude 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.

Examples

# Interactive session on the current directory
claude .

# Work on a specific project
claude /path/to/project

# Pass native Claude Code flags
claude . --model sonnet
claude . --resume
claude . -p "explain this codebase"

# One-shot prompt mode
claude . -p "find and fix the memory leak in server.js"

# Forward your SSH agent (useful for git operations inside the container)
claude --with-ssh-agent .
claude --with-ssh-agent /path/to/project -p "push the fix"

# Headless: no TTY is allocated when stdin and stdout are not terminals,
# so the output is plain text rather than terminal escape sequences
claude . -p "run the test suite" > report.log 2>&1

# Use a custom container image
claude --image my-custom-claude .

# Attach to an existing Podman network to reach another service by name
# (e.g. an ollama container reachable at http://ollama:11434)
claude --network ollama_default .

# Use a custom working directory inside the container
claude --container-workdir /app .

# See exactly what would run, without running it
claude . --dry-run

# List all sessions
claude --all-sessions

# Resume a previous session
claude . --session a1b2c3d4

Wrapper-specific options

Flag Description
--with-ssh-agent Bind-mount the host SSH agent socket into the container so that git push, git clone, etc. work with your SSH keys. See note on commit signing below.
--session <id> Resume a previous session by its 8-character ID.
--all-sessions List all sessions with creation timestamps.
--mount <src:dst[:opt]> Additional bind mount, repeatable (e.g. :ro for read-only). Can also be set via CLAUDE_JAIL_MOUNTS env var.
--mcp-image <ref> OCI image whose entrypoint is a single static MCP server binary (e.g. ghcr.io/mudler/mcps/localrecall:latest), repeatable. Pulled on every run; the binary is extracted to ~/.claude-jail/mcp/<name> and appears at /opt/mcp/<name> inside the container. Can also be set via CLAUDE_JAIL_MCP_IMAGES env var.
--network <name> Attach the container to an existing Podman network so it can reach other services (containers) on that network by name. Can also be set via CLAUDE_JAIL_NETWORK env var.
--max-memory <value> Container memory limit (default: total host RAM). Can also be set via CONTAINER_MAX_MEMORY env var.
--image <name> Container image to use (default: claude-code). Can also be set via CLAUDE_JAIL_IMAGE env var.
--container-workdir <path> Working directory inside the container (default: /workspace). Can also be set via CLAUDE_JAIL_WORKSPACE env var.
--ignore-file <path> Path to ignore file (default: <workspace>/.claudeignore). Can also be set via CLAUDE_JAIL_IGNORE env var.
--policy <path> Take all file-sourced configuration from <path> instead of the workspace. Disables .env.claude and ~/.claude-jail/env.default. Intended for job brokers that launch sessions against untrusted workspaces. CLI-only — there is deliberately no env-var equivalent.
--no-tty Never allocate a TTY for the container. Already implied when stdin or stdout is not a terminal, so headless callers need not pass it; use it to force plain output on a terminal you do have. Can also be set via CLAUDE_JAIL_NO_TTY env var.
--dry-run Print the podman command the wrapper would run, then exit without running it. Stdout is exactly the command (copy-pasteable); diagnostics go to stderr. No session directory is created.
-h, --help Show help message and exit.

Caveats when pasting a --dry-run command: it is meant for inspection, not a verbatim rerun. Name-only -e KEY arguments resolve against the shell you paste into, not .env.claude — a plain shell hasn't loaded that file, so variables like ANTHROPIC_API_KEY will silently be missing from the container. And because no session exists yet on a fresh workspace, Podman will auto-create the missing -v .../.claude.json bind-mount source as a directory, permanently breaking that session; run the command for real (without --dry-run) at least once first.

SSH commit signing

The image ships with openssh-client, so git push/clone over SSH and SSH-based commit signing (gpg.format = ssh) both work once --with-ssh-agent is on. Everything else is your responsibility: the wrapper does not inject a git identity, signing config, or public key into the container.

To sign commits inside the container, set up at least:

  • user.name, user.email, gpg.format = ssh, commit.gpgsign = true, user.signingkey
  • Make the signing key reachable. Either mount your gitconfig and public key explicitly, e.g.
    claude --with-ssh-agent \
      --mount ~/.gitconfig:/home/claude/.gitconfig:ro \
      --mount ~/.ssh/id_ed25519.pub:/home/claude/.ssh/id_ed25519.pub:ro \
      .
    
    or use the inline form user.signingkey = "key::ssh-ed25519 AAAA... you@host" and skip mounting any file.

If your host ~/.gitconfig uses ~ in paths (e.g. signingkey = ~/.ssh/id_ed25519.pub), remember that ~ resolves to /home/claude inside the container — the file must live there, or the path must be adjusted.

Environment variables

Variables can be set in three ways, listed by priority (highest first):

  1. CLI flags (--image, --session, etc.) — always win.
  2. Host environment (export ANTHROPIC_API_KEY=... in your shell) — overrides both env files.
  3. .env.claude file in the workspace directory — per-project override, loaded automatically if present.
  4. ~/.claude-jail/env.default file — shared defaults for every project; only fills in keys not already set by the steps above.

Under --policy <path> the list is shorter: CLI flags, then the host environment, then the policy file. Neither .env.claude nor env.default is read. See Broker jobs.

Variables are divided into two groups:

Script-level (configure how the container is launched)

These are consumed by claude.sh and are not forwarded into the container.

Variable Default CLI equivalent Description
CLAUDE_JAIL_IMAGE claude-code --image Container image name.
CLAUDE_JAIL_WORKSPACE /workspace --container-workdir Working directory inside the container.
CLAUDE_JAIL_USE_SSH 0 --with-ssh-agent Set to 1 to forward the host SSH agent.
CLAUDE_JAIL_SESSION (derived from workspace path) --session Override the deterministic session ID. Must point to an existing session.
CLAUDE_JAIL_MOUNTS (none) --mount Comma-separated extra bind mounts (e.g. /a:/b:ro,/c:/d).
CLAUDE_JAIL_MCP_IMAGES (none) --mcp-image Comma-separated OCI image refs of single-binary MCP servers to keep extracted in ~/.claude-jail/mcp.
CLAUDE_JAIL_NETWORK (none) --network Existing Podman network to attach the container to (e.g. ollama_default).
CLAUDE_JAIL_IGNORE (none) --ignore-file Path to ignore file (default: <workspace>/.claudeignore).
CLAUDE_JAIL_NO_TTY 0 --no-tty Set to 1 to never allocate a TTY. Auto-detected otherwise.
CONTAINER_MAX_MEMORY (total RAM) --max-memory Container memory limit (e.g. 512m, 4g).

Container-level (injected inside the container)

Every variable in .env.claude that is not in the script-level group above gets forwarded into the container as a regular environment variable.

Values are passed to Podman by name only (-e KEY, never -e KEY=value), so they never appear in the podman run command line. This matters because process arguments are world-readable on Linux — any user on the host can read /proc/<pid>/cmdline or run ps aux. Podman reads each value from the environment it inherits from the wrapper instead, which is readable only by you and root. A side effect: if a variable is set both in your shell and in .env.claude, the container now receives the shell value (matching the precedence documented above), not the file's — previously the file's value leaked through even though the wrapper itself used the shell's.

This means a forwarded variable must be a real exported environment variable. If a key in .env.claude or env.default collides with one of the wrapper's own internal variable names (e.g. workspace, network, JAIL_DIR, SESSIONS_DIR), it cannot be forwarded; the wrapper prints a warning and skips it.

Note: this hides secrets from other users on the host. It does not encrypt .env.claude at rest — keep that file out of version control.

Variable Description
ANTHROPIC_API_KEY Anthropic API key. If also exported in your shell, the shell value wins.
(any other) Custom variables available to Claude Code and any process in the container.

Tip: copy the included .env.example to get started: cp .env.example .env.claude

Everything else is passed through to claude unchanged.

Sessions

The session ID is derived deterministically from the absolute workspace path (first 8 hex chars of its SHA-256). Re-invoking claude in the same directory transparently reuses the same session — no need to set CLAUDE_JAIL_SESSION in .env.claude or pass --session. Session data is stored under ~/.claude-jail/sessions/<id>/ and printed to stderr at startup:

Session: a1b2c3d4 (/home/user/.claude-jail/sessions/a1b2c3d4)

To pin a different ID (or share one across folders), pass --session <id> or set CLAUDE_JAIL_SESSION. In that case the session must already exist; if it does not, the wrapper prints the available sessions and exits.

When a session directory does not yet exist, an empty {} config is generated along with a config/ directory containing default settings. Each session has fully isolated state — configuration, credentials, and conversation history — so you can run multiple containers in parallel without conflicts.

~/.claude-jail/
  sessions/
    a1b2c3d4/              # session-specific state
      .claude.json          # credentials (initially empty {})
      config/               # mapped to /home/claude/.claude in container
        settings.json       # auto-trusts container workdir
    f9e8d7c6/
    ...

The wrapper maps the invoking host user to the image's claude user (UID/GID 1000) through Podman's keep-id user namespace. Session bind mounts therefore remain writable without Podman's :U option, which would recursively change ownership across the session tree and can make container startup increasingly slow as history accumulates.

.env.claude

If a file named .env.claude exists in the workspace directory, its variables are automatically loaded. Variables prefixed with CLAUDE_JAIL_ and CONTAINER_MAX_MEMORY are used by the wrapper script itself. All other variables are forwarded into the container.

# .env.claude
ANTHROPIC_API_KEY=sk-ant-...
CLAUDE_JAIL_SESSION=a1b2c3d4
CLAUDE_JAIL_USE_SSH=1
CONTAINER_MAX_MEMORY=2g
# CLAUDE_JAIL_IMAGE=my-custom-image
# CLAUDE_JAIL_WORKSPACE=/app
MY_CUSTOM_VAR=hello
  • Blank lines and lines starting with # are ignored.
  • A single matching pair of surrounding "..." or '...' quotes is stripped from values; nothing inside is further expanded (no $VAR, no ~).
  • CLAUDE_JAIL_SESSION pins a specific session ID instead of the one derived from the workspace path. The CLI flag --session always takes precedence; both must reference an existing session.
  • CLAUDE_JAIL_* and CONTAINER_MAX_MEMORY variables are consumed by the script and not forwarded into the container.
  • Host environment variables (e.g. ANTHROPIC_API_KEY exported in your shell) override values from .env.claude.
  • In mount source paths (--mount / CLAUDE_JAIL_MOUNTS), a leading ~ or literal $HOME is expanded to the host user's home directory.

Tip: Add .env.claude to your .gitignore — it will typically contain secrets.

Shared defaults: ~/.claude-jail/env.default

A file named env.default in the jail directory (~/.claude-jail/env.default) holds defaults shared across all projects. It uses the exact same format as .env.claude (same parsing, same quoting rules, same script-level vs. container-level split).

When the wrapper starts it loads .env.claude first, then env.default; for each variable the first file to define it wins. The result is a clean layering:

  • Put the settings common to every project (preferred image, default mounts, CLAUDE_JAIL_USE_SSH, etc.) in ~/.claude-jail/env.default.
  • Keep only the per-project deltas in each workspace's .env.claude.
  • Anything in .env.claude overrides env.default; host environment variables still override both.
# ~/.claude-jail/env.default
CLAUDE_JAIL_USE_SSH=1
CLAUDE_JAIL_IMAGE=my-custom-image
CLAUDE_JAIL_MOUNTS=$HOME/.gitconfig:/home/claude/.gitconfig:ro

If env.default does not exist, the wrapper behaves exactly as before — it is entirely optional.

Custom MCP servers: ~/.claude-jail/mcp

A directory named mcp in the jail directory (~/.claude-jail/mcp) holds custom MCP server binaries shared across all projects. When it exists, it is mounted read-only at /opt/mcp inside every container; like env.default, it is entirely optional and nothing changes when it is absent.

# one-time setup on the host
mkdir -p ~/.claude-jail/mcp
cp localrecall-mcp ~/.claude-jail/mcp/
chmod +x ~/.claude-jail/mcp/localrecall-mcp

Then register the server from inside a session:

claude mcp add --scope user localrecall \
  --env LOCALRECALL_URL=http://localrecall:8080 \
  --env LOCALRECALL_API_KEY=your-key \
  --env LOCALRECALL_COLLECTION=project_example_memory \
  --env LOCALRECALL_ENABLED_TOOLS=search,list_files \
  -- /opt/mcp/localrecall-mcp

The -- and everything after it reach the claude CLI intact (see the argument-parsing note above), and /opt/mcp/localrecall-mcp resolves in every session because the mount point is fixed.

Things to know:

  • --scope user is per-session here. Inside the jail, the "user" config (~/.claude.json) is the session's own .claude.json, so a user-scoped server is registered once per workspace session and persists with it from then on. For a registration that lives in the repo and works for every clone, use --scope project instead — it writes .mcp.json into the mounted workspace.
  • Pin LocalRecall to one project and a read-only tool set. Replace project_example_memory with the stable logical collection for that project. Stock LocalRecall enables every registered tool when LOCALRECALL_ENABLED_TOOLS is omitted; coding agents should receive only search,list_files.
  • 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 CLAUDE_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 CLAUDE_JAIL_MOUNTS for MCP servers. CLAUDE_JAIL_MOUNTS is a single key, so a project's .env.claude 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.

MCP servers from OCI images: --mcp-image

Instead of copying binaries into ~/.claude-jail/mcp by hand, --mcp-image fills the directory from container images — for example the mudler/MCPs collection, where each image is a single static stdio MCP server:

claude . --mcp-image ghcr.io/mudler/mcps/localrecall:latest

On every run the wrapper pulls the image (so a :latest tag actually tracks upstream), reads the image's entrypoint, and copies that binary out of a created-but-never-started container into ~/.claude-jail/mcp/<name>localrecall in the example, derived from the image name without tag or digest. When the pulled image ID matches the one recorded from the previous extraction, nothing is re-extracted, so the steady-state cost is just the pull check. No MCP container ever runs; the server executes inside the jail like any hand-dropped binary.

Registration is the same one-time step as above:

claude mcp add --scope user localrecall \
  --env LOCALRECALL_URL=http://localrecall:8080 \
  --env LOCALRECALL_API_KEY=your-key \
  --env LOCALRECALL_COLLECTION=project_example_memory \
  --env LOCALRECALL_ENABLED_TOOLS=search,list_files \
  -- /opt/mcp/localrecall

To refresh the same servers for every session, set the env-var spelling in ~/.claude-jail/env.default:

# ~/.claude-jail/env.default
CLAUDE_JAIL_MCP_IMAGES=ghcr.io/mudler/mcps/localrecall:latest,ghcr.io/mudler/mcps/duckduckgo:latest

Things to know:

  • Works for any single-binary image. The wrapper extracts Entrypoint[0] (falling back to Cmd[0]). An image whose entrypoint is a script with runtime dependencies fails with an error rather than producing a broken server.
  • Offline is not fatal. If the pull fails but a previously extracted binary exists, the wrapper warns and keeps using it; it only errors when there is nothing to fall back to.
  • Hand-dropped binaries are protected. Extraction refuses to overwrite a file in ~/.claude-jail/mcp that it did not extract itself (tracked via a .<name>.image-id marker file next to the binary).
  • The binary must suit the jail image. Same rule as hand-dropped binaries: Linux, host architecture, statically linked. CGO_ENABLED=0 Go binaries — which is what mudler/MCPs ships — satisfy this.

Broker jobs: --policy

.env.claude lives in the workspace, and the workspace is mounted read-write. Any CLAUDE_JAIL_* key it defines configures the next launch, so an agent that can write to its own project directory can choose what the following session mounts:

# .env.claude, written by an agent during job N
CLAUDE_JAIL_MOUNTS=/:/host

For interactive use this is a feature — you own both the workspace and the host. For a job broker running untrusted repos it is an escalation path, so --policy moves every file-sourced setting to a host-controlled file:

claude --policy /etc/claude-jail/jobs/1234.env --no-tty /srv/jobs/1234/repo

The policy file uses the same KEY=value format as .env.claude. Under --policy:

  • ${workspace}/.env.claude is ignored, silently.
  • ~/.claude-jail/env.default is not read either, so one file fully determines a job.
  • The host environment still outranks the policy file, which is how to inject a per-job secret without writing it to disk.
  • ${workspace}/.claudeignore is still honored, in addition to any --ignore-file. Every pattern only ever hides a path, so a project can add to what is hidden but can never unhide what the policy hid.
  • A missing policy file, or one stored inside the workspace, is a fatal error rather than a fallback.

.claudeignore

If a file named .claudeignore exists in the workspace root, files and directories matching its patterns will be hidden inside the container. This lets you mount a project directory while keeping sensitive files (secrets, credentials, private configs) invisible to Claude.

# .claudeignore
.env.secret
credentials/
*.key
**/*.pem
config/.env.production

Format:

  • One glob pattern per line
  • # for comments, blank lines ignored
  • Supports simple globs (*.secret), recursive patterns (**/*.key), and directory-only matches (trailing /)
  • Leading / is stripped (patterns are always relative to the workspace root)
  • Patterns may contain spaces; the whole line is one pattern
  • Negation patterns (!pattern) are not supported

How it works: The workspace directory is still bind-mounted as a whole, but each matched file gets /dev/null mounted over it (read-only), and each matched directory gets an empty tmpfs overlay. The original files on the host are never modified.

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. Put your broad directory patterns in .claudeignore and the rest of the file gets cheaper too.

Conversely, a pattern that matches many individual files costs one mount each. Something like **/*.js across a large dependency tree can generate enough mounts to 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.

Custom ignore file: Use --ignore-file <path> or CLAUDE_JAIL_IGNORE=<path> to specify an alternative ignore file. If an explicitly-requested ignore file does not exist, the script exits with an error. The default .claudeignore is silently skipped if absent.

Tip: Add .claudeignore to your project if you keep secrets alongside your code (e.g. .env files, TLS certificates, API keys).

What gets mounted

Host path Container path Purpose
<directory> Container workdir (default /workspace, configurable via CLAUDE_JAIL_WORKSPACE) Your project files (read/write)
~/.claude-jail/sessions/<id>/config /home/claude/.claude Session-specific Claude Code state
~/.claude-jail/sessions/<id>/.claude.json /home/claude/.claude.json Session-specific credentials
$SSH_AUTH_SOCK /ssh-agent SSH agent socket (only with --with-ssh-agent)
~/.claude-jail/mcp /opt/mcp (read-only) Custom MCP server binaries, hand-dropped or extracted by --mcp-image (only if the directory exists)

Nothing else from the host is visible inside the container.

Note: The container runs Claude Code with --dangerously-skip-permissions, which disables its built-in confirmation prompts. This is safe because the container itself acts as the sandbox — Claude can only access the explicitly mounted workspace and session directories.

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 session state.

A custom Claude Code status line or hook may immediately recreate the problem. Restart once with all hooks disabled:

claude . --settings '{"disableAllHooks":true}'

Then run /statusline clear inside Claude, exit, and launch normally. The disableAllHooks override applies only to the rescue session and temporarily disables every hook, not only the status line.

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. In that case, disabling the offending status line or hook is the required mitigation.

Uninstall

rm ~/.local/bin/claude
podman rmi claude-code    # or your custom image name if CLAUDE_JAIL_IMAGE was set

License

BSD 3-Clause (see COPYRIGHT)