Reading rights

Splitting what Claude can read from what it can write

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

Read-only workspace

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.txt contains a, so I wrote b.

One thing to flag: there is no directory named test-dir-2 anywhere on the filesystem — I searched. The only other working directory is /workspace, which contained a single empty b.txt, so I took that as the intended target and wrote b to /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.json can 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.