- Python 72.1%
- Shell 11.3%
- Roff 10.4%
- Dockerfile 6.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
/dev/net/tun for nested Podman, so nested containers can have their own network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RTL9C42xNgsvD5j85k15fD |
||
| .forgejo/workflows | ||
| packaging | ||
| src/claudia | ||
| tests | ||
| .gitignore | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
Claudia
Claudia runs Claude Code in a rootless Podman container
where Claude has full freedom (--dangerously-skip-permissions, passwordless
sudo), but can only:
- read the input directories you give it,
- write to one output directory, which is how results come back out,
- keep its own state (login, settings, history, auto-updated Claude Code) in a per-instance home that survives between runs.
Instances are named. The first run of an instance asks you to log in to Claude. Later runs of the same instance don't.
Install
Requires Podman (rootless) and Python ≥ 3.11 on the host.
# as a uv tool
uv tool install git+https://kode.naiv.no/olemd/claudia
# or the Debian package from the Forgejo registry
sudo curl https://kode.naiv.no/api/packages/olemd/debian/repository.key \
-o /etc/apt/keyrings/forgejo-olemd.asc
sudo tee /etc/apt/sources.list.d/claudia.sources > /dev/null <<'SOURCES'
Types: deb
URIs: https://kode.naiv.no/api/packages/olemd/debian/
Suites: trixie
Components: main
Signed-By: /etc/apt/keyrings/forgejo-olemd.asc
Architectures: all
SOURCES
sudo apt update && sudo apt install claudia
The registry only has Architectures: all, because Claudia is a pure Python
package. With Architectures: all in the sources file, apt doesn't look for
amd64/i386 indexes there, so it doesn't print "Skipping acquire" notices.
The Debian package installs the man page (man claudia) and bash
completion. With the uv tool, enable completion yourself:
mkdir -p ~/.local/share/bash-completion/completions
claudia completion bash > ~/.local/share/bash-completion/completions/claudia
The .deb, wheel and sdist are also attached to every
release.
Usage
# First run: -o is required. Inputs (-r) are optional and repeatable.
claudia run report -r ~/docs/specs -r ~/src/project -o ~/work/report-out
# Later runs remember the mounts; any -r/-o given replaces the remembered value.
claudia run report
# Forget one input (by basename or host path), or all of them.
claudia run report --remove-ro specs
claudia run report --clear-ro
# Headless: run one task and exit (the instance must be logged in already).
claudia run report -p "Summarise the specs into summary.md"
# Restrict egress to Anthropic's hosts (plus any you allow). Remembered;
# --no-restricted switches back.
claudia run report --restricted --allow-domain pypi.org
# Nested Podman (build and run containers inside) is on by default;
# --no-nested turns it off for the instance, --nested back on.
claudia run report --no-nested
# Use your own LLM instead of Anthropic (remembered; --no-llm switches back).
claudia run local --llm http://localhost:11434 --llm-model qwen3.6:latest
claudia list # all instances (--names: names only)
claudia show report # mounts, network mode, paths
claudia rm report # deletes login/state; never touches input or output
claudia run report --dry-run # print the podman command(s) instead
claudia build # build the images locally
claudia completion bash # print the bash completion script
Inside the container:
| Path | What | Mode |
|---|---|---|
/work/input/<basename> |
each -r DIR |
read-only |
/work/output |
-o DIR, the working directory |
read-write |
/home/claudia |
the instance's persistent home | read-write |
The user is claudia (uid 1000), mapped to your host user, so files Claude
writes to the output directory are owned by you. Files it creates with sudo
are owned by a subordinate uid instead. Remove those with podman unshare rm.
Nested containers
Claude can use podman build and podman run inside the container, as the
rootless user claudia. Nested images and containers are stored in the
instance home, so they survive between runs and are deleted by claudia rm.
Nested containers share the outer container's network, so --restricted
applies to them too. To pull from Docker Hub in restricted mode, allow
registry-1.docker.io, auth.docker.io and production.cloudfront.docker.com.
This needs three extra options on the outer container, and all are left out
with --no-nested:
--cap-add SYS_ADMINletsnewuidmapcreate the nested user namespace. The capability is scoped to the rootless user namespace and grants nothing on the host. It does widen the kernel attack surface.--security-opt unmask=/proc/*lets the nested runtime mount a fresh/proc, which the kernel refuses while paths in/procare masked.--device /dev/net/tunlets pasta give a nested container its own network (--network pasta, rootless bridge networks). The tap devices are created in the outer container's network namespace.
No --privileged or seccomp=unconfined is used.
Own LLM
--llm URL points an instance at your own model server instead of Anthropic.
The server must speak the Anthropic Messages API (POST /v1/messages), as
Ollama, llama.cpp's llama-server, vLLM and LiteLLM do. Claude Code is only
supported by Anthropic with Claude models, so expect some features to work
less well with other models.
# Ollama on this machine. localhost means the host, not the container.
claudia run local -o ~/work/out --llm http://localhost:11434 --llm-model qwen3.6:latest
# A server elsewhere, with a token.
export CLAUDIA_LLM_TOKEN=sk-...
claudia run remote -o ~/work/out --llm https://llm.example.org --llm-model my-model
# The token in another variable; only the variable's name is remembered.
claudia run remote --llm-token-env MY_LLM_KEY
# Tell Claude Code the model's context window, and send nothing to Anthropic.
claudia run local --llm-context 65536 --llm-only
How it works:
- Claudia sets
ANTHROPIC_BASE_URLandANTHROPIC_AUTH_TOKENin the container, andANTHROPIC_MODELplus the per-tier model variables to--llm-model, so background tasks use the same model. It also setsCLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1, because many non-Anthropic servers reject pre-release request fields. No claude.ai login is needed, also for headless (-p) runs. --llm-context TOKENSsetsCLAUDE_CODE_MAX_CONTEXT_TOKENS. Claude Code assumes 200k tokens for model names it doesn't know and compacts the conversation by that window. This only tells Claude Code the size; the server decides how much the model really reads. Give the model at least that window on the server. Claude Code's very first request (system prompt and tool definitions) is about 15k tokens. Ollama's default can be as low as 4k, and Ollama then cuts the prompt without an error ("truncating input prompt" in its log), so Claude ignores your instructions. For Ollama, setOLLAMA_CONTEXT_LENGTHfor the server orPARAMETER num_ctxin a Modelfile, and checkollama psor/api/psfor the context in use.--llm-onlysends nothing to Anthropic. It setsCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1(telemetry, version checks, release notes) and passes--settings '{"skipWebFetchPreflight": true}'(WebFetch's domain check). This also turns off auto-updates, so the instance keeps the Claude Code version it has. With--restricted, the Anthropic hosts are left out of the allowlist, so only the LLM host and--allow-domainhosts are reachable.- The token is read from
$CLAUDIA_LLM_TOKEN(or--llm-token-env VAR) on each run and handed to Podman through the environment. It is never written to the instance config or put on a command line, so it doesn't show in--dry-runor the process list. If the variable is unset, the placeholdernoneis sent, which servers without authentication accept. localhost,127.0.0.1and::1mean the host. In full network mode Claudia forwards only the LLM's port from the container's loopback to the host's (pasta-T PORT), so a server that only listens on127.0.0.1, as Ollama does by default, is reachable, and no other service on the host's loopback is.localhostis sent as127.0.0.1, because Claude Code would try::1first; give[::1]explicitly for a server that only listens on IPv6. (Claudia 0.2.3 used--map-host-loopback, which exposed all of them.)- With
--restricted, the LLM host is added to the proxy's allowlist, and a port other than 443 is allowed for HTTPS tunnels. The proxy can't reach the host's loopback, so a model server on this machine has to listen on another address, such as a bridge address (for exampleOLLAMA_HOST=192.168.122.1) given in--llm. The proxy filters by host name only, so every port on the allowed LLM host is reachable over plain HTTP.
Work log
Every session is started with an appended system prompt (baked into the
image at /usr/share/claudia/log-instructions.md) that requires Claude to keep
a high-level log of what it does and why in /work/output/CLAUDIA-LOG.md. The
log is append-only, written at architecture level, and ends each task with a
summary and open questions. The same prompt also tells Claude to suggest a
dynamic workflow (the Workflow tool, which fans the work out to several
subagents) when a task is large and splits into independent parts; it only
runs one after you opt in.
Where things live on the host
| Path | Content | Mounted? |
|---|---|---|
$XDG_CONFIG_HOME/claudia/instances/NAME.json |
start-up options (mounts, network, allowlist) | never |
$XDG_DATA_HOME/claudia/NAME/home/ |
Claude's login, settings, history, Claude Code itself | as /home/claudia |
claudia run refuses any -r/-o that is, contains or lies inside either
Claudia tree. That way Claude can never see or change how it will be started
next time, and can't read other instances' credentials.
Security model
- The container is rootless, so root inside (via
sudo) is your own host user at most. Read-only bind mounts stay read-only even for root. - The instance home is writable by Claude by design. That is what keeps login and auto-updates. Claude can therefore change its own settings or Claude Code binary for that instance, but never the mounts or network mode, which are chosen by the host CLI.
- The work-log instructions and the entrypoint live in the image. Each run
uses
--rm, so changes made inside the container don't last. --restrictedputs the container on an internal Podman network with no route out. A per-runclaudia-proxycontainer (tinyproxy, deny by default) forwards only toapi.anthropic.com,claude.ai,claude.com,platform.claude.com,downloads.claude.ai, any--allow-domainhosts and the--llmhost (with--llm-only, only the last two).- Connectors from your Claude account come along. Once an instance is
logged in with a claude.ai account, Claude Code inside the container can use
every MCP connector you have connected to that account on the web (Gmail,
Google Drive, calendars and so on). It can do so with your credentials,
and these connectors reach outside the container. Mount restrictions don't
apply to them. In
--restrictedmode they are blocked, because the connector proxymcp-proxy.anthropic.comis not on the allowlist (add it with--allow-domainif you want them). To turn them off in full network mode as well, you have three options:- disconnect them in your claude.ai settings (this affects every client),
- log the instance in with an account that has no connectors, or
- set
ENABLE_CLAUDEAI_MCP_SERVERS=falsefor the instance. Add"env": {"ENABLE_CLAUDEAI_MCP_SERVERS": "false"}to$XDG_DATA_HOME/claudia/NAME/home/.claude/settings.json. That file is in the instance home, which Claude can write to, so Claude can undo this. Treat it as a convenience, not a security boundary.
- Nested Podman (on by default) adds
SYS_ADMINinside the user namespace, unmasks/procand adds/dev/net/tun. See Nested containers. Use--no-nestedfor the tightest sandbox.
Images
claudia run uses kode.naiv.no/olemd/claudia:<version> (and
claudia-proxy:<version> for --restricted). If the image isn't present
locally, it is pulled. If the pull fails, it is built from the files shipped
inside the package. Claude Code updates itself inside each instance home, so
the image only needs rebuilding for changes to the base system.
Development
uv sync
uv run ruff format --check . && uv run ruff check .
uv run pytest
shellcheck src/claudia/assets/entrypoint.sh src/claudia/assets/proxy/entrypoint.sh packaging/*.sh packaging/claudia.launcher
shellcheck -s bash src/claudia/assets/completion/claudia.bash
groff -man -ww -z packaging/claudia.1
uv build # dist/*.whl, dist/*.tar.gz
packaging/build-deb.sh # dist/claudia_<version>_all.deb
CI (.forgejo/workflows/ci.yml) runs the checks on every push and pull
request. Publishing a release (tag v<version> matching pyproject.toml)
also runs the release job, which builds and publishes the wheel, sdist,
.deb and both container images. Add an entry for the new version at
the top of packaging/changelog (Debian changelog format) first: it becomes
the .deb's changelog, and build-deb.sh refuses to build when its newest
entry does not match pyproject.toml. The release job needs the repository
secret PACKAGES_TOKEN, an access token of the repository owner (olemd)
with write:package and write:repository (repository access "Public only").
The first is for the registries, the second for attaching files to the
release.
Forgejo cannot limit write:package to a single repository. To keep
repository write access off the package token, give it only write:package
and add a second secret RELEASE_TOKEN with write:repository limited to
olemd/claudia. The release job uses RELEASE_TOKEN for attachments when it
is set.
License
MIT, see LICENSE.