Claude Code + Apple Container sandbox
This setup runs Claude Code inside Apple's container runtime, so that
the agent can only see the project directory it was started in — not the
rest of the host's home directory.
The intended boundary is:
macOS
|
+-- $PWD (if allow-listed) -- RW --> /workspace
|
+-- ~/.claude -------------- RW --> /home/kiko/.claude
|
+-- everything else in $HOME
|
+------ NOT MOUNTED
Claude's configuration deliberately stays on the host in ~/.claude,
so host-side tooling (e.g. AgentsView)
can keep reading sessions, history and settings while the agent
itself runs in the container.
Apple's container needs a Mac with Apple silicon running macOS 26
(Tahoe) or later. Homebrew has it as a regular formula (not a cask):
brew install container
container system kernel set --recommended
brew services start container
brew services start runs container system start as a keep-alive
launchd service, so the runtime also comes back after a reboot. Because
it runs non-interactively, install the Linux kernel first: started by
hand, container system start would otherwise prompt for it
interactively.
Check that the service is up:
container system status
container list --all
The second command should print an empty table. Logs go to
$(brew --prefix)/var/log/container.log. To shut the runtime down
again, run brew services stop container.
Apple itself distributes container as a signed installer package from
the GitHub releases page;
use that instead if you prefer not to go through Homebrew.
From this directory run:
container build -t kiko
This builds the image kiko:latest.
From the project directory that Claude should work in:
/path/to/this/repo/run.sh
Claude starts with:
/workspace = the current working directory
~/.claude = the host's ~/.claude
The current directory is only mounted if it lies inside one of the allowed directories (see section 5).
Arguments are passed straight to claude, which is the image's
entrypoint:
./run.sh --help
./run.sh -p 'summarise this repository'
It can be useful to add a shell alias so Claude can be started
as claude:
alias claude "/path/to/this/repo/run.sh"
Sometimes Claude needs to see more than the current project — sibling
repositories, shared documentation. run.sh maps additional host
directories into the workspace with --dir:
./run.sh --dir ~/work/ops-docs
Each directory shows up as /workspace/<basename>, so the example
above becomes /workspace/ops-docs. Extra directories are mounted
read-only by default; append :rw for a writable one:
./run.sh --dir ~/work/ops-docs:rw
The option can be repeated and also takes a comma-separated list, and
relative paths stay relative to the directory run.sh is started in:
./run.sh --dir ../foo --dir ../bar,../scratch
For a fixed set of directories, keep the invocation in a shell alias or
a small wrapper script per project. There is deliberately no config file
that run.sh picks up on its own: what Claude can see should always be
visible in the command that started it.
Everything run.sh does not recognise is passed through to claude
unchanged, so ./run.sh --dir ../foo -p 'compare both repos' works.
Use -- if a Claude argument would otherwise be mistaken for one of the
options above, and ./run.sh --sandbox-help to list them.
Two things to keep in mind:
- The mount target is derived from the basename, so two directories
with the same name cannot both be mapped.
run.shfails with an error instead of silently mounting only one of them. - The extra directories appear inside
/workspace, which is the project's own git checkout, sogit statusin the container will list them as untracked. Adding them to the project's.gitignore(or.git/info/exclude) keeps that quiet.
Mounting $PWD is only safe as long as $PWD is a project directory.
Started from ~ — or from ~/Documents, or / — the same command
would hand the whole tree to the agent. To make that mistake loud
instead of silent, run.sh checks the current directory against an
allow-list before mounting it:
KIKO_ALLOWED_DIRS='~/work/'
The variable holds a space-separated list of directories; the current
directory has to be one of them or live below one. The default is
~/work/. Widen it in the shell profile if projects live elsewhere:
export KIKO_ALLOWED_DIRS='~/work/ ~/projects/'
If the current directory is outside all of them, run.sh prints a
warning and starts the container without mounting it. /workspace
is then the empty directory from the image, so Claude comes up with
nothing of the host to look at rather than with the wrong thing:
run.sh: warning: /Users/me is not inside any of the allowed directories (~/work/)
run.sh: warning: not mounting it as /workspace; set $KIKO_ALLOWED_DIRS to allow it
Entries that do not exist are ignored, and an empty
KIKO_ALLOWED_DIRS allows nothing at all. The check only covers the
current directory — directories added with --dir (section 4) are
explicit by nature and are mounted as asked.
Base is ubuntu:26.04 with a non-root user kiko:
curl,git,jq,ripgrepuvand Python 3.14uv-installed tools:lefthook,ruff,tox,yq- Claude Code, installed via
https://claude.ai/install.sh
~/.claude.json is symlinked to ~/.claude/claude.json so that this
file also lives in the host-mounted configuration directory instead of
in the container's home. This way the initial setup only needs to be
run once.
run.sh runs the container with:
--cap-drop ALL— no Linux capabilities--dns 1.1.1.1— fixed resolver, independent of host DNS--init— proper PID 1 for signal handling and reaping--rm— no container state kept between runs--interactive --tty— interactive Claude session--name <directory>— named after the directory it was started in, socontainer lstells several sessions apart. The name is deliberately not made unique: a second run from the same directory is refused by the runtime rather than quietly started alongside the first.- two bind mounts by default: the workspace (unless the current
directory failed the check in section 5) and
~/.claude, plus any directory added with--dir(see section 4), read-only unless:rwwas requested
Inside the container, these should fail or be absent:
ls /Users
ls /Volumes
These should exist:
ls /workspace
ls ~/.claude
Do not extend the run command to mount:
$HOME
~/.ssh
~/.kube
~/.config
~/Library
The workspace and ~/.claude are the only host directories exposed by
default. Directories added with --dir widen that boundary
deliberately and one at a time, which is the point: keep them as
narrow as the task needs, and leave them read-only unless Claude
really has to write there. --dir ~ defeats the whole setup.
Note that ~/.claude is mounted read-write: this is what makes
host-side session tooling work, but it also means the agent can write to
its own configuration. That is an accepted trade-off here.
There is currently no SSH integration. The container has no access to
host SSH keys or the host ssh-agent, so git operations against
private remotes will not work from inside the sandbox.
This configuration intentionally does not claim to restrict network egress. Claude Code needs network access to Anthropic, and additional tools may need it too.
If stronger network isolation is required, add a network policy at the Apple Container/VM layer rather than assuming filesystem isolation also restricts networking.