Run Claude with separate sessions in isolated sandboxes. The end-goal is to provide enough safety to make it comfortable (for me) to run with permissions bypassed.
  • Go 95.8%
  • Python 4%
  • Shell 0.1%
  • Just 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Claudia 0af09a115e
All checks were successful
ci/woodpecker/push/build Pipeline was successful
chore: sync the files wrangle manages
.woodpecker/release.yml
2026-10-20 21:23:18 +02:00
.just feat!: take the install prefix from the wrangle fragment 2026-08-01 23:42:37 +02:00
.woodpecker chore: sync the files wrangle manages 2026-10-20 21:23:18 +02:00
cmd/claudia-shim feat: hold secrets in an out-of-sandbox broker instead of an on-disk snapshot 2026-06-10 10:31:26 +02:00
docs feat: prune the scratch dirs claude code leaves in $TMPDIR 2026-10-20 21:19:22 +02:00
internal feat: prune the scratch dirs claude code leaves in $TMPDIR 2026-10-20 21:19:22 +02:00
scripts ci: update to wrangle style 2026-08-17 22:40:47 +02:00
.gitignore fix: anchor the binary ignores to the repo root 2026-08-01 23:55:39 +02:00
CHANGELOG.md docs: changelog for v8.1.2 2026-10-20 20:24:42 +02:00
go.mod chore: update gout to v0.8.0 2026-10-20 20:23:29 +02:00
go.sum chore: update gout to v0.8.0 2026-10-20 20:23:29 +02:00
justfile feat!: take the install prefix from the wrangle fragment 2026-08-01 23:42:37 +02:00
main.go fix: correct module path to git.meatbag.se/varl/claudia 2026-06-25 09:28:40 +02:00
README.md feat: add claudia prune to clear out what agents leave behind 2026-10-20 21:14:56 +02:00

claudia

Run Claude Code in an OS-native sandbox using Landlock on Linux and Seatbelt (sandbox-exec) on macOS.

Each profile gets its own isolated Claude config, cache, and conversation history. The host filesystem is read-only except for explicitly mounted directories.

See Getting started for install instructions, requirements, and first-run setup. The contract between claudia and what runs inside the sandbox is the guest ABI. Upgrading across a major version? See the migration guides.

Usage

claudia                              # launch the agent in sandbox with cwd writable
claudia exec -- <cmd> [args...]      # run any command in the sandbox (no agent env)
claudia -a claude exec -- <cmd>      # ...with an agent's environment
claudia status                       # show profile config
claudia doctor                       # check for silent misconfiguration
claudia doctor --accept              # keep drifted seed files as they are
claudia shell                        # open a shell in the sandbox
claudia update                       # update claudia itself
claudia version                      # show claudia, agent and shim versions

claudia card list                    # installed agent cards
claudia card use codex               # make codex this profile's default agent
claudia -a codex prompt "take over"  # run a different agent for one invocation

claudia mount add <path>             # add a read-only path (default)
claudia mount add <path>:rw          # add a read-write path
claudia mount rm <path>              # remove a mounted path

claudia secret add K V               # set a secret (hidden from agent)
claudia secret add --inject api.github.com=bearer K   # set and bind (never enters the sandbox)
claudia secret bind K ORIGIN=FORM... # bind an existing secret; unbind K removes
claudia secret add K                 # read value from stdin if piped, else prompt (no echo)
claudia secret add K -               # read secret value from stdin
claudia secret rm K                  # remove a secret

claudia config                       # show current profile config
claudia config set env.K V          # set a profile env var
claudia config rm  env.K            # remove a profile env var
claudia config add --scope seed env-host GOFLAGS   # seed a default for new profiles

claudia edit config                  # open profile config in $EDITOR
claudia edit prompt                  # edit profile CLAUDE.md
claudia edit statusline              # edit statusline script
claudia edit --scope seed prompt     # edit the seed CLAUDE.md (default for new profiles)

claudia resume                       # resume most recent session for cwd
claudia resume --list                # list resumable sessions

claudia audit                        # pretty-print audit log
claudia audit -n 50                  # print only the last 50 entries
claudia audit -f                     # print last 10 entries, then stream new ones (--follow)
claudia audit -f -n 50               # stream with 50 entries of backlog
claudia audit path                   # print audit log file path
claudia audit clear                  # truncate audit log

claudia prune                        # what agents left behind (dry run)
claudia prune --yes                  # remove it (--all for every profile)

claudia reset mount                  # reset mounts (also: secret, env, prompt, env-host, ...)

claudia profile get                  # list profiles
claudia profile create               # check prerequisites, set up a profile
claudia profile use <n>              # switch active profile
claudia profile rm <n>               # delete a profile (--all for everything)

claudia memory                       # show the memory subcommands
claudia memory list                  # list current project + profile-global memories
claudia memory show <name>           # print memory body
claudia memory add --scope project   # draft a new project memory in $EDITOR
claudia memory add --scope profile   # draft a new profile-global memory
claudia memory add --scope seed      # draft a memory every new profile inherits
claudia memory edit <name>           # open memory in $EDITOR
claudia memory rm --scope project <name>...  # delete a project memory
claudia memory rm --scope profile <name>     # delete a profile-global memory

Running claudia with no arguments is the main workflow: it ensures the profile is set up, adds your current directory as writable, and launches the profile's agent inside the sandbox.

claudia does not install or house the agent binary — install it however you like and claudia resolves it from the sandbox PATH. claudia status prints which copy it resolved to, and its version.

Profiles

Each profile is fully isolated — its own agent state, conversations, cache, and writable paths. Profiles share nothing.

A profile is one security posture and one workspace identity; an agent is one binary and one state dir. They are orthogonal, so a profile can host several agents: they share its mounts, secrets, egress allowlist, memory and audit stream, and differ only in where their state lives. That is what makes hand-off a flag — claudia -a codex prompt "take this over" runs in the same cwd with the same everything.

claudia profile create work   # create a profile named "work"
claudia -p work               # run claude in the "work" profile
claudia profile get       # list all profiles
claudia profile use work  # set "work" as active profile

direnv integration

Add to ~/.config/direnv/direnvrc:

use_claudia() { eval "$(claudia profile load "$@")"; }

Then in any project's .envrc:

use claudia myproject

Plugins

claudia does not manage Claude Code plugins — Claude Code does. What claudia provides is the per-profile plugin dir (<profile>/plugins/, exported as CLAUDE_CODE_PLUGIN_CACHE_DIR and granted read-write-exec in the sandbox), so each profile's plugins stay isolated from every other profile's.

Install and manage them with the agent's own tooling, run inside the sandbox so they land in this profile:

claudia -a claude exec -- claude plugin marketplace add your-org/plugins
claudia -a claude exec -- claude plugin install my-tool@your-plugins

Same for MCP servers, which are likewise the agent's own config:

claudia -a claude exec -- claude mcp add playwright npx @playwright/mcp@latest

A server that needs a credential, its own binary on PATH, or a host in the egress allowlist needs a little more than that. See MCP servers.

Memories

Each profile keeps a file-backed memory store that Claude reads and writes across sessions. Memories survive /clear, --resume, and new conversations — they're how the agent remembers user preferences, project decisions, and prior corrections instead of relearning them every session.

Memories have two scopes:

  • Project-local — under <profile>/memory/projects/<slug>/. Apply only to the current project.
  • Profile-global — under <profile>/memory/profile/. Apply to every project in the profile. New profile-globals can be seeded into every new profile via ~/.local/share/claudia/seed/memories/ (see Seeding profiles).

Both scopes live in claudia's own directory, not inside an agent's state tree, so every agent in the profile reads the same notes — which is what makes claudia -a codex a hand-off rather than a fresh start.

Preload merges both at session start; on filename collision, project-local wins.

claudia memory                # show the memory subcommands
claudia memory list           # list memories (project + profile, with scope tags)
claudia memory add --scope project  # draft a new project-local memory
claudia memory add --scope profile  # draft a new profile-global memory
claudia memory show <n>       # print one memory's body
claudia memory edit <n>       # open in $EDITOR (resolves project then profile)
claudia memory rm --scope project <n>  # delete a project memory
claudia memory rm --scope profile <n>  # delete a profile-global

A SessionStart hook (claudia memory preload) injects the full body of every memory file into context at the start of each session, so recall doesn't depend on the agent guessing relevance from a one-line index hook.

See Memories workflow for the file format, the four memory types (user / feedback / project / reference), the pre-written / git-tracked draft workflow, and tips for writing memories Claude actually retrieves.

Style rules

A memory tells the agent what you prefer. A rule refuses the write that ignores it — which is the difference between an instruction that gets outvoted over a long session and one that cannot be.

claudia rules list                     # what's in effect here
claudia rules add --scope profile      # draft a rule in $EDITOR
claudia rules test <file>              # dry run against a file on disk
claudia config set rules.mode warn     # downgrade every rule to a warning

Each rule is a regex, a target (code, comment, commit, prose) and the instruction to show when it fires. A PreToolUse hook (claudia rules check) matches every Write, Edit and git commit against them: severity: block refuses the call and feeds the rule text back as the reason, severity: warn allows it and says so.

The rules directory is granted read-only in the sandbox. Memory is read+write because an agent recording what it learned is the feature; a rule is a constraint on the agent, and one it could edit is one it could relax.

See Style rules workflow for the file format, the two scopes, when to block versus warn, and what regexes deliberately cannot express.

Secrets

For tokens, API keys, and credentials, use claudia secret. Unlike profile config env values (which the agent can read directly), secret values are kept out of the agent's environment entirely. The agent sees a placeholder (NPM_TOKEN=__claudia_secret_NPM_TOKEN__).

A secret reaches the network one of two ways:

  • Bound (recommended for HTTP credentials): the secret is tied to the origins it is used at, and claudia's egress proxy writes it into requests to those origins. The value never enters the sandbox, and no other host ever receives it.
  • Unbound: the shim substitutes the real value at execve time into a curated set of wrapped tools. That puts it inside the sandbox, in every wrapped tool's env.
claudia secret add --inject registry.npmjs.org=bearer NPM_TOKEN
claudia secret add --inject api.github.com=bearer \
                   --inject github.com=basic:x-access-token GITHUB_TOKEN
claudia secret add AWS_SECRET_ACCESS_KEY  # unbound: reaches wrapped tools via the shim
claudia secret get                     # list secret names (never values)
claudia secret get GITHUB_TOKEN        # list where it is injected
claudia secret rm NPM_TOKEN

Binding a secret

--inject ORIGIN=FORM (repeatable, before the key) or claudia secret bind KEY ORIGIN=FORM... ties a secret to where it is used. ORIGIN is [https://|http://]host[:port]; a bare host means https on 443. http:// explicitly opts into sending the credential in clear, for LAN services. Hosts are exact — no wildcards, since a suffix takes in hosts that serve other people's content. FORM is one of:

Form Header written
bearer Authorization: Bearer <secret>
token Authorization: token <secret>
basic:USER Authorization: Basic base64(USER:<secret>)
basic-user Authorization: Basic base64(<secret>:)
header:NAME NAME: <secret>

The proxy overwrites that header on every request to the origin, whatever the tool sent, so a tool only needs the placeholder (or nothing) configured. It never substitutes the placeholder anywhere else in a request.

Read a value from stdin instead of argv (avoids shell history capture):

pass show npm/token | claudia secret add NPM_TOKEN -

How it works

For a bound secret:

  1. At launch, claudia mints a per-session CA, name-constrained to the bound hosts, and points every TLS stack's trust variable (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, GIT_SSL_CAINFO, …) at it plus the host's roots.
  2. Requests to a bound origin go through claudia's egress proxy, which hands them to an injector in the broker process outside the sandbox. The injector terminates TLS with the session CA, checks the request really is for that origin (SNI and Host must match the CONNECT target), sets the header, and forwards it over a fresh TLS connection verified against the host's own roots. It returns redirects rather than following them, and replaces the secret with its placeholder in any response that echoes it.
  3. In open network mode the broker hosts that proxy itself on a loopback port; in allowlist mode it is the usual egress proxy, and a binding whose host the allowlist blocks is not injected. Either way the proxy requires a per-session credential, carried in HTTPS_PROXY, so other local processes cannot use it.
  4. The shim is never served a bound secret.

For an unbound secret:

  1. Values live in ~/.local/share/claudia/secrets/<profile>.json (mode 0600), outside any directory that's bind-mounted into the sandbox. The agent cannot read this file.
  2. At launch, claudia starts a broker process outside the sandbox holding the values in memory, and creates ~/.local/share/claudia/runtime/<profile>/<pid>-<rand>/ containing the broker's unix socket and one shims/<tool> symlink → the installed claudia-shim per wrapped tool. No plaintext snapshot is written to disk.
  3. The sandbox env gets a placeholder like NPM_TOKEN=__claudia_secret_NPM_TOKEN__. PATH is prefixed with the shims directory.
  4. When the agent runs npm, the shim intercepts, fetches the values from the broker over the socket, substitutes them into its own env, strips itself from PATH, and syscall.Execs the real npm. From that point npm sees the real token; nothing else does.

Wrapped tools: npm, npx, yarn, pnpm, bun, git, gh, aws, s4cmd, gcloud, gsutil, bq, curl. Shells (bash, sh, zsh) and interpreters (python, node, ruby, perl, java, go) are deliberately not wrapped — wrapping a shell would leak the real values into any command the agent runs through that shell, including printenv.

Trust model

Bound secrets are contained. No process in the sandbox ever holds the value, so neither the agent nor any tool it runs can read it, and the injector sends it only to its bound origins. What a bound secret still grants is its capability: the agent can do anything the token allows at those origins — publish a package, push a branch. Scope tokens narrowly. Tools that use their own compiled-in CA list (Java, some Rust tools) fail TLS to bound hosts; on macOS so do Go binaries such as gh, which ignore SSL_CERT_FILE there. Both fail closed. See docs/design/proxy-injected-secrets.md.

Unbound secrets are hardening, not containment. It prevents leakage into transcripts, agent self-inspection (process.env, /proc/self/environ, printenv), and normal tool output. What it buys concretely: no plaintext at rest, every access peer-authenticated so only the managed claudia-shim binary is served, and every access recorded in the audit log (claudia audit) from outside the sandbox, where the agent can neither forge nor suppress the record.

What it does not stop: an agent that can invoke a shimmed tool can still make that tool hand out its own secret — curl --data "$TOKEN" … substitutes the placeholder into the request, and the shim has no way to tell that apart from legitimate use. Authorizing a tool authorizes what it does with the secret it was given.

One platform caveat: with isolate_processes off, the broker is the session's own child under the same uid, so the agent can ptrace it. On the default Linux path the broker lives outside the session's PID namespace and that route is closed too.

How isolation works

Cross-platform

Path Access Purpose
/dev read-write device nodes (/dev/null, /dev/tty, etc.)
$PATH dirs under $HOME read-only user bin directories
~/.gitconfig, ~/.gitignore, ~/.npmrc, ~/.yarnrc, ~/.curlrc, ~/.wgetrc read-only tool config dotfiles
<profile>/<agent>/ read-write per-profile agent state (CLAUDE_CONFIG_DIR, CODEX_HOME)
<profile>/cache/ read-write per-profile cache (XDG_CACHE_HOME)
<profile>/plugins/ read-write per-profile plugins + content (CLAUDE_CODE_PLUGIN_CACHE_DIR)
<profile>/ read-write lockfiles
cwd and mounted paths configurable your project files

Everything else under $HOME — including ~/.ssh, ~/.aws, ~/.gnupg, and all other dotfiles and directories — is not accessible.

Environment variables are filtered to a safe allowlist (system, locale, toolchains, proxy settings). Host env vars like AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, etc. are not passed into the sandbox.

For non-secret env vars, use the profile config env key (claudia config set env.K V). Values set this way are visible to the agent.

For actual secrets (tokens, API keys, credentials), use claudia secret. The real value never enters the agent's environment — the agent sees a placeholder __claudia_secret_<NAME>__. A secret bound to its origins is written into requests by claudia's egress proxy, outside the sandbox; an unbound one is substituted at execve time by a small claudia-shim helper, only for a curated list of wrapped tools (npm, npx, yarn, pnpm, bun, git, gh, aws, s4cmd, gcloud, gsutil, bq, curl). See the Secrets section below.

Cross-platform defaults

Both backends apply the same policy: deny-default on $HOME, with explicit allow rules per profile. On Linux this is enforced by Landlock (kernel ≥ 5.13); on macOS by Seatbelt.

Path Access Purpose
/bin, /sbin, /lib, /lib64, /usr, /nix read+exec system tools and libraries
/etc, /proc, /sys, /run, /var read-only system config and runtime
/dev read+write PTY, /dev/null, /dev/urandom
/tmp read+write+exec scratch dir (real /tmp, observable on host)
$HOME (default) denied only explicit mounts and dotfiles allowed
$HOME/.gitconfig, $HOME/.ssh/config, ... read-only dotfiles tools need

macOS only

Uses Seatbelt (sandbox-exec) to apply a deny-default policy.

Path Access Purpose
Everything outside $HOME read-only system tools and libraries
~/Library/Caches read-write tool caches (go, npm, etc.)
/private/tmp read-write temporary files
/private/var/folders read-write system temporary directories
$HOME denied reads blocked except for allowlisted paths above

Dotfile security

The dotfiles listed above are passed into the sandbox read-only. Do not store secrets directly in these files. Instead, use environment variable references and pass the values through the profile config.

npm — use a token variable in ~/.npmrc:

//registry.npmjs.org/:_authToken=${NPM_TOKEN}

Then set the token as a secret:

claudia secret add --inject registry.npmjs.org=bearer NPM_TOKEN npm_abc123...

npm sends the placeholder, and claudia's egress proxy replaces the Authorization header on its way to the registry. The agent, npm and every other process in the sandbox only ever see NPM_TOKEN=__claudia_secret_NPM_TOKEN__ — even if the agent calls printenv in a Bash tool.

git — if your ~/.gitconfig includes credentials or tokens, move them to a credential helper or environment variable. The sandbox can read your gitconfig, so anything hardcoded there is visible inside the sandbox.

That same gitconfig usually sets commit.gpgsign, which cannot work in a sandbox where ~/.gnupg and ~/.ssh are blocked. See Git identity and SSH for the managed author and signing key that fixes it, and gives git push over SSH a key to use.

curl / wget — avoid storing credentials in ~/.curlrc or ~/.wgetrc. Bind the secret to the API's origin instead: then a plain curl https://api.example.com/... is authenticated by the proxy, and the token is never on curl's command line or in its env.

As a general rule: if a dotfile supports ${VAR} substitution, prefer that over hardcoded secrets, and set the variable with claudia secret rather than claudia config set env.* so the value is never visible to the agent.

Pruning

Agents leave cruft behind: transcripts, file-history and shell snapshots in their state dir, tool caches in the profile, screenshots in the repo. claudia prune reports it, and removes it with --yes. It also cleans up what claudia itself leaves behind: proxy dirs and state files of exited sessions, and the dirs of removed profiles.

claudia prune                        # dry run: per-rule counts and sizes
claudia prune --list                 # the exact paths --yes would remove
claudia prune --all --yes            # every profile, for real

What counts as cruft is data. Each agent card's prune rules cover that agent's state dir. The profile's seeded prune list covers its cache, debug logs, scratch dir and the projects it launched in. A rule removes what it matches only once nothing under it has changed for its age, so a running session's files are left alone. See Pruning.

Data

Everything lives in ~/.local/share/claudia/ (respects XDG_DATA_HOME):

cards/                      # one JSON file per agent — what an agent IS
  claude.json               # dir env names, agent env, egress hosts, argv, owned keys
  codex.json
  pi.json
seed/                       # user-owned source-of-truth for tunable defaults
  memories/                 # profile-global memory seed (*.md, copied on init)
  agents/<agent>/           # copied verbatim into a NEW agent state dir
    settings.json           # product opinions; yours to edit, yours to delete
    CLAUDE.md
    statusline.sh
  profile/
    env-host                # gitignore-style list, merged into config.env_host
    exec-dirs               # gitignore-style list, merged into config.exec_dirs
    read-paths              # gitignore-style list, merged into config.read_paths
    ancestor-files          # gitignore-style list, merged into config.ancestor_files
    shim                    # gitignore-style list, merged into config.shim
profiles.json               # profile registry
profiles/<name>.json        # profile config — NOT reachable from the sandbox
profiles/<name>/            # per-purpose grants into the sandbox
  cache/                    # XDG_CACHE_HOME (per-profile, shared by its agents)
  plugins/                  # per-profile plugin cache
  debug-logs/               # agent debug logs (read+write, no exec)
  scratch/                  # cwd of a `-t` launch, which mounts no project dir
  memory/                   # claudia's memories (read+write, no exec)
    profile/                #   profile-global, preloaded in full
    projects/<slug>/        #   scoped to one working directory
  git/                      # managed gitconfig, allowed_signers, signing .pub (read-only)
agents/<profile>/<agent>/   # per-agent state dir (CLAUDE_CONFIG_DIR, CODEX_HOME, ...)
sessions/index.jsonl        # what claudia has launched; backs `claudia resume`
sessions/<pid>/state.json   # live session state (the only part mounted, read-write)
audit/<name>/log.jsonl      # per-profile audit log (mounted; sibling dirs not reachable)
secrets/<name>.json         # per-profile secret store (NOT mounted; mode 0600)
keys/<name>/signing_ed25519 # signing/auth private key (NOT mounted in agent mode)
runtime/<name>/<pid>-<rand>/ # per-instance broker socket + shim symlinks
  broker.sock               # broker socket; values stay in broker memory
  shims/                    # symlinks to claudia-shim, one per wrapped tool
runtime/<name>/<pid>-agent/ # ssh-agent socket, git.signing = agent only

The directories under profiles/<name>/ are granted individually, each at the narrowest access that works — memory and debug-logs get no exec, the git dir is read-only. profiles/<name>.json is granted nothing at all: it holds the profile's security posture, so a sandboxed agent able to write it could set its own egress allowlist and mounts for the next launch. Agent homes live under agents/ for the same reason — the lock-by-mkdir their parent needs is safe only over a directory that holds nothing else. seed/ is the user-owned source-of-truth for tunable defaults — claudia profile create materializes starter files there from Go constants the first time around, then treats anything you've edited as authoritative on every subsequent init. The secrets/ and runtime/ siblings are deliberately outside the bind-mounted profile subtree: secrets/ is never mounted, and runtime/<name>/<pid>-<rand>/ is bind-mounted read-only via a separate explicit mount so only the shim — not the agent's other code paths — has a natural reason to reach it.

See Seeding profiles for what each seed file controls and how to back up / restore your tunable defaults.

Config

Profile config lives at <profile>/config.json. Edit with claudia edit config.

{
  "paths": [
    "/home/user/dev/project:rw",
    "/home/user/data:ro"
  ],
  "cache_dirs": [
    "~/.cache/go-build",
    "~/.cache/gopls",
    "~/.cache/npm",
    "~/.cache/bun",
    "~/.cache/uv",
    "~/.cache/pip",
    "~/.cache/nix",
    "~/.cache/node",
    "~/.cache/direnv"
  ],
  "exec_dirs": [
    "~/.cargo/bin:path",
    "/opt/homebrew/bin:path",
    "~/.local/share/mise"
  ],
  "env": {
    "NODE_ENV": "development"
  }
}

The env map is for non-secret environment variables — things visible to the agent, like NODE_ENV or AWS_PROFILE. For tokens and credentials, use claudia secret instead so values never appear in the agent's view. See the Secrets section.

exec_dirs grants subtrees read+exec, so tools installed outside the default system paths are runnable. An entry with a :path suffix also joins the sandbox PATH; one without is granted but not put on PATH.

That split is the point. A bin directory wants both (~/.cargo/bin:path). A toolchain store root wants only the grant — Go execs <GOROOT>/pkg/tool/*/compile, which lives outside any bin dir, so ~/.local/share/mise has to be readable and executable without a store root landing on PATH.

Dirs that do join PATH sit after the shim dir but before the host PATH, so they can override problematic host entries while never shadowing a secret-bearing tool's wrapper. PATH itself is protected and cannot be overridden via env.

Manage the list with claudia config add exec-dirs <dir>[:path] and claudia config rm exec-dirs <dir>[:path].

read_paths grants files and subtrees read-only — tool configs outside any project — and ancestor_files grants named files read-only wherever they exist above the launch dir, each as that one file: the pins a version manager finds by walking up.

All three ship empty. claudia does not assume a version manager; grant yours — mise, asdf, moor — as Version managers shows.