After initially playing around with the new sandbox-ish container, I wanted to be able to pass some directories as read-only context, along the lines of:
claude-container --ro ~/reference ~/notes -p 'Read /mnt/reference, write your findings to /workspace/out.md'
That is to read one tree but only be able to write another, in one invocation. This allows for more realistic operations and avoids having to worry about prompt-level guardrails on what to touch and not touch.
The last three posts tightened the implementation in one direction at a time: first the filesystem isolation, then the network firewalling via Squid proxy, then the ability to run it without sitting in front of it ('headless'). All three treated the inside of the container as a single uniform trust zone, where anything the agent could touch from the code to its own configuration was just a workspace element.
In this post I am going to dismantle that in favour of a more useful one: with file privileges, mounts, and add some new flags to pass to Claude Code along the way.
1. Privileges
Post 2 ended with a list of things I had not done so far:
No capability drops, no
no-new-privileges, no pids or memory limits.
Reviewing the hardened bclaude implementation, I noticed I could pick up the following settings
for compose.yml:
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
pids_limit: 2048
Nothing in the workspace runs as root, so there is nothing for a capability to grant.
pids_limit avoids fork bombs, a denial-of-service an agent can reach by
accident rather than by malice, and would bring the host to a grinding halt.
The image drops setuid and setgid bits wholesale:
RUN find / -xdev -perm /6000 -type f -exec chmod ug-s {} + 2>/dev/null || true
In its current formulation, this line is inert under cap_drop: ALL.
In post 2 I worked out that claude-container is a fork of Anthropic's reference devcontainer
with init-firewall.sh removed, and that sudo had been retained as a dependency of the script
that was deleted. It has now gone from the image, and no-new-privileges would have made it inert regardless.
Likewise the iptables and aggregate installs from apt went with it.
The fork has now finally stopped carrying around tools for a firewall it does not have.
Memory limits are an interesting one here, because they are not expressible in compose.yml.
Rootless podman does not ignore mem_limit when the host has not delegated the memory
controller, it simply fails the run. We probe them imperatively instead:
cgroup_limits_available() {
[[ -f /sys/fs/cgroup/cgroup.controllers ]] || return 1 # not cgroups v2
[[ $(id -u) -eq 0 ]] && return 0 # rootful: always enforceable
local f="/sys/fs/cgroup/user.slice/user-$(id -u).slice/cgroup.controllers"
[[ -r "$f" ]] || return 0 # unknown layout: assume it works
grep -qw memory "$f"
}
and emit the limits into a generated file when the answer is yes, warning when it is no.
2. Mounts
The first directory passed to claude-container is the workspace, but we now accept further directories
to mount alongside it at /mnt/<name>, each of which can be writable or read-only.
| Argument | Mount | Mode |
|---|---|---|
| first positional | /workspace |
rw — also the compose build context |
| further positionals | /mnt/<name> |
rw |
--ro DIR (repeatable) |
/mnt/<name> |
ro |
--ro-workspace |
/workspace |
ro (modifies the positional /workspace to be read-only) |
| --> |
The design problem is that compose has no syntax for a variable-length volume list. You cannot write "and then however many directories the user named". So the mounts are generated into a tempfile that goes last in the compose stack, alongside the memory limits from the previous section:
services:
claude-auth-workspace:
volumes:
- /home/louis/repo:/workspace:ro
- /home/louis/reference:/mnt/reference:ro
- /home/louis/notes:/mnt/notes:rw
mem_limit: 4g
memswap_limit: 4g
This is generated rather than declared for the same reason in both cases: the file has to depend on something compose cannot see.
Two minor decisions made here were:
z, never Z. Under SELinux an unlabelled bind mount is unreadable from inside the
container. The z option applies the shared label; Z applies a private one by
relabelling the host directory in place. That is a persistent modification to your
filesystem, which is precisely the side effect a read-only mount exists to prevent. Z
appears nowhere in the script.
Basename collisions are an error. Everything lands at /mnt/<basename>, so
--ro ~/work/utils --ro ~/personal/utils would silently give you one of them. The
runner refuses and prints both paths.
Two layers
The part I had not appreciated before building it is that a :ro mount is not sufficient, and
neither is the agent-level permission.
:ro is the kernel boundary, enforced at the VFS layer in the container's mount
namespace, and writes return EROFS. It applies equally against anything the agent spawns,
subprocesses, build tools, an npm postinstall, etc. It is also worth saying that
read-only is a property of the mount, not of your files. Host permissions,
ownership and mtimes are untouched, and your editor keeps writing to that directory
while the container runs.
--add-dir on the other hand is an agent-level permission in Claude Code.
CC restricts its tools (like bash and file operations) to the working directory tree,
so without it a /mnt/reference mount is effectively invisible; present in the filesystem,
yet absent from the agent. The claude-container runner passes it automatically for every extra mount.
The two options serve different purposes. --add-dir gives Claude Code access to a mounted directory,
while :ro makes that directory read-only for everything running inside the container.
If you want Claude to be able to read a directory without being able to modify it, you'd use both.
--add-dir controls access for Claude Code; :ro provides actual write protection at the filesystem level.
A read-only mount can still be an exfiltration site
To state the obvious, while a read-only mount stops writes, it does nothing about reads, from which the exfiltration half of the threat model I started this series with arises.
If you mount ~/code read-only you must be aware that you've put every .env, .npmrc, .netrc and stray
*.pem underneath it in scope for the agent (and anything it runs).
Under -n, with unrestricted egress through the gateway, that is a live path out. Mount the
specific repo, never the parent.
verify-egress now says so out loud. It probes each mount for writability rather than
trusting the configuration, and scans the mounted trees for secret-shaped filenames:
--- mount modes (empirical: what the agent can actually do) ---
/workspace not writable
/mnt/notes writable
/mnt/reference not writable
--- secret-shaped files in mounted trees ---
! /mnt/reference/.env
^ readable regardless of the mount being read-only
A writable mount and a mounted .env may well be what you asked for but the point is to make you
aware of them in case not.
3. Flags
This one was not on the plan, and arrived more out of necessity for the mounts, and once the read-only flags were going in I felt I may as well add some more.
The runner used to take one flag of Claude's (-p) and pass a prompt. Anything it did
not recognise fell through into the positional arguments and was treated as a directory.
That was fine when there was one directory, but once there were several it was time to
solidify this a bit.
The runner now recognises thirteen Claude Code flags explicitly: -p/--print,
-c/--continue, -r/--resume [ID], --fork-session, --model, --effort,
--append-system-prompt, --output-format, --json-schema, --debug, --cloud,
--teleport, --rc, and everything else reaches claude verbatim that's passed after --.
An unknown flag before -- is now an error, rather than a directory thought to be called --max-turns.
The awkward corner case is optional-value flags. -r takes an optional session id, and
directories are positional here, so:
has_opt_val() {
[[ -n "${1:-}" && "$1" != -* && ! -d "$1" ]]
}
In a better world I would do this with a fancy CLI (figue! usage-rs! shiny!) but the thought of compiling and/or interpreting this with anything besides a shell made me hesitant for something I am not trying to be slowed down by.
A token that names an existing directory is a directory, not a value. That keeps
-r ~/repo (the picker, on a directory) apart from -r abc123 (resume by id). It is a
heuristic, but I'd rather have one that's written down than an undocumented runtime ambiguity.
Two collisions to know about here are -n, the container's, not Claude's, which Claude Code uses
for --name, so use -- --name foo, and --cloud, --teleport and --rc all
need claude.ai, which the restricted allowlist drops once you comment out the
anthropic_auth lines after login. The runner warns when you combine them. I will likely get these
up and running shortly. --rc may also want inbound connectivity, which internal: true forbids by construction, and
that one is not a warning but by design.
While I was tinkering in there, the entrypoint moved. Whereas it used to live in the Dockerfile's CMD,
with compose.headless.yml overriding it to add -p, now compose.yml carries
entrypoint: ["claude", "--dangerously-skip-permissions"] and the headless override
only drops the TTY, so both modes share one command line and the flags append to it. The
CMD stays in the Dockerfile as a sane default for a bare podman run.
Demo time
Change the content of the test-dir file to the next letter in the alphabet. Show your work

I started claude-container with the --ro-workspace flag this time, and sure enough got an EROFS
error (read-only file system).
An analysis run that provably cannot modify anything, on a network that permits one hostname, with no TTY and nobody watching. The one I opened with, which now works:
claude-container --ro ~/reference ~/notes -p 'Read /mnt/reference, write findings to /workspace/out.md'
bat test-dir/a.txt
a
bat test-dir-2/b.txt
<EMPTY>
./claude-container --ro test-dir test-dir-2 -p "Read the letter in the file in test-dir and write the next letter in the alphabet to test-dir-2's file."
/mnt/test-dir/a.txtcontainsa, so I wroteb.One thing to flag: there is no directory named
test-dir-2anywhere on the filesystem — I searched. The only other working directory is/workspace, which contained a single emptyb.txt, so I took that as the intended target and wrotebto/workspace/b.txt. Nothing was overwritten (the file was 0 bytes).If you actually meant a separate
/mnt/test-dir-2/, say the word and I'll create it there instead. egress log appended to /home/louis/.local/state/claude-container/egress.log
bat test-dir-2/b.txt
b
Extension work
The third split was meant to be the configuration: mount ~/.claude-container
read-only so the agent cannot write its own settings.json, with the OAuth token in a
separate writable volume. Post 2 listed the reason as a known limit:
settings.jsoncan hold hooks, which are shell commands. The container is not disposable while that mount is writable.
and this is still true.
It is not built because what I initially assumed lived here was wrong. CLAUDE_CONFIG_DIR does not hold
credentials and settings, but sessions, history, todos and shell snapshots, all of
which Claude Code writes continuously during a run. Mounting it read-only does not
harden the container but rather would break it. The split that works is finer-grained than
"credentials writable, everything else read-only", and changes the login path, so it
would be something to ship separately rather than bolt onto this.
One more limit found while writing this. The compose project name is derived from the runner's own directory, not from the invocation, so two sessions at once share a gateway — the first to exit tears down the other's proxy and files its traffic under the wrong session in the log. A per-invocation project name fixes it. Also not done yet, but also not something I was immediately able to reproduce, so looks like more of a hidden footgun than a barrier to concurrent usage altogether (yay).
Where this leaves the box
So all in all now we have three splits with one common idea. The container was protecting the host from the agent; nothing was protecting the agent's own inputs from the agent.
Privileges are split into what the process has and what it could acquire. Directories are split into what it reads and what it writes. Configuration is the one still unsplit, and is harder than it might look at a glance.
What is still uniform: /workspace is all-or-nothing writable, with no per-path policy
inside it. The egress allowlist does not vary by mode, even though a --ro-workspace
review session arguably needs less network than a build does. And a read-only mount is
still a fully readable one, which the secret scan reports but does nothing about.
The repo is currently closed source in development but will be open sourced at lmmx/claude-container.