Christian Lehnert — Linux, Hacking & Faith

tank-os-with-llms - The Hardened Container I Run Claude Code and Gemini CLI In

Christian Lehnert2026-06-27~10 min read

tank-os-with-llms

The Question That Was Bothering Me

In June I wrote about Sally O'Malley's tank-os, the Fedora bootc
image for running AI agents as rootless Podman workloads. That
post made the case for the architecture as the cleanest available
demonstration of what an agent host should look like in 2026.
What it did not answer is the question I cared about for my own
setup: how do I actually run Claude Code and Gemini CLI on my
own machine, today, without setting up a dedicated bootc host
just to do it.

The threat model that motivates the question is concrete. I have
a laptop that talks to internal services through a corporate VPN.
I have access to internal Git servers, internal monitoring
dashboards, the cloud metadata endpoint of various cloud
accounts, and services on my own loopback. If I install Claude
Code or Gemini CLI directly on the laptop, the agent inherits all
of this. A prompt injection in a tool result, a compromised npm
dependency, a model error, any of these could turn the agent into
a lateral-movement vector with my full network reach.

The default deployment for these tools does nothing to address
this. npm install -g @anthropic-ai/claude-code followed by
claude in a terminal produces a process that shares the host's
network namespace, the host's filesystem access, and the host's
authentication tokens. The agent is, by construction, as
dangerous as my own user account.

So I built tank-os-with-llms. It is a Docker Compose
configuration that wraps Sally's tank-os image, installs Claude
Code and Gemini CLI on top, and enforces a default-deny network
perimeter through iptables before dropping to an unprivileged
user. The whole thing is approximately fifteen kilobytes of
configuration and an hour of setup. This post is the writeup of
how it works and why each piece earns its place.

What Is in the Container

The Dockerfile starts from quay.io/sallyom/tank-os:latest,
which is Sally's published tank-os image. From there I install
Node.js and npm (the agents are npm packages), common development
tools (git, curl, jq, ripgrep, gcc, python3-pip, openssh-clients,
tmux), Podman and podman-compose for nested rootless container
execution, and the two agents themselves:

npm install -g @anthropic-ai/claude-code
npm install -g @google/gemini-cli

I also restore file capabilities on newuidmap and newgidmap
because tank-os strips setuid bits as a hardening measure, but
rootless Podman needs those two helpers to write UID and GID maps
into user namespaces. The replacement is setcap cap_setuid+ep
and setcap cap_setgid+ep, which is narrower than full setuid
root: the helper gets exactly the privilege it needs and nothing
more.

The Podman configuration uses fuse-overlayfs as the storage
driver, which is the right choice on a read-only root filesystem.
The default overlay driver requires kernel-level overlay mounts
that do not compose with the read-only root.

The bashrc that ships with the image aliases docker to podman
and docker-compose to podman-compose. The agent calling
docker build actually calls podman build. This is deliberate:
it lets the agent use familiar Docker workflows while the
underlying runtime is the rootless Podman I have hardened for.

The Network Perimeter

The docker-compose.yml puts the container on a dedicated bridge
network at 172.28.0.0/16, separate from the default Docker
bridge. The container has its own routing table. The host's
network interfaces are not directly reachable from the container.
The container's view of the world begins at the Docker gateway.

The entrypoint script then runs as root long enough to configure
iptables with a default-deny OUTPUT policy. This is the
substantive security boundary, so it is worth reading in detail:

 1iptables -P OUTPUT DROP
 2 
 3iptables -A OUTPUT -o lo -j ACCEPT
 4iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
 5 
 6GATEWAY_V4=$(ip -4 route show default | awk '{print $3; exit}')
 7[ -n "${GATEWAY_V4:-}" ] && iptables -A OUTPUT -d "$GATEWAY_V4" -j ACCEPT
 8 
 9iptables -A OUTPUT -d 10.0.0.0/8     -j DROP
10iptables -A OUTPUT -d 172.16.0.0/12  -j DROP
11iptables -A OUTPUT -d 192.168.0.0/16 -j DROP
12iptables -A OUTPUT -d 169.254.0.0/16 -j DROP
13iptables -A OUTPUT -d 127.0.0.0/8    -j DROP
14iptables -A OUTPUT -d 224.0.0.0/4    -j DROP
15iptables -A OUTPUT -d 240.0.0.0/4    -j DROP
16 
17iptables -A OUTPUT -j ACCEPT

The structure is: default-deny everything, then explicitly allow
loopback (the container's own lo, not the host's), allow
established connections (otherwise inbound replies to outbound
requests get dropped), allow traffic to the Docker gateway IP
(which is how the container reaches the public internet), then
drop the categories I deliberately do not want the agent to
reach, then accept everything else.

The drop rules look redundant given the default-deny policy. They
are deliberately belt-and-suspenders. If a future rule addition
inadvertently allows broader access, the explicit drops still
block the categories that matter. The drops also document the
threat model inline. Anyone reading the script can see exactly
which network categories I have decided the agent should not be
able to reach.

The categories worth naming:

  • 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 are the RFC
    1918 private ranges. These constitute most corporate intranets.
    An agent that cannot route into them cannot reach the company
    Git server, the internal package mirror, the developer
    dashboard, any of the services that make a corporate network
    worth being on.
  • 169.254.0.0/16 is the link-local range. It includes
    169.254.169.254, which is the cloud metadata endpoint on AWS,
    GCP, and Azure. An agent running on a developer laptop should
    not be reaching the metadata endpoint of any cloud account that
    laptop has credentials for. Dropping link-local closes that
    path entirely.
  • 127.0.0.0/8 is loopback to the host. The container has its
    own loopback through the first ACCEPT rule, but the agent
    attempting to reach 127.0.0.1:something on the host is
    blocked.
  • 224.0.0.0/4 and 240.0.0.0/4 are multicast and reserved
    ranges. No legitimate outbound agent traffic uses these.
    The same structure runs for IPv6 with ip6tables. Dropping
    fc00::/7 (unique local), fe80::/10 (link-local), ::1/128
    (loopback), and ff00::/8 (multicast). Network isolation that
    handles only IPv4 in 2026 is broken in ways the operator may not
    notice. I learned this the hard way on an earlier configuration
    where the agent's IPv6 path was wide open while IPv4 was tight.

A separate concern, handled at the Docker level rather than via
iptables, is DNS. The docker-compose.yml forces
dns: 8.8.8.8, 1.1.1.1. The agent cannot use the host's
configured DNS server, which would otherwise resolve internal
private names. Public DNS only, by configuration. An agent that
asks for an internal hostname gets NXDOMAIN regardless of whether
the iptables rules would have allowed the connection.

Process Isolation

Once the iptables rules are installed, the entrypoint uses
setpriv to drop to UID 1000 (the openclaw user inherited from
tank-os) before execing the actual container command. Root is
held only as long as needed to configure the firewall. The
agents themselves never run as root. A compromise that happens
inside the agent's process tree starts from openclaw, which has
no sudo, no membership in privileged groups, and no path to
modify the OS layer of the container.

The home directory at /var/home/openclaw is a Docker named
volume, which means it persists across container restarts. This
is where the agents' configuration lives, where the npm cache
goes, where my history accumulates. The host filesystem outside
of /workspace is not reachable.

The /workspace directory is bind-mounted from the host's
./workspace. This is the explicit data-sharing surface.
Anything I put there is visible to the agent. Anything outside
there is not. The boundary is my decision, in a place I can see.

/tmp and /run are tmpfs mounts with noexec,nosuid. The
agent can write to them for its own temporary state, but it
cannot execute anything from them and cannot rely on any setuid
helpers existing there.

The Pragmatic Trade-offs

Two design choices in the configuration are pragmatic rather than
purist, and they are worth naming because the purist position
would produce a container that does not work for my actual use
case.

The container runs privileged: true in the compose file. Docker
hardcodes a restriction on proc mounts inside user namespaces
that cannot be bypassed through capabilities or seccomp profiles.
Rootless Podman inside the container needs unrestricted proc
access to function. Without privileged, the nested-rootless-
container capability does not work. With it, the network
perimeter and the unprivileged userspace remain effective, which
are the controls that actually bind. The privileged flag affects
what happens inside the container, but my network reach is still
constrained by iptables and my identity inside is still openclaw,
not root.

The container ships systemd in the image because tank-os does,
but the compose runtime overrides the CMD to sleep infinity.
The container does not actually boot systemd. It stays alive
waiting for docker compose exec sessions. This dual-mode design
(/sbin/init as the bootable default, sleep infinity as the
docker-friendly override) lets the image work both as a real
bootable host and as a development container without forking the
image. I appreciated this when I read the original tank-os and I
appreciate it more now that I have built on top of it.

What Is Still Missing

The configuration is not complete in three specific ways. Worth
naming explicitly so future readers (and future me) know what is
still on the list.

The filesystem isolation is one-directional. Anything I place in
/workspace is readable and writable by the agent. If I put an
SSH key, an API token, or a sensitive document there, I have
handed it to the agent. The protection is for the host filesystem
outside /workspace. Inside /workspace, the agent is trusted
by construction. I treat the workspace as agent-accessible by
definition and do not put credentials there.

The agent can still reach the public internet. The network
perimeter blocks lateral movement to the corporate intranet, but
not the internet at large. An agent that decides to exfiltrate
the workspace to a public endpoint can do so. A more aggressive
configuration would whitelist specific outbound endpoints
(api.anthropic.com, registry.npmjs.org, github.com) and
drop the rest. I have not built that layer because the agent
genuinely needs many public services in normal operation and the
whitelist maintenance burden is real. For workloads more
sensitive than mine, the additional layer is worth adding.

The API tokens are passed through as environment variables
(ANTHROPIC_API_KEY, GEMINI_API_KEY). The agent sees them in
plaintext. The right architecture would put a token broker in
between, similar to service-gator in the original tank-os, so
the agent talks to a local gateway that holds the actual
credentials and enforces scope. I have not built that yet. It is
the natural next extension and probably the most consequential
improvement still to make.

Closing

The reason I built this is the same reason Sally built tank-os.
The agents are useful tools and they are also unconstrained
tools. Running them with no security perimeter on a machine that
has access to anything sensitive is the agent-infrastructure
equivalent of running services as root. The cost of building the
perimeter is small. The cost of not building it is whatever the
agent decides to do with the access it inherited.

The whole repository is approximately fifteen kilobytes. The
Dockerfile is eighty lines. The entrypoint is fifty lines. The
compose file is fifty lines. An afternoon of setup. The
properties it gives me: the agents I want to use work normally,
the corporate VPN is unreachable from inside the container, the
cloud metadata endpoint is unreachable, the host's loopback
services are unreachable, internal hostnames do not resolve, the
agent runs as an unprivileged user, the OS layer is read-only,
and the explicit data-sharing surface is the workspace directory
I bind-mounted.

I run my agents in this container exclusively now. The friction
is essentially zero once the container is up (make shell opens
a bash session, claude and gemini are on the PATH, the
workspace is at /workspace), and the defense in depth is
substantial relative to the alternative of npm install -g on
the host.

If you are running coding agents on a machine that also has
access to anything you would prefer the agent not to reach, the
patterns here are worth at least an afternoon of your time. The
base image at quay.io/sallyom/tank-os:latest is the same one
Sally publishes. The rest is configuration. Build it. Read what
it does. Adapt the iptables rules to your specific threat model.
The agents are not going to police themselves.

Tagged:
#docker #podman #security
← Back to posts