container: Use rootless Podman instead of Docker

Access to the Docker socket is equivalent to host root: whoever can talk
to /var/run/docker.sock can start a privileged container with the host
filesystem mounted and edit any file on it. Membership of the docker
group defeats the containment this tool exists to provide, because an
agent escapes the project directory by asking the daemon, not by
exploiting a bug. Rootless Podman keeps the workflow and drops the
escape: containers run in a user namespace owned by the invoking user,
and container root maps to a subuid from /etc/subuid instead of to host
root.

Rootless containers are created with --userns=keep-id so that the host
UID/GID map to the same values inside the container and bind-mounted
files keep their ownership in both directions; without it the container
UID 1000 would own nothing in the project. Sudo inside the container
keeps working, but only reaches container root.

Docker stays available through AGENT_CONTAINER_RUNTIME=docker for hosts
that cannot run rootless Podman. Existing Docker containers and images
are deliberately left alone: they are not reused, and the first Podman
run builds its own image.
This commit is contained in:
Jeena 2026-10-29 02:13:31 +09:00
parent aff70f009e
commit ed1c9a7b92
2 changed files with 104 additions and 25 deletions

View file

@ -1,9 +1,10 @@
# agent-container # agent-container
Run OpenCode, Claude Code, and Pi (pi.dev) inside a shared Arch Linux Docker container that closely mirrors a local development environment, while limiting access to sensitive files on the host. Run OpenCode, Claude Code, and Pi (pi.dev) inside a shared Arch Linux Podman container that closely mirrors a local development environment, while limiting access to sensitive files on the host.
## Features ## Features
- **Rootless**: Containers run as your own user inside a user namespace via rootless Podman. No privileged daemon, no `docker` group, nothing that turns a compromised agent into host root (see "Container runtime" below)
- Tools are not baked into the image: OpenCode, Claude Code, and Pi are - Tools are not baked into the image: OpenCode, Claude Code, and Pi are
bootstrapped into the persistent container home on first use (official bootstrapped into the persistent container home on first use (official
installers, no sudo), so `pi update`, `claude update`, and installers, no sudo), so `pi update`, `claude update`, and
@ -17,9 +18,66 @@ Run OpenCode, Claude Code, and Pi (pi.dev) inside a shared Arch Linux Docker con
- Mounts only the current project directory (same absolute path inside container) - Mounts only the current project directory (same absolute path inside container)
- **Security boundary**: No access to SSH keys, passwords, or full `$HOME` (intentionally prevents remote code pushes) - **Security boundary**: No access to SSH keys, passwords, or full `$HOME` (intentionally prevents remote code pushes)
## Container runtime
Containers run with rootless Podman. Nothing about that needs a privileged
daemon or a privileged group: the container lives inside a user namespace owned
by your own user, so it has exactly your file permissions and no more.
This matters because Docker's `docker` group is equivalent to unrestricted root
on the host. Write access to `/var/run/docker.sock` lets anyone start a
privileged container with `-v /:/host` and edit any file, including `/etc/shadow`
and your SSH keys. An agent with that access is one bad tool call away from
owning the machine. Rootless Podman removes that path entirely: container root
maps to a subordinate UID from `/etc/subuid`, not to host root.
How the details are handled:
- Containers are created with `--userns=keep-id`, so your host UID/GID map to
the same UID/GID inside. Files in the mounted project and in the container home
keep their normal ownership in both directions.
- `sudo` inside the container still works (the image has passwordless sudo), but
only reaches container root, which is a subordinate UID on the host. Files that
container root creates on the bind mounts show up on the host as owned by that
subordinate UID (typically `100000:100000`), not by `root`.
- Container storage (images, writable layers) lives in
`~/.local/share/containers/storage`, not in `/var/lib/docker`.
- Docker is still possible if you really want it:
`AGENT_CONTAINER_RUNTIME=docker agent-container pi`.
### Migrating from Docker
Existing `ac-*` containers and the `agent-container:latest` image in Docker are
not reused; Podman builds and manages its own copy. Remove the old ones once you
no longer need them:
```sh
docker ps -a --filter 'name=^ac-' --format '{{.Names}}' | xargs -r docker rm -f
docker rmi agent-container:latest
```
Only after that, and after moving any other user-level services off Docker (see
below), leave the `docker` group:
```sh
sudo gpasswd -d "$USER" docker
```
The change takes effect on your next login (`newgrp docker` or a fresh session).
System services that run Docker as root keep working; only your own access to the
socket goes away.
## Install ## Install
On a fresh machine, two steps are all it takes. The agent tools themselves are not part of the image and need no installation step: on first use, agent-container.py automatically runs each tool's official installer inside the container (no sudo), just like it builds the Docker image automatically if it is missing. You need rootless Podman on the host. On Arch Linux the packages `podman` and `passt` (or `slirp4netns`) are enough; your user also needs subuid/subgid entries, which `usermod --add-subuids 100000-165535 --add-subgids 100000-165535 <user>` sets up if the package did not:
```sh
sudo pacman -S podman passt
grep "^$USER:" /etc/subuid /etc/subgid
podman info >/dev/null && echo "rootless podman works"
```
On a fresh machine, two more steps are all it takes. The agent tools themselves are not part of the image and need no installation step: on first use, agent-container.py automatically runs each tool's official installer inside the container (no sudo), just like it builds the Podman image automatically if it is missing.
```sh ```sh
cd ~/Projects/ cd ~/Projects/
@ -73,6 +131,7 @@ This means inline overrides work as expected: `OPENCODE_CONFIG=foo opencode`
For persistent environment variables, add them to `container-home/.bashrc` (or `.bash_profile` / `.profile`). Tools run through `bash -l` inside the container, so standard shell config files are sourced automatically. Default shell config files from `/etc/skel` are seeded into the container home on first run. For persistent environment variables, add them to `container-home/.bashrc` (or `.bash_profile` / `.profile`). Tools run through `bash -l` inside the container, so standard shell config files are sourced automatically. Default shell config files from `/etc/skel` are seeded into the container home on first run.
- `XDG_DATA_HOME`: Override default data directory (default: `~/.local/share`) - `XDG_DATA_HOME`: Override default data directory (default: `~/.local/share`)
- `AGENT_CONTAINER_RUNTIME`: Container runtime to use (default: `podman`, set to `docker` for the old behaviour)
## Usage ## Usage
@ -120,7 +179,7 @@ To remove all containers, the image, and the persistent home directory:
agent-container purge agent-container purge
``` ```
Note that this also deletes the shared agent state that lives in the container home (`.claude`, `.pi`, ...). The symlinks in your `$HOME` will dangle afterwards. Note that this also deletes the shared agent state that lives in the container home (`.claude`, `.pi`, ...). The symlinks in your `$HOME` will dangle afterwards. Podman's own storage directory is left alone; other Podman images and containers are not touched.
## Sharing host config files via hard links ## Sharing host config files via hard links

View file

@ -30,6 +30,12 @@ class AgentContainer:
DATA_DIR_NAME = "agent-container" DATA_DIR_NAME = "agent-container"
START_TIMEOUT = 3.0 START_TIMEOUT = 3.0
# Rootless Podman is the default. It needs neither a privileged daemon
# nor membership of a privileged group, so an agent inside the container
# cannot escalate to host root the way it can through the Docker socket.
# Set AGENT_CONTAINER_RUNTIME=docker to use Docker instead.
RUNTIME = os.environ.get("AGENT_CONTAINER_RUNTIME", "podman")
def __init__(self): def __init__(self):
self.project_path = Path.cwd().resolve() self.project_path = Path.cwd().resolve()
self.project_id = self._project_id(self.project_path) self.project_id = self._project_id(self.project_path)
@ -42,7 +48,7 @@ class AgentContainer:
self.container_name = ( self.container_name = (
f"{self.CONTAINER_PREFIX}{safe_name}-{self.project_id}" f"{self.CONTAINER_PREFIX}{safe_name}-{self.project_id}"
) )
self.docker_context_dir = Path(__file__).resolve().parent self.context_dir = Path(__file__).resolve().parent
def _get_xdg_data_home(self) -> Path: def _get_xdg_data_home(self) -> Path:
"""Get XDG_DATA_HOME with fallback to ~/.local/share""" """Get XDG_DATA_HOME with fallback to ~/.local/share"""
@ -146,7 +152,7 @@ class AgentContainer:
logger.error( logger.error(
"Wait for the current instance to finish or manually stop it with:" "Wait for the current instance to finish or manually stop it with:"
) )
logger.error(f" docker stop {self.container_name}") logger.error(f" {self.RUNTIME} stop {self.container_name}")
sys.exit(1) sys.exit(1)
# Pre-create project directory structure to prevent root-owned directories # Pre-create project directory structure to prevent root-owned directories
@ -240,7 +246,7 @@ class AgentContainer:
return return
probe = subprocess.run( probe = subprocess.run(
["docker", "exec", self.container_name, "bash", "-lc", spec["probe"]], [self.RUNTIME, "exec", self.container_name, "bash", "-lc", spec["probe"]],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
@ -252,7 +258,7 @@ class AgentContainer:
try: try:
subprocess.run( subprocess.run(
[ [
"docker", "exec", self.container_name, "bash", "-lc", self.RUNTIME, "exec", self.container_name, "bash", "-lc",
spec["install"], spec["install"],
], ],
check=True, check=True,
@ -280,7 +286,7 @@ class AgentContainer:
'printf "%s\\n" "$line"; } >> "$HOME/.bash_profile"' 'printf "%s\\n" "$line"; } >> "$HOME/.bash_profile"'
) )
subprocess.run( subprocess.run(
["docker", "exec", self.container_name, "bash", "-lc", script], [self.RUNTIME, "exec", self.container_name, "bash", "-lc", script],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=True, check=True,
@ -291,7 +297,7 @@ class AgentContainer:
home directory, skipping files that already exist.""" home directory, skipping files that already exist."""
subprocess.run( subprocess.run(
[ [
"docker", self.RUNTIME,
"exec", "exec",
self.container_name, self.container_name,
"bash", "bash",
@ -325,7 +331,7 @@ class AgentContainer:
# Attach a TTY only when stdin is one, so scripted runs (`pi -p`, # Attach a TTY only when stdin is one, so scripted runs (`pi -p`,
# cron, CI) work without a terminal. # cron, CI) work without a terminal.
exec_cmd = ["docker", "exec", "-i"] exec_cmd = [self.RUNTIME, "exec", "-i"]
if sys.stdin.isatty(): if sys.stdin.isatty():
exec_cmd.append("-t") exec_cmd.append("-t")
result = subprocess.run( result = subprocess.run(
@ -350,7 +356,7 @@ class AgentContainer:
def image_exists(self) -> bool: def image_exists(self) -> bool:
return ( return (
subprocess.run( subprocess.run(
["docker", "image", "inspect", self.IMAGE], [self.RUNTIME, "image", "inspect", self.IMAGE],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
).returncode ).returncode
@ -362,7 +368,7 @@ class AgentContainer:
f"Building image '{self.IMAGE}' with user {self.host_username} ({self.host_uid}:{self.host_gid})" f"Building image '{self.IMAGE}' with user {self.host_username} ({self.host_uid}:{self.host_gid})"
) )
cmd = [ cmd = [
"docker", self.RUNTIME,
"build", "build",
# Use host networking during build so the build works on hosts # Use host networking during build so the build works on hosts
# where the default bridge network cannot create veth pairs # where the default bridge network cannot create veth pairs
@ -381,18 +387,31 @@ class AgentContainer:
cmd.append("--no-cache") cmd.append("--no-cache")
if pull: if pull:
cmd.append("--pull") cmd.append("--pull")
cmd.append(str(self.docker_context_dir)) cmd.append(str(self.context_dir))
self._run_cmd(cmd) self._run_cmd(cmd)
# ========================= # =========================
# Container lifecycle # Container lifecycle
# ========================= # =========================
def _userns_args(self) -> list[str]:
"""Make container UIDs match host UIDs (rootless Podman only).
Rootless Podman maps container root to the user's subuid range, so
the container's UID 1000 would otherwise own nothing in the bind
mounted project. `keep-id` maps the host UID/GID to the same values
inside the container, and container root to a subuid instead of
host root -- which is exactly the isolation we want.
"""
if self.RUNTIME != "podman" or os.geteuid() == 0:
return []
return ["--userns=keep-id"]
def create_container(self) -> None: def create_container(self) -> None:
logger.info(f"Creating container '{self.container_name}'") logger.info(f"Creating container '{self.container_name}'")
self._run_cmd( self._run_cmd(
[ [
"docker", self.RUNTIME,
"create", "create",
"--name", "--name",
self.container_name, self.container_name,
@ -402,6 +421,7 @@ class AgentContainer:
"AGENT_CONTAINER=1", "AGENT_CONTAINER=1",
"--network", "--network",
"host", "host",
*self._userns_args(),
"--user", "--user",
f"{self.host_uid}:{self.host_gid}", f"{self.host_uid}:{self.host_gid}",
"--volume", "--volume",
@ -422,12 +442,12 @@ class AgentContainer:
image, so tools baked into previous builds (or missing from new image, so tools baked into previous builds (or missing from new
ones) linger. Comparing image IDs catches that.""" ones) linger. Comparing image IDs catches that."""
container_image = subprocess.run( container_image = subprocess.run(
["docker", "container", "inspect", "-f", "{{.Image}}", self.container_name], [self.RUNTIME, "container", "inspect", "-f", "{{.Image}}", self.container_name],
capture_output=True, capture_output=True,
text=True, text=True,
).stdout.strip() ).stdout.strip()
current_image = subprocess.run( current_image = subprocess.run(
["docker", "image", "inspect", "-f", "{{.Id}}", self.IMAGE], [self.RUNTIME, "image", "inspect", "-f", "{{.Id}}", self.IMAGE],
capture_output=True, capture_output=True,
text=True, text=True,
).stdout.strip() ).stdout.strip()
@ -441,7 +461,7 @@ class AgentContainer:
against Config.Env catches that.""" against Config.Env catches that."""
env = subprocess.run( env = subprocess.run(
[ [
"docker", self.RUNTIME,
"container", "container",
"inspect", "inspect",
"-f", "-f",
@ -455,7 +475,7 @@ class AgentContainer:
def start_container(self) -> bool: def start_container(self) -> bool:
subprocess.run( subprocess.run(
["docker", "start", self.container_name], [self.RUNTIME, "start", self.container_name],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
@ -463,11 +483,11 @@ class AgentContainer:
def stop_container(self) -> None: def stop_container(self) -> None:
if self.container_running(): if self.container_running():
self._run_cmd(["docker", "stop", "-t", "0", self.container_name]) self._run_cmd([self.RUNTIME, "stop", "-t", "0", self.container_name])
def remove_container(self) -> None: def remove_container(self) -> None:
subprocess.run( subprocess.run(
["docker", "rm", "-f", self.container_name], [self.RUNTIME, "rm", "-f", self.container_name],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
@ -492,7 +512,7 @@ class AgentContainer:
if self.image_exists(): if self.image_exists():
logger.info(f"Removing image '{self.IMAGE}'...") logger.info(f"Removing image '{self.IMAGE}'...")
subprocess.run( subprocess.run(
["docker", "rmi", self.IMAGE], [self.RUNTIME, "rmi", self.IMAGE],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
@ -510,7 +530,7 @@ class AgentContainer:
logger.info("Removing existing containers...") logger.info("Removing existing containers...")
result = subprocess.run( result = subprocess.run(
[ [
"docker", self.RUNTIME,
"ps", "ps",
"-a", "-a",
"--filter", "--filter",
@ -525,7 +545,7 @@ class AgentContainer:
if name: if name:
logger.info(f" Removing container '{name}'") logger.info(f" Removing container '{name}'")
subprocess.run( subprocess.run(
["docker", "rm", "-f", name], [self.RUNTIME, "rm", "-f", name],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
@ -537,7 +557,7 @@ class AgentContainer:
def container_exists(self) -> bool: def container_exists(self) -> bool:
return ( return (
subprocess.run( subprocess.run(
["docker", "inspect", self.container_name], [self.RUNTIME, "inspect", self.container_name],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
).returncode ).returncode
@ -546,7 +566,7 @@ class AgentContainer:
def container_running(self) -> bool: def container_running(self) -> bool:
result = subprocess.run( result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", self.container_name], [self.RUNTIME, "inspect", "-f", "{{.State.Running}}", self.container_name],
capture_output=True, capture_output=True,
text=True, text=True,
) )