diff --git a/README.md b/README.md index ed9d7d7..bcecdf6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # 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 +- **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 bootstrapped into the persistent container home on first use (official 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) - **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 -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 ` 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 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. - `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 @@ -120,7 +179,7 @@ To remove all containers, the image, and the persistent home directory: 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 diff --git a/agent-container.py b/agent-container.py index a2a13b7..a7312b6 100755 --- a/agent-container.py +++ b/agent-container.py @@ -30,6 +30,12 @@ class AgentContainer: DATA_DIR_NAME = "agent-container" 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): self.project_path = Path.cwd().resolve() self.project_id = self._project_id(self.project_path) @@ -42,7 +48,7 @@ class AgentContainer: self.container_name = ( 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: """Get XDG_DATA_HOME with fallback to ~/.local/share""" @@ -146,7 +152,7 @@ class AgentContainer: logger.error( "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) # Pre-create project directory structure to prevent root-owned directories @@ -240,7 +246,7 @@ class AgentContainer: return 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, stderr=subprocess.DEVNULL, ) @@ -252,7 +258,7 @@ class AgentContainer: try: subprocess.run( [ - "docker", "exec", self.container_name, "bash", "-lc", + self.RUNTIME, "exec", self.container_name, "bash", "-lc", spec["install"], ], check=True, @@ -280,7 +286,7 @@ class AgentContainer: 'printf "%s\\n" "$line"; } >> "$HOME/.bash_profile"' ) subprocess.run( - ["docker", "exec", self.container_name, "bash", "-lc", script], + [self.RUNTIME, "exec", self.container_name, "bash", "-lc", script], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, @@ -291,7 +297,7 @@ class AgentContainer: home directory, skipping files that already exist.""" subprocess.run( [ - "docker", + self.RUNTIME, "exec", self.container_name, "bash", @@ -325,7 +331,7 @@ class AgentContainer: # Attach a TTY only when stdin is one, so scripted runs (`pi -p`, # cron, CI) work without a terminal. - exec_cmd = ["docker", "exec", "-i"] + exec_cmd = [self.RUNTIME, "exec", "-i"] if sys.stdin.isatty(): exec_cmd.append("-t") result = subprocess.run( @@ -350,7 +356,7 @@ class AgentContainer: def image_exists(self) -> bool: return ( subprocess.run( - ["docker", "image", "inspect", self.IMAGE], + [self.RUNTIME, "image", "inspect", self.IMAGE], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ).returncode @@ -362,7 +368,7 @@ class AgentContainer: f"Building image '{self.IMAGE}' with user {self.host_username} ({self.host_uid}:{self.host_gid})" ) cmd = [ - "docker", + self.RUNTIME, "build", # Use host networking during build so the build works on hosts # where the default bridge network cannot create veth pairs @@ -381,18 +387,31 @@ class AgentContainer: cmd.append("--no-cache") if pull: cmd.append("--pull") - cmd.append(str(self.docker_context_dir)) + cmd.append(str(self.context_dir)) self._run_cmd(cmd) # ========================= # 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: logger.info(f"Creating container '{self.container_name}'") self._run_cmd( [ - "docker", + self.RUNTIME, "create", "--name", self.container_name, @@ -402,6 +421,7 @@ class AgentContainer: "AGENT_CONTAINER=1", "--network", "host", + *self._userns_args(), "--user", f"{self.host_uid}:{self.host_gid}", "--volume", @@ -422,12 +442,12 @@ class AgentContainer: image, so tools baked into previous builds (or missing from new ones) linger. Comparing image IDs catches that.""" container_image = subprocess.run( - ["docker", "container", "inspect", "-f", "{{.Image}}", self.container_name], + [self.RUNTIME, "container", "inspect", "-f", "{{.Image}}", self.container_name], capture_output=True, text=True, ).stdout.strip() current_image = subprocess.run( - ["docker", "image", "inspect", "-f", "{{.Id}}", self.IMAGE], + [self.RUNTIME, "image", "inspect", "-f", "{{.Id}}", self.IMAGE], capture_output=True, text=True, ).stdout.strip() @@ -441,7 +461,7 @@ class AgentContainer: against Config.Env catches that.""" env = subprocess.run( [ - "docker", + self.RUNTIME, "container", "inspect", "-f", @@ -455,7 +475,7 @@ class AgentContainer: def start_container(self) -> bool: subprocess.run( - ["docker", "start", self.container_name], + [self.RUNTIME, "start", self.container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) @@ -463,11 +483,11 @@ class AgentContainer: def stop_container(self) -> None: 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: subprocess.run( - ["docker", "rm", "-f", self.container_name], + [self.RUNTIME, "rm", "-f", self.container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) @@ -492,7 +512,7 @@ class AgentContainer: if self.image_exists(): logger.info(f"Removing image '{self.IMAGE}'...") subprocess.run( - ["docker", "rmi", self.IMAGE], + [self.RUNTIME, "rmi", self.IMAGE], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) @@ -510,7 +530,7 @@ class AgentContainer: logger.info("Removing existing containers...") result = subprocess.run( [ - "docker", + self.RUNTIME, "ps", "-a", "--filter", @@ -525,7 +545,7 @@ class AgentContainer: if name: logger.info(f" Removing container '{name}'") subprocess.run( - ["docker", "rm", "-f", name], + [self.RUNTIME, "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) @@ -537,7 +557,7 @@ class AgentContainer: def container_exists(self) -> bool: return ( subprocess.run( - ["docker", "inspect", self.container_name], + [self.RUNTIME, "inspect", self.container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ).returncode @@ -546,7 +566,7 @@ class AgentContainer: def container_running(self) -> bool: result = subprocess.run( - ["docker", "inspect", "-f", "{{.State.Running}}", self.container_name], + [self.RUNTIME, "inspect", "-f", "{{.State.Running}}", self.container_name], capture_output=True, text=True, )