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:
parent
aff70f009e
commit
ed1c9a7b92
2 changed files with 104 additions and 25 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue