diff --git a/.claude/settings.json b/.claude/settings.json index 6e6217520..cccfaf158 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,9 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", + "env": { + "CONTAINER_HOST": "unix://./.apache-magpie-local/run/podman.sock", + "DOCKER_HOST": "unix://./.apache-magpie-local/run/docker.sock" + }, "sandbox": { "enabled": true, "excludedCommands": [ diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index 59e757c4e..72cd0f64e 100644 --- a/docs/adapters/codex.md +++ b/docs/adapters/codex.md @@ -170,7 +170,7 @@ replaces — code review. Run the static validator from the framework or snapshot: ~~~bash -uv run --directory tools/sandbox-lint --group dev sandbox-lint --codex .codex +uv run --project tools/sandbox-lint --group dev sandbox-lint --codex .codex ~~~ Then test native rule classification: diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md index 61d099eac..d26054620 100644 --- a/docs/labels-and-capabilities.md +++ b/docs/labels-and-capabilities.md @@ -307,6 +307,7 @@ or a contract-free mix of substrates (e.g. `tools/spec-inventory` is | [`tools/dashboard-generator`](../tools/dashboard-generator/) | `substrate:analytics` | Self-contained HTML dashboard generator | | [`tools/dev`](../tools/dev/) | `substrate:framework-dev` | Framework dev-loop helpers | | [`tools/egress-gateway`](../tools/egress-gateway/) | `substrate:sandbox` | Egress-allowlist forward proxy (proxy.py plugin); host-level egress chokepoint — defence-in-depth for RFC-AI-0003 §4.4 | +| [`tools/container-gateway`](../tools/container-gateway/) | `substrate:sandbox` | Per-project policy proxy for the podman / docker API; label-scoped, mount- and privilege-checked container access from inside the sandbox | | [`tools/forwarder-relay`](../tools/forwarder-relay/) | `contract:report-relay` | Adapter contract for inbound-relay backends (ASF Security relay, huntr.com, HackerOne triagers). Pure interface spec; adapters declare detection + credit-extraction + reporter-addressing rules. | | [`tools/bitbucket`](../tools/bitbucket/) | `contract:change-request` + `contract:tracker` | Coverage: `partial`. Bitbucket Cloud and Bitbucket Data Center bridge foundation for repository metadata context, branch restriction context for PR-management decisions, pull-request discovery/fetching, read-only commit fetching, read-only diff fetching, comments-only discussion fetching, read-only review-state fetching, Cloud-only pull-request task listing/fetching, read-only merge-check context fetching, and read-only status fetching, plus narrowly scoped Cloud pull-request comment creation and approve/unapprove actions. Tracker coverage includes Cloud-only issue listing/fetching, issue comment fetching, issue attachment metadata fetching, and confirmed issue-comment creation. The `partial` qualifier means this tool implements named contract operations but does not satisfy the complete contract and must not be counted as a complete/selectable backend. Broader pull-request review/mutation, broader issue writes, and linked Jira handoff coverage remain incomplete. | | [`tools/fossil`](../tools/fossil/) | `contract:tracker` + `contract:source-control` | Fossil SCM forge bridge: integrates local SQLite-backed ticket tracking, wiki, and forum reads with the version-control shim | diff --git a/docs/mode-economics.md b/docs/mode-economics.md index db2049709..37af90703 100644 --- a/docs/mode-economics.md +++ b/docs/mode-economics.md @@ -92,7 +92,7 @@ special-token spellings counted as ordinary text. Coverage: **75 of 75 local `skills/*/SKILL.md` files**. External `source.md` redirects and harness symlinks are excluded. -Measurement manifest SHA-256: `0ef06f4874382739a4738d35ec92e689f0483db6e99292c4accc72836d0619ad`. +Measurement manifest SHA-256: `5ab15771b5022fff4f3fc54bba6ba3302719af5c333aa7651121cb1b9380d0db`. | Skill file | Measured tokens | Source SHA-256 (first 16 characters) | |---|---:|---| @@ -159,10 +159,10 @@ Measurement manifest SHA-256: `0ef06f4874382739a4738d35ec92e689f0483db6e99292c4a | [security-model-verify](../skills/security-model-verify/SKILL.md) | 6,625 | `cde155672857b33c` | | [security-tracker-stats-dashboard](../skills/security-tracker-stats-dashboard/SKILL.md) | 4,897 | `b52154deb8557ba4` | | [setup](../skills/setup/SKILL.md) | 8,724 | `82788542bb240309` | -| [setup-isolated-setup-doctor](../skills/setup-isolated-setup-doctor/SKILL.md) | 6,287 | `a39955c3ae92e306` | -| [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 9,313 | `867d0becf43bd736` | -| [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 4,717 | `5afb01aaf8bbac99` | -| [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 6,718 | `e6a10ad7f569db31` | +| [setup-isolated-setup-doctor](../skills/setup-isolated-setup-doctor/SKILL.md) | 7,651 | `7485a409d69de376` | +| [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 10,059 | `d2a1e98a9f2c3b38` | +| [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 4,843 | `60bab0e30ac2e25a` | +| [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 7,410 | `e5c4cd00e97147ad` | | [setup-override-upstream](../skills/setup-override-upstream/SKILL.md) | 4,012 | `fb583feb56b7f77c` | | [setup-privacy-llm](../skills/setup-privacy-llm/SKILL.md) | 2,145 | `0e27b542a1656846` | | [setup-shared-config-sync](../skills/setup-shared-config-sync/SKILL.md) | 4,357 | `d1dfcd7cdeb5f5a6` | diff --git a/docs/rfcs/RFC-AI-0003.md b/docs/rfcs/RFC-AI-0003.md index dd4aff96a..795fffbc8 100644 --- a/docs/rfcs/RFC-AI-0003.md +++ b/docs/rfcs/RFC-AI-0003.md @@ -255,6 +255,12 @@ The gateway runs **outside the sandbox** — it must bind a listener and make un This mechanism is **optional and provisional**: it ships as a tool with a documented contract and unit-tested allowlist policy, but it is not yet wired into a setup skill or the `privacy-llm-check` gate. See §10.6. +The same pattern, a per-project policy proxy outside the sandbox whose socket is the only one the sandbox may reach, +is reused by [`tools/container-gateway/`](../../tools/container-gateway/) for the container daemon socket. +That gateway also hands every container it creates this egress gateway as its HTTP proxy, +so container traffic that honours proxy variables is bound by the same host allow-list. +RFC-AI-0004 Principle 2 lists both as the *socket gateways* layer. + ## 5. Data flow ```text diff --git a/docs/rfcs/RFC-AI-0004.md b/docs/rfcs/RFC-AI-0004.md index 001c3c789..4473d6f90 100644 --- a/docs/rfcs/RFC-AI-0004.md +++ b/docs/rfcs/RFC-AI-0004.md @@ -185,6 +185,7 @@ The reference implementation (see [`docs/setup/secure-agent-internals.md`](http |---|---|---| | **0. Clean environment** | Inherited credential-shaped env vars (`$AWS_*`, `$GH_TOKEN`, `$ANTHROPIC_API_KEY`, …). | A shell wrapper (`claude-iso`) that strips the agent's process env to a project-declared whitelist before exec. | | **1. Filesystem + network sandbox** | Bash subprocess reads outside the project tree; outbound HTTPS to non-allowed hosts. | Linux: `bubblewrap` user-namespace + `socat` SNI proxy. macOS: `sandbox-exec`. | +| **1b. Socket gateways** | Daemon sockets that are root-equivalent over their mounts (container runtimes), and container network egress the sandbox proxy never sees. | Per-project policy proxies running outside the sandbox: `tools/egress-gateway` (host allow-list, RFC-AI-0003 § 4.4) and `tools/container-gateway` (label-scoped, mount- and privilege-checked container API, egress-gateway injected as the containers' proxy). The sandbox may reach only the gateways' own sockets, never the daemon socket. | | **2. Tool permissions** | The agent's own Read/Edit/Write/Bash tools touching denied paths or binaries. | The agent host's permission system (e.g., Claude Code's `permissions.deny`). | | **3. Forced confirmation** | Visible-to-others writes that haven't been seen by a human. | `permissions.ask` for every state-mutating shell call (e.g., `gh pr create`, `gh issue edit`, `gh gist *`, `gh secret *`). Implements Principle 1 at the OS layer. | diff --git a/docs/setup/sandbox-troubleshooting.md b/docs/setup/sandbox-troubleshooting.md index f2e1056f5..1e2005d81 100644 --- a/docs/setup/sandbox-troubleshooting.md +++ b/docs/setup/sandbox-troubleshooting.md @@ -580,197 +580,77 @@ Per-entry rationale: Cannot connect to the Docker daemon at unix:///Users//.docker/run/docker.sock. Is the docker daemon running? ERRO[0000] error connecting to /var/run/docker.sock: open /var/run/docker.sock: operation not permitted Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list` +Error: unable to connect to Podman socket: failed to read identity "/Users//.local/share/containers/podman/machine/machine": operation not permitted +dial unix ./.apache-magpie-local/run/podman.sock: connect: no such file or directory +dial unix ./.apache-magpie-local/run/podman.sock: connect: operation not permitted ``` -…on any `docker` / `podman` / `nerdctl` invocation. The CLI is -installed and the runtime is running on the host — the sandbox is -just blocking access to its socket. +…on any `docker` / `podman` / `nerdctl` invocation. +The first three lines are the CLI reaching straight for the real daemon socket or the podman machine's ssh identity, both denied by design. +The last two are the CLI reaching the container gateway's own socket instead. +`no such file or directory` means the gateway is not running for this project. +`operation not permitted` means its socket is not in `sandbox.network.allowUnixSockets`. -On macOS with Docker Desktop the failure usually arrives *earlier* -than that, as one of: - -```text -zsh: operation not permitted: docker -docker: unknown command: docker compose -``` - -The first means the sandbox is blocking the `docker` binary itself; -the second means it is blocking the CLI plugins. Neither reaches the -socket at all, so the socket allowlist below does not fix them on its -own — see the CLI paths in the same block. - -A third form appears once the CLI runs but the connection is still -refused, on every platform: - -```text -permission denied while trying to connect to the docker API at unix:///var/run/docker.sock -``` - -That one is the missing `sandbox.network.allowUnixSockets` entry, not a -filesystem permission — see below. +Inside the sandbox, `podman machine list` prints an empty table even when the machine is running, because the machine directory under `~/.local/share/containers/podman/machine/` is unreadable. +An empty list from inside the sandbox is therefore not evidence that no machine exists. +Check the machine's real state from **outside** the sandbox (a `!`-prefixed shell command, or your own terminal) before assuming it needs `podman machine init`. ### Root cause -The runtime CLI talks to its daemon via a unix-domain socket. The -framework's reference `~/.claude/settings.json` has -`Read(~/.docker/**)` in `permissions.deny` (to keep the agent -from reading Docker credentials stored under `~/.docker/config.json`) -and lists `~/.docker` in the broader filesystem `denyRead` set. -Both block the socket file under `~/.docker/run/docker.sock`, -which is where Docker.app for Mac drops its socket. - -On macOS the same `~/.docker` denial also blocks two things that -are not the socket, and that a socket-only allowlist therefore -leaves broken: - -- **The CLI binary.** Docker Desktop installs it *inside* the denied - directory — `docker` on `PATH` is `~/.docker/bin/docker`, a symlink - into `/Applications/Docker.app`. Denied, the shell cannot execute it - at all (`operation not permitted: docker`). -- **The CLI plugins.** `docker compose` and `docker buildx` are not - builtins; they are separate binaries in `~/.docker/cli-plugins/`. - Denied, `docker ps` works while `docker compose` reports - `unknown command`, which breaks any compose-driven workflow. - -Separately, and on **every** platform: listing a socket in -`sandbox.filesystem.allowRead` grants permission to *read the file*, -not to *connect to it*. Socket connections are gated by their own -`sandbox.network.allowUnixSockets` list. With the path allowed for -reading but absent from that list, the CLI starts, finds the socket, -and is refused at `connect(2)`: +The container daemon socket is root-equivalent over whatever the daemon mounts: a default Podman machine mounts `/Users`, `/private`, and `/var/folders` read-write, and Docker Desktop's daemon is no narrower. +Neither excluding `docker` / `podman` from the sandbox with `sandbox.excludedCommands`, which some upstream guidance suggests, nor listing the daemon socket itself in `sandbox.network.allowUnixSockets` is acceptable for that reason: both hand the agent unrestricted host access through the daemon. +The framework's `sandbox-lint` tool enforces the second half of that. +It rejects any `allowUnixSockets` entry whose basename is `docker.sock`, `podman.sock`, or ends in `-api.sock`, unless the entry's parent directory is `.apache-magpie-local/run`. -```console -$ docker ps -permission denied while trying to connect to the docker API at unix:///var/run/docker.sock -``` +On macOS, the podman CLI's default connection to a Podman machine goes over `ssh://`, using an identity file under `~/.local/share/containers/podman/machine/`, a path the framework's blanket `~/` read denial already covers. +The machine's actual API socket lives elsewhere, under `$TMPDIR/podman/-api.sock` (`podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}'` prints the exact path), not under `~/.local/share` as the ssh identity path might suggest. -Both settings are required; neither substitutes for the other. This is -not macOS-specific — a Linux adopter using `/var/run/docker.sock` needs -the `allowUnixSockets` entry just the same. - -For Colima the socket lives under `~/.colima/...` (not currently -covered by any allow / deny in the framework reference, so it -works by default), and for rootless Podman it lives under -`$XDG_RUNTIME_DIR/podman/...` (also not covered → works). The -case that fails is specifically Docker.app on macOS plus the -generic `~/.docker` denial. +The supported route is the [container gateway](../../tools/container-gateway/README.md). +It runs outside the sandbox, holds the only connection to the real daemon socket, and exposes two policy-checked sockets of its own under `/.apache-magpie-local/run/`. +`CONTAINER_HOST` and `DOCKER_HOST` point at `podman.sock` and `docker.sock` in that directory, only those two sockets are ever added to `allowUnixSockets`, and a `SessionStart` hook starts the gateway when a session begins. +See [Container gateway](secure-agent-setup.md#container-gateway) in the setup guide for the full install. ### Fix -Allow Bash subprocesses to read the *socket file*, the CLI, and its -plugins without opening the `~/.docker/` directory generally: +| Error line | Cause | Action | +|---|---|---| +| `failed to read identity "…/machine/machine": operation not permitted` | `CONTAINER_HOST` / `DOCKER_HOST` are unset, so the CLI fell back to its default connection instead of the gateway | Add the reference `env` block below to `.claude/settings.json` / `settings.local.json` | +| `dial unix ./.apache-magpie-local/run/podman.sock: connect: no such file or directory` | The gateway is not running for this project | Run `~/.claude/scripts/container-gateway-hook.sh start` from a terminal, or check `/.apache-magpie-local/run/container-gateway.log` for why it did not start | +| `dial unix ./.apache-magpie-local/run/podman.sock: connect: operation not permitted` | The gateway is running but its socket is missing from `sandbox.network.allowUnixSockets` | Add both gateway sockets as absolute paths, per [Container gateway](secure-agent-setup.md#container-gateway) | ```jsonc -// ~/.claude/settings.json +// .claude/settings.json (already the framework's committed default on this branch) { - "sandbox": { - "filesystem": { - "allowRead": [ - // ...existing entries... - "~/.docker/run/docker.sock", // Docker.app for Mac socket - "~/.colima/default/docker.sock", // Colima default socket (defensive; usually not blocked) - "/var/run/docker.sock", // Linux daemon socket (root-managed install) - "~/.docker/bin/", // Docker Desktop CLI binaries (`docker` itself lives here on macOS) - "~/.docker/cli-plugins/" // `docker compose`, `docker buildx` — separate plugin binaries - ] - }, - "network": { - // Reading the socket file is not the same permission as connecting - // to it. Without these, the CLI runs but every command is refused - // with "permission denied while trying to connect to the docker API". - "allowUnixSockets": [ - "/var/run/docker.sock", - "~/.docker/run/docker.sock" - ] - } - }, - "permissions": { - "deny": [ - // ...existing entries... - "Read(~/.docker/config.json)", // keep this denial — credentials live here - "Read(~/.docker/contexts/**)" // keep this denial — saved contexts - // (Replace the broad `Read(~/.docker/**)` with these two specific paths.) - ] + "env": { + "CONTAINER_HOST": "unix://./.apache-magpie-local/run/podman.sock", + "DOCKER_HOST": "unix://./.apache-magpie-local/run/docker.sock" } } ``` -Per-entry rationale: +#### `403 container-gateway: …` -- `~/.docker/run/docker.sock` — Docker.app for Mac's socket - location. Read access on the socket file is what the docker CLI - needs to `connect(2)` to the daemon. -- `~/.colima/default/docker.sock` — Colima's default; explicit - even though it works today, to anticipate a future widening of - the generic `~/.` denial. -- `/var/run/docker.sock` — Linux systems with daemon Docker; - socket is root-managed but world-readable by convention. -- `~/.docker/bin/` — Docker Desktop for Mac installs the `docker` - CLI here (as a symlink into `/Applications/Docker.app`), so - without it the binary cannot be executed and no socket entry - matters. Not needed for Homebrew or Linux installs, where the - CLI lives on a normal `PATH` directory outside `~/.docker`. -- `~/.docker/cli-plugins/` — `docker compose` and `docker buildx` - are plugin binaries, not builtins. Without it `docker ps` - succeeds but `docker compose` fails as `unknown command`. -- `sandbox.network.allowUnixSockets` — the connect-side permission, - required on every platform. `allowRead` on the same path only - lets a process *open the file*; the sandbox gates socket - *connections* through this separate list. Verified by removing - it while leaving the `allowRead` entries in place: `docker - compose version` still ran, and `docker ps` failed with - `permission denied while trying to connect to the docker API`. -- The narrowed `permissions.deny` keeps the agent's `Read` tool - from seeing Docker auth tokens (`config.json`) and saved - contexts (which include host IPs and credentials), while - allowing the Bash subprocess to use the socket. +A request that reaches the gateway but fails its policy comes back as `403`, and the CLI prints the message verbatim, for example `container-gateway: bind-mount: /Users/you/.ssh is outside the allowed roots (…); see docs/setup/sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error`. +The message names the rule that refused the request, and it points back at this very catalog entry. +The full create-time refusal table lives in [`tools/container-gateway/README.md` → What the policy refuses](../../tools/container-gateway/README.md#what-the-policy-refuses). +Adjust the request rather than widening the sandbox: a `403` from the gateway is the policy working as intended, not a sandbox misconfiguration. ### Notes -- For **rootless Podman**, the socket is at - `$XDG_RUNTIME_DIR/podman/podman.sock` (typically - `/run/user//podman/podman.sock`). Currently allowed by - default because the framework reference does not deny - `/run/user//`; if a future widening adds such a denial, - add `/run/user/*/podman/` to `allowRead`. -- For **CI / image-build workflows** that run inside an adopter - repo, prefer adding the socket allow at project scope - (`.claude/settings.local.json` in the adopter) rather than user - scope — that keeps the framework's user-scope reference minimal - and makes the widening visible to whoever audits the adopter's - repo. -- Do **not** widen `allowRead` to `~/.docker/**` — the directory - holds auth tokens and saved contexts; the whole point of the - framework's `Read(~/.docker/**)` denial is to keep those out of - the agent's reach. -- **Podman on macOS** has two failure modes that look alike. With - no machine created (`podman machine list` is empty) `podman info` - fails with `unable to connect to Podman socket … no such file or - directory` — that is a missing VM, not a sandbox denial; run - `podman machine init` / `start` outside the agent. Separately, - `podman system connection list` fails with - `open ~/.config/containers/podman-connections.json: operation not - permitted` because the framework's `~/` read denial covers - Podman's config directory. Allow that **one file**, not the - directory — `~/.config/containers/auth.json` next to it holds - registry credentials: - - ```jsonc - // /.claude/settings.local.json - { - "sandbox": { - "filesystem": { - "allowRead": [ - "~/.config/containers/podman-connections.json" // machine connection table; auth.json stays denied - ] - } - } - } - ``` - - Once a machine exists, its API socket lives under - `~/.local/share/containers/podman/machine/` and needs the same - `allowRead` + `allowUnixSockets` pair as the Docker socket above. +- The gateway's `docker`-backend discovery on macOS reads the current `docker context`. + Colima's socket lives under `~/.colima//docker.sock`, and a Colima context already set as active is picked up the same way as Docker Desktop's, with no Colima-specific configuration. +- The gateway's `podman`-backend discovery on Linux reads `$XDG_RUNTIME_DIR/podman/podman.sock` directly, which is rootless Podman's default socket location, again with no separate configuration. +- Do **not** widen `allowRead` to `~/.docker/**`. + The directory holds auth tokens and saved contexts, and the whole point of the framework's `Read(~/.docker/**)` denial is to keep those out of the agent's reach. +- Docker Desktop's CLI binary and plugins still need explicit read access, independently of which socket the CLI talks to. + `docker` on `PATH` is `~/.docker/bin/docker`, a symlink into `/Applications/Docker.app`, and `docker compose` / `docker buildx` are separate binaries under `~/.docker/cli-plugins/`. + Add `~/.docker/bin/` and `~/.docker/cli-plugins/` to `sandbox.filesystem.allowRead` as exact paths rather than the broader `~/.docker/**`, or install `docker` via Homebrew, whose CLI lives on a normal `PATH` directory outside `~/.docker` and needs no extra allow. +- If no Podman machine exists, or it is stopped, run `podman machine init` / `podman machine start` from your own terminal, outside the sandbox. + Verify the result from outside the sandbox too: per the Symptom note above, `podman machine list` run inside the sandbox reports an empty table regardless of the machine's real state. +- When only Podman is installed, the gateway still serves the `docker` CLI. + `DOCKER_HOST` points at the gateway's `docker.sock`, which relays to whichever backend it found, so `docker ps` and friends work through Podman's Docker-compatible API alone. +- For CI / image-build workflows that run inside an adopter repo and need a wider gateway configuration than the reference default (e.g. `--extra-bind-root` on `container-gateway serve`, or any other project-specific sandbox allowance), prefer project scope (`.claude/settings.local.json` in the adopter) over user scope. + That keeps the framework's user-scope reference minimal and makes the widening visible to whoever audits the adopter's repo — a rule that holds for any workflow-specific sandbox widening, not just this one. --- diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index 397250e0b..ef54f1450 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -49,6 +49,12 @@ - [Verify](#verify-3) - [From your own terminal — git's program config](#from-your-own-terminal--gits-program-config) - [Trade-offs](#trade-offs-2) + - [Container gateway](#container-gateway) + - [Why install it](#why-install-it-1) + - [Install (user-scope)](#install-user-scope-4) + - [Egress](#egress) + - [Verify](#verify-4) + - [Trade-offs](#trade-offs-3) - [Syncing user-scope config across machines](#syncing-user-scope-config-across-machines) - [What to track, what not to track](#what-to-track-what-not-to-track) - [Layout](#layout) @@ -456,6 +462,14 @@ below, annotated. ```jsonc { + // The container gateway (tools/container-gateway) is where sandboxed + // podman / docker calls go. Both CLIs honour these variables; the + // sockets are project-relative, so this block is the same for every + // adopter. The gateway is started by the SessionStart hook below. + "env": { + "CONTAINER_HOST": "unix://./.apache-magpie-local/run/podman.sock", + "DOCKER_HOST": "unix://./.apache-magpie-local/run/docker.sock" + }, "sandbox": { "enabled": true, // `excludedCommands` runs the listed commands OUTSIDE the sandbox. @@ -508,6 +522,13 @@ below, annotated. "network": { "allowUnixSockets": [ // macOS only (ignored on Linux): sockets a sandboxed Bash may connect(2) to. A read entry alone lets it stat the file, not talk to it. "/Users//.gnupg/S.gpg-agent.ssh" // gpg-agent's ssh socket — needed for signed commits and pushes over ssh; absolute path (see "SSH agent / Yubikey appears unreachable" in sandbox-troubleshooting.md) + // Per project, local settings (`.claude/settings.local.json`, + // added by hand — see "Container gateway" below) carry the + // container gateway's own sockets here as absolute paths, so a + // sandboxed podman / docker CLI can connect(2) to them: + // "/.apache-magpie-local/run/podman.sock", + // "/.apache-magpie-local/run/docker.sock" + // never the daemon socket itself: that is host access, see sandbox-troubleshooting.md ], "allowedDomains": [ // every host the framework legitimately reaches "github.com", "api.github.com", "api.bitbucket.org", @@ -2295,6 +2316,127 @@ To undo it: `git config --global --unset gpg.ssh.program` and `git config --glob [program config above](#from-your-own-terminal--gits-program-config) is what covers your own git commands. +## Container gateway + +Sandboxed Bash subprocesses cannot reach a container runtime's daemon socket directly: the daemon is root-equivalent over whatever it mounts, so allowing that socket in `sandbox.network.allowUnixSockets` would hand the agent unrestricted host access. +The [container gateway](../../tools/container-gateway/README.md) is a per-project policy proxy that sits in front of the real `podman` / `docker` daemon socket, runs **outside** the sandbox where it can hold that connection, and exposes two policy-checked sockets of its own for the sandboxed CLI to talk to instead. +Every request the gateway forwards is filtered to this project's own resources and stripped of anything that would turn a container into host access. +The full refusal table is in the tool's README. +This is the same *socket gateways* layer [RFC-AI-0004](../rfcs/RFC-AI-0004.md) Principle 2 names alongside the [egress gateway](../../tools/egress-gateway/tool.md), and the container gateway hands that egress gateway to every container it starts as its HTTP proxy, so container traffic is bound by the same host allow-list as the sandboxed shell. + +### Why install it + +- **Containers only.** + The agent reaches the daemon exclusively through the API surface the gateway forwards, and every request shape that would turn a container into host access is stripped or refused. +- **This project's containers only.** + Every resource the gateway creates is labelled with the project slug, and every read or act call is filtered to that label, so two projects sharing one daemon see disjoint worlds. +- **Same egress policy as the shell.** + Containers get the egress gateway as their HTTP proxy, so tools inside them that honour proxy variables are bound by the same host allow-list as sandboxed commands. + +### Install (user-scope) + +```bash +mkdir -p ~/.claude/scripts +cp /path/to/magpie/tools/agent-isolation/container-gateway-hook.sh ~/.claude/scripts/ +chmod +x ~/.claude/scripts/container-gateway-hook.sh +mkdir -p ~/.claude/scripts/container-gateway/src +cp -r /path/to/magpie/tools/container-gateway/src/container_gateway \ + ~/.claude/scripts/container-gateway/src/container_gateway +``` + +The hook executes only code from a location the operator installed or +pinned, never from the repository being opened, so the second copy is +not optional: without it the hook finds no source at session start and +is a silent no-op — it never fails the session, it simply never starts +the gateway. A framework contributor working inside this checkout can +instead export `MAGPIE_CONTAINER_GATEWAY_SRC=tools/container-gateway/src` +and skip the copy; an adopter whose `.apache-magpie/` snapshot is +already populated needs neither, since the hook falls back to +`/.apache-magpie/tools/container-gateway/src` on its own. + +Wire it as a `SessionStart` / `SessionEnd` pair in `~/.claude/settings.json`, alongside any other hooks already there: + +```jsonc +{ + "hooks": { + "SessionStart": [ + { "hooks": [ { "type": "command", "command": "~/.claude/scripts/container-gateway-hook.sh start" } ] } + ], + "SessionEnd": [ + { "hooks": [ { "type": "command", "command": "~/.claude/scripts/container-gateway-hook.sh stop" } ] } + ] + } +} +``` + +The framework's own `.claude/settings.json` already carries the `env` half of the project-settings block, using project-relative `unix://` URLs so the same file works in every worktree: + +```jsonc +// .claude/settings.json (committed, project-wide) +{ + "env": { + "CONTAINER_HOST": "unix://./.apache-magpie-local/run/podman.sock", + "DOCKER_HOST": "unix://./.apache-magpie-local/run/docker.sock" + } +} +``` + +`allowUnixSockets` entries need an absolute path, which is per-machine, so they belong in the gitignored `.claude/settings.local.json` instead. +Add the block by hand, substituting your own project's absolute path for `` — nothing writes it for you. +(`setup-isolated-setup-install` Step L proposes the same block as a settings diff; `/magpie-setup config` does **not** write it, and automating it there is a recorded follow-up.) + +```jsonc +// .claude/settings.local.json (gitignored, per machine) +{ + "sandbox": { + "network": { + "allowUnixSockets": [ + "/.apache-magpie-local/run/podman.sock", + "/.apache-magpie-local/run/docker.sock" + ] + } + } +} +``` + +Never add the real daemon socket to `allowUnixSockets` under any name: the framework's `sandbox-lint` tool rejects an entry whose basename is `docker.sock`, `podman.sock`, or ends in `-api.sock`, unless its parent directory is `.apache-magpie-local/run`. + +### Egress + +At start, the gateway resolves the egress gateway's address for each backend and probes it once. +`inject-if-available` (the default) injects `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` into every container it creates when that probe succeeds, and logs a warning instead of failing when it does not. +`require` refuses container creation with `403` while the egress gateway is unreachable, for adopters who want a hard failure rather than a silent gap. +`off` never injects, for adopters running their own container-level filtering. +Set the mode with `MAGPIE_CONTAINER_GATEWAY_ARGS="--egress require"` (or `off`) before the `SessionStart` hook runs. + +On Linux, the address the gateway hands a container for the egress gateway is a fixed guess: `172.17.0.1` for dockerd's default bridge, `10.88.0.1` for rootless Podman. +Neither is guaranteed to match every Linux install's actual bridge address. +Override it with `--egress-host
` in `MAGPIE_CONTAINER_GATEWAY_ARGS` if the default guess is wrong for your host. + +### Verify + +```bash +PYTHONPATH=tools/container-gateway/src python3 -m container_gateway status --project "$PWD" +podman info --format '{{.Host.Hostname}}' # from a sandboxed Bash tool call +podman run --rm -v "$HOME/.ssh:/x" alpine true # expected: 403 container-gateway: bind-mount … +``` + +`status` prints a JSON object (`running`, `pid`, `sockets`, `serving`) and exits 0 when the gateway is up for this project, 3 otherwise. +The `podman info` call should succeed from inside the sandbox once the hook has started the gateway and the two `allowUnixSockets` entries are in place. +The `podman run` call is expected to fail: a bind mount outside the project root (and any `--extra-bind-root`) is exactly what the policy refuses, and the `403` message is the gateway working as intended. + +### Trade-offs + +- **Not a container security boundary.** + The gateway keeps the agent off the daemon socket and off other projects' resources, but it does not harden the container runtime itself. + A malicious image that escapes its container remains the runtime's problem, not this gateway's. +- **Raw sockets bypass the proxy.** + Egress filtering is limited to the proxy-variable injection above, so a raw socket or custom DNS resolution from inside a container is not intercepted. +- **Images are shared across projects.** + Isolation is by label on a daemon and image store shared across every project on the machine, not by a separate daemon or image cache per project. +- **`--volumes-from` is refused.** + A named volume or container must carry this project's label before it can be mounted or referenced, so borrowing another container's volumes across projects does not work through the gateway. + ## Syncing user-scope config across machines The user-scope pieces of the secure setup — @@ -2795,10 +2937,17 @@ below and report ✓ done / ✗ missing / ⚠ partial, with the evidence `sandbox.network.allowedDomains` block, and the `sandbox.filesystem` allowlist (`allowRead`/`allowWrite`). 2. User-scope `~/.claude/settings.json` has the `PreToolUse` - `Bash` matcher wired to a `sandbox-bypass-warn.sh` command - and the `statusLine` command set to `sandbox-status-line.sh`. -3. Both hook scripts exist and are executable + `Bash` matcher wired to a `sandbox-bypass-warn.sh` command, a + `PostToolUse` `Bash` matcher wired to a `sandbox-error-hint.sh` + command, and the `statusLine` command set to + `sandbox-status-line.sh`. A missing `sandbox-error-hint.sh` + wiring is ⚠, not ✗ — it is a discoverability aid for the + failure modes catalogued in + `docs/setup/sandbox-troubleshooting.md`, and its absence does + not break anything on its own. +3. All three hook scripts exist and are executable (`~/.claude/scripts/sandbox-bypass-warn.sh`, + `~/.claude/scripts/sandbox-error-hint.sh`, `~/.claude/scripts/sandbox-status-line.sh`). 4. The `claude-iso` shell function is sourced in `~/.bashrc` or `~/.zshrc`. Note whether `alias claude='claude-iso'` is set. @@ -2813,7 +2962,19 @@ below and report ✓ done / ✗ missing / ⚠ partial, with the evidence `[NO SANDBOX]`). 7. Run `cat ~/.aws/credentials`, `echo $AWS_ACCESS_KEY_ID`, and `curl https://example.com` and confirm each is denied. -8. The **vetted-ops split and exclusion**. Two things, and the +8. **Project-root coverage in the sandbox allowlists.** For this + worktree and every other one `git worktree list --porcelain` + names, confirm the worktree's own absolute path is in that + worktree's own `.claude/settings.local.json` + `sandbox.filesystem.allowRead` and `allowWrite` (per + [apache/magpie#197](https://github.com/apache/magpie/issues/197), + `allowRead: ["."]` does not cover the cwd once the harness + pre-resolves it at session start). Then probe live: a + sandboxed read of `.git/HEAD` and a sandboxed write of a temp + file inside the current worktree's root should both succeed — + the read is the one that actually exercises the bug this check + exists for. +9. **The vetted-ops split and exclusion.** Two things, and the first matters more: - Only `vetted-op-read` is in `permissions.allow`. If `vetted-op` (the write dispatcher) appears in `allow`, that @@ -2825,38 +2986,59 @@ below and report ✓ done / ✗ missing / ⚠ partial, with the evidence (the catalogue) and `.apache-magpie-overrides/tools/vetted-ops/**` (the policy). If the repo has no vetted-ops policy at all, report n/a. -9. If a hardware key signs my commits or authenticates my git - remotes: the touch overlay is wired (`PreToolUse` / - `PostToolUse` `Bash` → - `~/.claude/scripts/gpg-touch-overlay.sh arm` / `disarm`), its - scripts match the framework's `tools/agent-isolation/` copies, - git's own programs point at the wrapper for commands I run - from a terminal (`git config --global --get gpg.ssh.program` - or `gpg.program` names a `gpg-touch-wrap-*` symlink to the - script, `core.sshCommand` is `… gpg-touch-overlay.sh wrap ssh`; - ⚠ if not, since the hook still covers the agent's own git - commands — but ✗ when git names the wrapper and the wrapper's - two files are not in `sandbox.filesystem.allowRead`, because - then every sandboxed signed commit fails with `cannot exec`), - the key's signature and authentication slots carry a touch - policy (`ykman openpgp info`, which I run myself), - and — with `gpg.format=ssh` — the file `git config - user.signingkey` names is readable from a sandboxed Bash (it - needs its own `sandbox.filesystem.allowRead` entry). For the - toolkit probe (`gpg-touch-overlay.sh _gui_available`) hand me - the command to run myself: it cannot see the display from - inside the sandbox. -10. `sandbox.excludedCommands` contains `"gh *"` (project or - user scope), and `permissions.ask` lists the gh write - subcommands one by one — a catch-all `Bash(gh *)` in `ask` - (any scope) is ✗: ask beats allow regardless of specificity, - so it forces a prompt on every read-only gh call the allow - rules were meant to exempt. Note, without failing, that the exclusion only +10. If a hardware key signs my commits or authenticates my git + remotes: the touch overlay is wired (`PreToolUse` / + `PostToolUse` `Bash` → + `~/.claude/scripts/gpg-touch-overlay.sh arm` / `disarm`), its + scripts match the framework's `tools/agent-isolation/` copies, + git's own programs point at the wrapper for commands I run + from a terminal (`git config --global --get gpg.ssh.program` + or `gpg.program` names a `gpg-touch-wrap-*` symlink to the + script, `core.sshCommand` is `… gpg-touch-overlay.sh wrap ssh`; + ⚠ if not, since the hook still covers the agent's own git + commands — but ✗ when git names the wrapper and the wrapper's + two files are not in `sandbox.filesystem.allowRead`, because + then every sandboxed signed commit fails with `cannot exec`), + the key's signature and authentication slots carry a touch + policy (`ykman openpgp info`, which I run myself), + and — with `gpg.format=ssh` — the file `git config + user.signingkey` names is readable from a sandboxed Bash (it + needs its own `sandbox.filesystem.allowRead` entry). For the + toolkit probe (`gpg-touch-overlay.sh _gui_available`) hand me + the command to run myself: it cannot see the display from + inside the sandbox. +11. `sandbox.excludedCommands` contains `"gh *"` (project or + user scope), and `permissions.ask` (project, local, and user + scope alike) lists the gh write subcommands one by one — a + catch-all `Bash(gh *)` in `ask` (any scope) is ✗: ask beats + allow regardless of specificity, so it forces a prompt on + every read-only gh call the allow rules were meant to exempt. + Note, without failing, that the exclusion only applies when every part of a Bash invocation is `cd …` or `gh …` — a pipe, `$(…)`, a loop, or any file redirection puts `gh` back in the sandbox (anthropics/claude-code#95532; see `docs/setup/sandbox-troubleshooting.md` for the shape table and the `gh tofile` alias workaround). +12. **Container gateway wired.** Only meaningful when `podman` or + `docker` is on `PATH`; report n/a otherwise. Four things: + - User-scope `~/.claude/settings.json` has a `SessionStart` + hook running `container-gateway-hook.sh start` and a + `SessionEnd` hook running `container-gateway-hook.sh stop`, + and `~/.claude/scripts/container-gateway-hook.sh` exists and + is executable. + - The project `.claude/settings.json` or + `.claude/settings.local.json` has `env.CONTAINER_HOST` and + `env.DOCKER_HOST`, and both gateway sockets appear in + `sandbox.network.allowUnixSockets`. + - No scope (project, project-local, or user) lists a raw + daemon socket in `allowUnixSockets` — an entry whose + basename is `docker.sock`, `podman.sock`, or ends in + `-api.sock`, unless its parent directory is + `.apache-magpie-local/run`, is ✗: it is the same invariant + `tools/sandbox-lint` enforces. + On any ✗, point at + [`docs/setup/sandbox-troubleshooting.md` → Docker / Podman command fails with a socket error](sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error) + rather than re-explaining the fix. ``` Re-run either form after every Claude Code upgrade — the sandbox diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index cef10247d..5c8316d19 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -591,12 +591,13 @@ Organization scope (declared, orthogonal to vendor): ASF = 14, agnostic = 61. **LLM / agent-integration neutrality** -**Agent harness: 25/25 substrate tools run under any harness unchanged (100%).** Substrate tools are Magpie's own machinery; each declares the agent harness it integrates with (`**Harness:**`), or `agnostic`. A tool is neutral when it is harness-agnostic or supports two or more harnesses; *coupled* when it targets a single harness. +**Agent harness: 26/26 substrate tools run under any harness unchanged (100%).** Substrate tools are Magpie's own machinery; each declares the agent harness it integrates with (`**Harness:**`), or `agnostic`. A tool is neutral when it is harness-agnostic or supports two or more harnesses; *coupled* when it targets a single harness. | Substrate tool | Substrate | Harness support | Verdict | |---|---|---|---| | `agent-guard` | action-guard | Claude Code, Gemini CLI, Kiro, OpenCode | ✅ portable | | `agent-isolation` | sandbox | any | ✅ agnostic | +| `container-gateway` | sandbox | any | ✅ agnostic | | `dashboard-generator` | analytics | any | ✅ agnostic | | `dev` | framework-dev | any | ✅ agnostic | | `egress-gateway` | sandbox | any | ✅ agnostic | @@ -629,7 +630,7 @@ Harness → substrate tools it supports: - **Gemini CLI** (3): `agent-guard`, `sandbox-lint`, `spec-loop` - **Kiro** (3): `agent-guard`, `sandbox-lint`, `spec-loop` - **OpenCode** (3): `agent-guard`, `sandbox-lint`, `spec-loop` -- **any harness** (22): `agent-isolation`, `dashboard-generator`, `dev`, `egress-gateway`, `permission-audit`, `pilot-report-validator`, `pr-management-stats`, `preflight-audit`, `privacy-llm`, `probe-templates`, `reproducible-archive`, `security-tracker-stats-dashboard`, `skill-and-tool-validator`, `skill-evals`, `skill-reconciler-diff`, `skill-token-count`, `spec-inventory`, `spec-status-index`, `spec-validator`, `symlink-lint`, `vendor-neutrality-score`, `vetted-ops` +- **any harness** (23): `agent-isolation`, `container-gateway`, `dashboard-generator`, `dev`, `egress-gateway`, `permission-audit`, `pilot-report-validator`, `pr-management-stats`, `preflight-audit`, `privacy-llm`, `probe-templates`, `reproducible-archive`, `security-tracker-stats-dashboard`, `skill-and-tool-validator`, `skill-evals`, `skill-reconciler-diff`, `skill-token-count`, `spec-inventory`, `spec-status-index`, `spec-validator`, `symlink-lint`, `vendor-neutrality-score`, `vetted-ops` **Model endpoint: neutral by construction — 4 default-approved endpoint classes across independent trust domains, plus adopter opt-in.** From the [`privacy-llm` registry](../tools/privacy-llm/models.md): the framework keys approval on *endpoint identity*, not on who hosts the model, so no single LLM vendor is privileged. diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md index 2b8838fc7..11947bac4 100644 --- a/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md @@ -9,8 +9,8 @@ description: | restrictions that block legitimate workflows in Claude Code, Codex, or Gemini CLI. Runtime-specific diagnostics; Claude has six live probes — SSH agent / Yubikey reachability, localhost port - binding, docker / podman runtime socket, per-project scratch - directory, the ssh signing key's readability, and `gh` running + binding, podman / docker through the container gateway, + per-project scratch directory, the ssh signing key's readability, and `gh` running outside the sandbox — each pointing the user at the matching numbered troubleshooting entry and its settings.json remediation (see body). Read-only — never @@ -69,9 +69,9 @@ the existing setup skills: surfaces drift against the framework's latest. - **`setup-isolated-setup-doctor` (this skill)** answers *"are common workflows **functionally** blocked by the current - sandbox?"* — live probes of SSH agent, port binding, docker / - podman socket, per-project scratch dir. Catches - over-restrictive allowlists. + sandbox?"* — live probes of SSH agent, port binding, podman / + docker through the container gateway, per-project scratch dir. + Catches over-restrictive allowlists. Run `verify` first when the install is in question (fresh machine, recent framework upgrade, sandbox-state surprise). Run @@ -217,43 +217,151 @@ PY **On ✗ → remediation:** [`docs/setup/sandbox-troubleshooting.md` — Test cannot bind to a localhost port](../../../../docs/setup/sandbox-troubleshooting.md#test-cannot-bind-to-a-localhost-port). -### Probe 3 — Docker / Podman runtime socket +### Probe 3 — Podman / Docker through the container gateway -Tests whether the runtime CLI can talk to its daemon. Run for -each of `docker` / `podman` that is on `PATH`; ⊘ each that is -not installed (this is not a sandbox failure, just an absent -prerequisite). +Tests whether the runtime CLI can talk to the [container +gateway](../../../../tools/container-gateway/README.md), not the +real daemon socket — the sandbox never gets a route to the daemon +itself. Run for each of `podman` / `docker` that is on `PATH`; ⊘ +each that is not installed (this is not a sandbox failure, just an +absent prerequisite). Each remaining check narrows down which of +the three wiring pieces (env var, running gateway, allowed socket) +is missing, in the order a fresh install would hit them. **Command:** ```bash -for rt in docker podman; do +# Two candidates, not the hook's three-way order (which also checks +# $MAGPIE_CONTAINER_GATEWAY_SRC and $HOME/.claude/scripts/container-gateway/src +# for an operator install): this probe only ever needs to run the read-only +# `status` subcommand against a source already reachable from this doctor +# session, so it deliberately omits the operator-install path rather than +# widen what the probe depends on being readable. +gw_src=".apache-magpie/tools/container-gateway/src" +[ -d "$gw_src/container_gateway" ] || gw_src="tools/container-gateway/src" +status_json=$(PYTHONPATH="$gw_src" python3 -m container_gateway status --project "$PWD" 2>/dev/null) + +gw_state() { # $1=backend -> serving | not-serving | not-running + printf '%s' "$status_json" | python3 -c " +import json, sys +try: + d = json.load(sys.stdin) +except Exception: + print('not-running'); sys.exit() +if not d.get('running'): + print('not-running') +elif '$1' in d.get('serving', []): + print('serving') +else: + print('not-serving') +" 2>/dev/null +} + +_probe_timeout() { # seconds cmd...; portable across GNU timeout, macOS Homebrew's gtimeout, or neither + local secs="$1" + shift + if command -v timeout > /dev/null 2>&1; then + timeout "$secs" "$@" + elif command -v gtimeout > /dev/null 2>&1; then + gtimeout "$secs" "$@" + else + "$@" & + local cmd_pid=$! + (sleep "$secs" && kill "$cmd_pid" 2> /dev/null) & + local watchdog_pid=$! + wait "$cmd_pid" 2> /dev/null + local rc=$? + kill "$watchdog_pid" 2> /dev/null + wait "$watchdog_pid" 2> /dev/null + return "$rc" + fi +} + +for rt in podman docker; do if ! command -v "$rt" > /dev/null 2>&1; then echo "PROBE: ${rt}-runtime → ⊘ ($rt not on PATH)" continue fi - out=$("$rt" info > /dev/null 2>&1 && echo ok || echo "fail:$?") - case "$out" in - ok) - echo "PROBE: ${rt}-runtime → ✓ (${rt} info returned)" ;; - fail:*) - err=$("$rt" info 2>&1 >/dev/null | head -2 | tr '\n' ' ') - echo "PROBE: ${rt}-runtime → ✗ ($out: $err)" - ;; + case "$rt" in podman) url="${CONTAINER_HOST:-}";; docker) url="${DOCKER_HOST:-}";; esac + if [ -z "$url" ]; then + echo "PROBE: ${rt}-runtime → ✗ ($( [ "$rt" = podman ] && echo CONTAINER_HOST || echo DOCKER_HOST ) unset — gateway not wired into settings)" + continue + fi + sock="${url#unix://}" + case "$(gw_state "$rt")" in + not-running) + echo "PROBE: ${rt}-runtime → ✗ (gateway socket missing at $sock — container gateway not running)" + continue ;; + not-serving) + case "$rt" in + podman) hint="is the Podman machine started" ;; + docker) hint="is Docker Desktop (or the docker daemon) started" ;; + esac + echo "PROBE: ${rt}-runtime → ✗ (gateway running without a $rt backend — $hint? start it, then restart the gateway)" + continue ;; esac + if [ ! -S "$sock" ]; then + echo "PROBE: ${rt}-runtime → ✗ (gateway socket missing at $sock — container gateway not running)" + continue + fi + if _probe_timeout 15 "$rt" info > /dev/null 2>"${TMPDIR:-/tmp}/$rt-probe.err"; then + echo "PROBE: ${rt}-runtime → ✓ ($rt reaches the container gateway at $sock)" + else + rc=$? + err=$(head -1 "${TMPDIR:-/tmp}/$rt-probe.err") + case "$rc" in + 137|143) + # The shell-fallback branch of _probe_timeout kills the child with + # SIGTERM (rc 143) or, if it does not respond, SIGKILL (rc 137); + # `$err` is typically empty in this case, so name the hang instead + # of falling through to an uninformative "rc=143: ". + echo "PROBE: ${rt}-runtime → ✗ (no response in 15s — $rt info hung; is the backend daemon stuck?)" ;; + *) + case "$err" in + *"operation not permitted"*|*"Operation not permitted"*) + echo "PROBE: ${rt}-runtime → ✗ (connect to $sock denied — add it to sandbox.network.allowUnixSockets)" ;; + *"502"*|*"unreachable"*) + echo "PROBE: ${rt}-runtime → ✗ (gateway up, backend down: $err)" ;; + *) echo "PROBE: ${rt}-runtime → ✗ (rc=$rc: $err)" ;; + esac ;; + esac + fi done ``` +`status_json` comes from the gateway's own read-only `status` +subcommand — the doctor may call it from inside the sandbox, +since it neither binds a socket nor touches the daemon. `gw_src` +picks the adopter's pinned snapshot +(`.apache-magpie/tools/container-gateway/src`) when present, else +the framework repo's own tree (`tools/container-gateway/src`), so +the same probe runs in both an adopter checkout and this +framework's own worktree. The `gw_state` check runs **before** the +raw socket-file test: a backend `status` does not list under +`serving` never gets a socket file in the first place, so testing +`-S "$sock"` first would misreport "gateway not running" for the +"running, but this backend's machine/daemon is down" case — the +`-S` test below is a defensive fallback for an already-serving +backend whose socket vanished mid-probe, not the primary check. + **Interpretation:** | Result | Status | Meaning | |---|---|---| -| `✓ info returned` | Pass | The CLI reached the daemon successfully. | -| `✗ fail:1: Cannot connect to the Docker daemon …` | Fail | Daemon socket not readable from inside the sandbox. | -| `✗ fail:1: connect: permission denied` | Fail | Same root cause, different stderr (Linux variant). | -| `✗ fail:125: … podman.sock: connect: no such file or directory` | Warn | Podman on macOS with no machine created (`podman machine list` is empty). Not a sandbox restriction — report it as ⚠, and check `podman system connection list` for the separate `~/.config/containers/podman-connections.json` read denial the catalog's Podman note covers. | +| `✓ reaches the container gateway at ` | Pass | CLI → gateway → daemon all answer. | +| `✗ … unset — gateway not wired into settings` | Fail | The reference `env` block is missing from project settings. | +| `✗ gateway socket missing` | Fail | The `SessionStart` hook did not start the gateway, or it exited; check `/.apache-magpie-local/run/container-gateway.log`. | +| `✗ gateway running without a backend` | Fail | `status` reports the gateway up but `serving` does not list this CLI's backend — the Podman machine or Docker daemon behind it is not running. Start it from outside the sandbox, then restart the gateway. | +| `✗ connect … denied` | Fail | The gateway socket is not in `sandbox.network.allowUnixSockets`. | +| `✗ gateway up, backend down` | Fail | Podman machine / Docker not running on the host; start it from your own terminal. | +| `✗ no response in 15s — info hung` | Fail | `_probe_timeout` killed a stalled ` info` call; the backend daemon behind the gateway is likely wedged — restart it from outside the sandbox. | | `⊘ not on PATH` | Skip | Runtime not installed; not a sandbox restriction. | +An empty `podman machine list` from inside the sandbox is a read +denial on the machine's directory, not proof that no machine +exists — decide the machine's real state from outside the sandbox, +per the catalog entry below. + **On ✗ → remediation:** [`docs/setup/sandbox-troubleshooting.md` — Docker / Podman command fails with a socket error](../../../../docs/setup/sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error). diff --git a/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md index 47fd1841c..7d3d05c97 100644 --- a/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md @@ -702,6 +702,65 @@ checks the resolved path. Nothing wider: not `~/.ssh/`, not `~/.gnupg/`, not `~/ Verification of all four is check 10 of `setup-isolated-setup-verify`; hand off rather than re-checking here. +### Step L — Container gateway (optional) + +Fully optional, **default no**. Ask once whether the operator runs +`podman` or `docker` from inside sandboxed sessions, or wants to +start. On no, skip the step and say so. On yes, the sandboxed CLI +never reaches the real daemon socket directly — it talks to the +[container gateway](../../../../tools/container-gateway/README.md) +instead, a per-project policy proxy that runs outside the sandbox. +Rationale and the full install: +[docs/setup/secure-agent-setup.md → Container gateway](../../../../docs/setup/secure-agent-setup.md#container-gateway). + +**L.1 — Hook script and the package it runs.** Copy +`tools/agent-isolation/container-gateway-hook.sh` into +`~/.claude/scripts/`, `chmod +x` it. The hook itself never executes +code from the repository being opened — only from a location the +operator installed or pinned — so also copy the whole package +`tools/container-gateway/src/container_gateway/` to +`~/.claude/scripts/container-gateway/src/container_gateway/`, next +to the hook. Without this second copy the hook finds no source at +session start and is a silent no-op: it never fails the session, +it simply never starts the gateway. A framework contributor working +in this checkout can instead export +`MAGPIE_CONTAINER_GATEWAY_SRC=tools/container-gateway/src` and skip +the copy; an adopter whose `.apache-magpie/` snapshot is already +populated needs neither, since the hook falls back to +`/.apache-magpie/tools/container-gateway/src` on its own. + +**L.2 — Hooks.** Wire a `SessionStart` hook running +`container-gateway-hook.sh start` and a `SessionEnd` hook running +`container-gateway-hook.sh stop` into `~/.claude/settings.json` — +merging into existing arrays with a diff the operator approves, +exactly as for K.2's touch-overlay hooks. + +**L.3 — Project wiring.** Propose the project `env` block +(`CONTAINER_HOST` / `DOCKER_HOST`, project-relative `unix://` URLs, +committed in `.claude/settings.json`) and the `allowUnixSockets` +pair (absolute paths, per-machine, in the gitignored +`.claude/settings.local.json`) as a single settings diff — the same +two-file split the setup guide documents. Never propose the real +daemon socket under any name; `tools/sandbox-lint` rejects an +`allowUnixSockets` entry named `docker.sock` / `podman.sock` / +`*-api.sock` outside `.apache-magpie-local/run/`. + +Tell the operator plainly, every time this step runs: + +- The framework never starts a Podman machine or Docker Desktop — + discovery happens only at gateway start time, against whatever is + already running. +- When Docker is absent but Podman is present, the gateway still + serves the `docker` CLI, translated onto the Podman backend — the + operator does not need both installed. +- The hook lives in `~/.claude/scripts/`, runs outside the sandbox + on every `SessionStart` / `SessionEnd`, and — per L.1 — only ever + executes the copy installed here, never anything from the project + tree it is about to serve. + +Verification of all four pieces is check 12 of +`setup-isolated-setup-verify`; hand off rather than re-checking here. + ## After the install lands **Tell the operator what to look for in the footer**, and what each diff --git a/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md index 529d583e2..1a720a049 100644 --- a/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md @@ -187,6 +187,14 @@ Walk each: beside them is a symlink to the script, not a copy — nothing to diff, but report it missing when git's `gpg.ssh.program` / `gpg.program` or `core.sshCommand` names it and it is gone), + `~/.claude/scripts/container-gateway-hook.sh` for the container + gateway's `SessionStart` / `SessionEnd` hook (diff against + `tools/agent-isolation/container-gateway-hook.sh`), and the + package it runs, `~/.claude/scripts/container-gateway/src/container_gateway/` + (diff file-by-file against + `tools/container-gateway/src/container_gateway/` — a stale copy + here is a silent behaviour drift, not the no-op a missing copy + is, so it is worth the same drift check as any other script), **and** — *only when whole-user scope is in effect, detected via `git config --global --get core.hooksPath` resolving to diff --git a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md index 3823e234e..fff6aabac 100644 --- a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md @@ -130,7 +130,7 @@ Drift severity: path, the version string, the command output, the `sandbox.enabled` value — never just "✓" or "✗" alone. -## The 11 checks +## The 12 checks The canonical list lives in [docs/setup/secure-agent-setup.md → Verification → Via a Claude Code prompt](../../../../docs/setup/secure-agent-setup.md#via-a-claude-code-prompt-1). @@ -438,6 +438,45 @@ Walk each in order: that alias installed, say so; it is a convenience, not a requirement. +12. **Container gateway wired.** Only meaningful when `podman` or + `docker` is on `PATH`; if neither is installed, report **n/a** + for the whole check. Four sub-checks: + + - **12a — hooks wired.** User-scope `~/.claude/settings.json` + has a `SessionStart` hook running + `container-gateway-hook.sh start` and a `SessionEnd` hook + running `container-gateway-hook.sh stop`. Either missing is + ✗. + - **12b — hook script present.** `~/.claude/scripts/container-gateway-hook.sh` + exists and is executable. Missing or non-executable is ✗. + - **12c — project wiring.** The project `.claude/settings.json` + or `.claude/settings.local.json` (check both — which file + carries the gateway entries depends on which install variant + the adopter chose) has `env.CONTAINER_HOST` and + `env.DOCKER_HOST`, and both gateway sockets appear in + `sandbox.network.allowUnixSockets`. Either half missing + (the `env` pair or the socket allow-list pair) is ✗; report + which half. + - **12d — no raw daemon socket in any scope's `allowUnixSockets`.** + Scan project, project-local, and user scope + (`.claude/settings.json`, `.claude/settings.local.json`, + `~/.claude/settings.json`) for an entry whose basename is + `docker.sock`, `podman.sock`, or ends in `-api.sock`, unless + its parent directory is `.apache-magpie-local/run`. Any hit + is ✗, quoting the offending entry, with this exact note — + it is the same invariant `tools/sandbox-lint` enforces: + + > `sandbox.network.allowUnixSockets: names a + > container daemon socket; route through the container + > gateway (/.apache-magpie-local/run/*.sock) + > instead` + + Install detail: + [`docs/setup/secure-agent-setup.md` → Container gateway](../../../../docs/setup/secure-agent-setup.md#container-gateway). + On any ✗, point at + [`docs/setup/sandbox-troubleshooting.md` → Docker / Podman command fails with a socket error](../../../../docs/setup/sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error) + rather than re-explaining the fix. + ## After the report If every check is ✓, say so explicitly and stop — no further @@ -483,6 +522,16 @@ without invoking it: from a skill): add the exclusion, or replace the catch-all with the explicit write-subcommand list from the reference `.claude/settings.json`; then re-run `setup-isolated-setup-verify`. +- ✗ on check 12a / 12b (hooks or the hook script missing) → + `setup-isolated-setup-install` Step L. +- ✗ on check 12c (project `env` or `allowUnixSockets` half missing) → + `setup-isolated-setup-install` Step L to propose the missing + block as a settings diff for the operator to approve. +- ✗ on check 12d (a raw daemon socket in `allowUnixSockets`) → + the operator removes that entry themselves (settings.json changes + are never applied from a skill) and, if they need the daemon + reachable, follows Step L instead; then re-run + `setup-isolated-setup-verify`. - The user-scope script copies live under `~/.claude-config/` for users who maintain that sync repo; uncommitted local edits there → `setup-shared-config-sync`. diff --git a/pyproject.toml b/pyproject.toml index 8ec2c7e61..6e08286d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ members = [ "tools/agent-isolation", "tools/bitbucket", "tools/egress-gateway", + "tools/container-gateway", "tools/cve-tool-vulnogram/generate-cve-json", "tools/cve-tool-vulnogram/oauth-api", "tools/github-body-field", diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md index 811de7bf8..26a0895a6 100644 --- a/tools/agent-isolation/README.md +++ b/tools/agent-isolation/README.md @@ -90,6 +90,7 @@ per runtime — see [`docs/adapters/add-a-harness.md`](../../docs/adapters/add-a | [`gpg-touch-overlay.sh`](gpg-touch-overlay.sh) | Claude Code `PreToolUse`/`PostToolUse` hook (Bash matcher). Puts a window on screen while a hardware signing key blocks waiting for a touch — the case pinentry never prompts for, and which is indistinguishable from a hung `git commit`. `arm` starts a watcher before a git command that could reach the key — one that signs, or one that talks to a remote over ssh; the watcher shows the window only once the key has actually blocked, stays quiet while pinentry owns the screen, and `disarm` tears the whole process group down afterwards. Watches both signing commands — `gpg`, and the `ssh-keygen -Y sign` git runs under `gpg.format=ssh` — and, for the authentication touch a `git pull` / `push` / `fetch` over ssh asks for, the connection ssh holds open to the agent's socket while its request is out. `wrap` is the same watcher for git commands the agent never runs — a commit or push from the operator's own terminal: git is pointed at the script as its signing program (`gpg.ssh.program` via the argument-free `gpg-touch-wrap-ssh-keygen` symlink, or `gpg.program`) and its ssh command (`core.sshCommand … wrap ssh`), and the script runs the real program with a watcher alive for exactly that long. See [`docs/setup/secure-agent-setup.md` → *Hardware-key touch overlay*](../../docs/setup/secure-agent-setup.md#hardware-key-touch-overlay) and [→ *From your own terminal*](../../docs/setup/secure-agent-setup.md#from-your-own-terminal--gits-program-config). | | [`gpg-touch-overlay-window.py`](gpg-touch-overlay-window.py) | The window itself on Linux: a GTK overlay, one per monitor, that dims the desktop around a pulsing contact ring. Spawned by the watcher, killed by it when the touch lands. Falls back to a `zenity` dialog on a host without PyGObject. | | [`gpg-touch-overlay-window-macos.py`](gpg-touch-overlay-window-macos.py) | The same window on macOS, drawn with Tk — a Mac has neither PyGObject nor zenity, so without this the hook has nothing to show. Main display only, and borderless rather than natively fullscreen so macOS does not switch Spaces out from under the terminal. Takes the keyboard while it is up, so a touch that lands before the key asks for one — which fires the key's OTP slot — types into the overlay instead of whatever was in front. | +| [`container-gateway-hook.sh`](container-gateway-hook.sh) | Claude Code `SessionStart` / `SessionEnd` hook. `start` launches the per-project [container gateway](../container-gateway/) as a detached daemon so sandboxed `podman` / `docker` commands have a policy-checked socket to talk to; `stop` ends it with the session. Finds the gateway in the operator's installed copy (`~/.claude/scripts/container-gateway/src`) or the adopter's `.apache-magpie/` pinned snapshot, and is a silent no-op when neither is present; in-repo copies are never trusted. See [`docs/setup/secure-agent-setup.md` → *Container gateway*](../../docs/setup/secure-agent-setup.md#container-gateway). | | [`claude-term-bg.sh`](claude-term-bg.sh) | **Opt-in quality-of-life helper (not a security control).** Keeps a calm baseline background and tints it only when Claude genuinely wants you to act (never while working, and never when it merely *finished* a turn), so a window you've tabbed away from can't sit blocked unnoticed. Distinguishes "blocked on a decision" from "finished and idle" — which look identical at the `Stop` event — via three signals across six hooks: `Stop` → `stop` (heuristic — tints only if the final assistant message reads as a question/request; a completion stays calm; needs `python3`/`python`, else defaults calm); `PreToolUse` (matcher `AskUserQuestion`) → `wait` (exact — a structured question was posed); `PostToolUse` (matcher `*`) → `reset` (calm while working, and clears the tint the instant you approve a permission prompt or answer a question); `Notification` → `notify` (tints for permission prompts only — the plain idle ping is a no-op so it can't wipe a pending question's tint); and `UserPromptSubmit` + `SessionStart` → `reset` (you replied / fresh session clears any stale tint). Writes the OSC escape to the Claude pty discovered by walking the process tree (hooks have no controlling tty); the only deterministic reset is an explicit `CLAUDE_RESET_BG` colour via OSC 11 (iTerm2 ignores OSC 111). Colours overridable via `CLAUDE_WAIT_BG` / `CLAUDE_RESET_BG`. Tested on iTerm2 + macOS; fail-soft elsewhere. See [`docs/setup/secure-agent-setup.md` → *Waiting-for-input terminal tint*](../../docs/setup/secure-agent-setup.md#waiting-for-input-terminal-tint). | | [`sandbox-add-project-root.sh`](sandbox-add-project-root.sh) | Adds the current adopter repo's project root (and, with `--all-worktrees`, every linked git worktree's working dir) as an explicit absolute path to `sandbox.filesystem.allowRead` and `allowWrite` in the project-local, gitignored `/.claude/settings.local.json` — one entry per worktree, each in that worktree's own settings file. Defensive against [issue #197](https://github.com/apache/magpie/issues/197) — `allowRead: ["."]` does not in practice cover CWD because the harness pre-resolves the `.` literal away from the read side. Never modifies user-scope or committed project-scope. Idempotent, atomic, tolerant of missing prereqs. Invoked from `setup-isolated-setup-install`, `/magpie-setup` (adopt / upgrade / worktree-init), and the `post-checkout` git hook installed by `/magpie-setup adopt`. | | [`git-global-post-checkout.sh`](git-global-post-checkout.sh) | Universal `post-checkout` git hook installed at `~/.claude/git-hooks/post-checkout` when the operator picks the **simple whole-user** flavour in `setup-isolated-setup-install`. Activated by `git config --global core.hooksPath ~/.claude/git-hooks/` so every `git checkout` / `git clone` / `git worktree add` across the host invokes it. Best-effort + idempotent + `\|\| true`: invokes `sandbox-add-project-root.sh` for any worktree with a `.claude/` directory. Trade-off documented in [`docs/setup/secure-agent-setup.md` → *Per-project vs whole-user scope*](../../docs/setup/secure-agent-setup.md#per-project-vs-whole-user-scope): `core.hooksPath` shadows per-repo `.git/hooks/*` across every repo on the host. The **dispatcher** flavour (below) supersedes this file. | diff --git a/tools/agent-isolation/container-gateway-hook.sh b/tools/agent-isolation/container-gateway-hook.sh new file mode 100755 index 000000000..3c2f1b23b --- /dev/null +++ b/tools/agent-isolation/container-gateway-hook.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# container-gateway-hook.sh — Claude Code SessionStart / SessionEnd hook. +# +# Starts the per-project container gateway when a session begins and stops +# it when the session ends, so sandboxed podman / docker commands have a +# socket to talk to. Runs outside the sandbox, like every hook. Never fails +# the session: every exit is 0, and a missing gateway is silently a no-op. +# +# start SessionStart — python3 -m container_gateway serve --project= --daemon +# stop SessionEnd — python3 -m container_gateway stop --project= +# +# Trust model: the hook executes only code from locations the operator installed +# or pinned, never from the repository being opened. This prevents a malicious repo +# from shipping a gateway binary executed with the operator's privileges on +# SessionStart. Sources are looked up in order: $MAGPIE_CONTAINER_GATEWAY_SRC +# (development override), $HOME/.claude/scripts/container-gateway/src (operator +# install), /.apache-magpie/tools/container-gateway/src (pinned snapshot). +# +# Two inherited variables are part of that trust model, because a repository can +# set both through project settings: +# +# PYTHONPATH is *replaced*, never extended. An inherited entry ahead of (or +# behind) the gateway's own source directory can shadow a stdlib module the +# package imports, which would run repository code inside the gateway process. +# +# $MAGPIE_CONTAINER_GATEWAY_ARGS is allow-listed, token by token, against the +# flags below (e.g. "--egress require"). Anything else -- notably +# --extra-bind-root, which widens the bind-mount roots -- makes the hook ignore +# the whole variable and log one line to stderr rather than start a gateway +# with a policy the repository chose. +# +# MAGPIE_CONTAINER_GATEWAY_DRY_RUN=1 prints the command instead of running it. + +set -uo pipefail + +action="${1:-}" +case "$action" in + start|stop) ;; + *) printf '%s: expected start|stop, got "%s"\n' "${0##*/}" "$action" >&2; exit 0 ;; +esac + +payload="$(cat 2>/dev/null || true)" +cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)" +[[ -n $cwd ]] || cwd="$PWD" +root="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$cwd")" +root="$(cd "$root" 2>/dev/null && pwd -P)" || exit 0 + +src="" +for candidate in "${MAGPIE_CONTAINER_GATEWAY_SRC:-}" \ + "$HOME/.claude/scripts/container-gateway/src" \ + "$root/.apache-magpie/tools/container-gateway/src"; do + if [[ -n $candidate && -d $candidate/container_gateway ]]; then + src="$candidate" + break + fi +done +[[ -n $src ]] || exit 0 + +# The only serve flags the hook will pass on from the environment. A value +# may be attached (--egress=require) or follow as the next token +# (--egress require); nothing else is accepted, and one bad token drops the +# whole variable. +allowed_flag='^--(egress|egress-port|egress-host|backend|backend-timeout|idle-timeout|log-level)(=.*)?$' + +vet_extra_args() { + # Echoes the vetted tokens; returns 1 when the variable must be ignored. + local expecting=0 token + for token in "$@"; do + if (( expecting )); then + expecting=0 + continue + fi + [[ $token =~ $allowed_flag ]] || return 1 + [[ $token == *=* ]] || expecting=1 + done + (( expecting == 0 )) || return 1 + printf '%s\n' "$@" +} + +if [[ $action == start ]]; then + # shellcheck disable=SC2206 # word-splitting the extra args is the point + extra=(${MAGPIE_CONTAINER_GATEWAY_ARGS:-}) + if (( ${#extra[@]} )) && ! vet_extra_args "${extra[@]}" >/dev/null; then + printf '%s: ignoring MAGPIE_CONTAINER_GATEWAY_ARGS: %s is not an accepted serve flag\n' \ + "${0##*/}" "${MAGPIE_CONTAINER_GATEWAY_ARGS}" >&2 + extra=() + fi + cmd=(python3 -m container_gateway serve --project="$root" --daemon "${extra[@]}") +else + cmd=(python3 -m container_gateway stop --project="$root") +fi + +if [[ -n ${MAGPIE_CONTAINER_GATEWAY_DRY_RUN:-} ]]; then + printf 'PYTHONPATH=%s %s\n' "$src" "${cmd[*]}" + exit 0 +fi +# PYTHONPATH is replaced, not extended: see the trust-model note above. +PYTHONPATH="$src" "${cmd[@]}" >/dev/null 2>&1 || true +exit 0 diff --git a/tools/agent-isolation/tests/test_container_gateway_hook.py b/tools/agent-isolation/tests/test_container_gateway_hook.py new file mode 100644 index 000000000..7ea45ce6c --- /dev/null +++ b/tools/agent-isolation/tests/test_container_gateway_hook.py @@ -0,0 +1,195 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""The session hook finds the project, finds the gateway, and never blocks a session.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "container-gateway-hook.sh" + + +def run(action: str, cwd: Path, env_extra: dict[str, str] | None = None, payload: dict[str, object] | None = None, home: Path | None = None) -> subprocess.CompletedProcess[str]: + env = {**os.environ, "MAGPIE_CONTAINER_GATEWAY_DRY_RUN": "1"} + if home is not None: + env["HOME"] = str(home) + env.update(env_extra or {}) + return subprocess.run(["bash", str(SCRIPT), action], input=json.dumps(payload or {"cwd": str(cwd)}), + capture_output=True, text=True, env=env, cwd=cwd, check=False) + + +def test_start_uses_snapshot_sources(tmp_path: Path) -> None: + tmp_path = tmp_path.resolve() # the hook prints physical paths (pwd -P); macOS tmp dirs are symlinked + fake_home = tmp_path / "home" + fake_home.mkdir() + src = tmp_path / ".apache-magpie" / "tools" / "container-gateway" / "src" / "container_gateway" + src.mkdir(parents=True) + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + done = run("start", tmp_path, home=fake_home) + assert done.returncode == 0, done.stderr + assert done.stdout.strip() == f"PYTHONPATH={src.parent} python3 -m container_gateway serve --project={tmp_path} --daemon" + + +def test_start_prefers_env_override_and_appends_args(tmp_path: Path) -> None: + tmp_path = tmp_path.resolve() + fake_home = tmp_path / "home" + fake_home.mkdir() + alt = tmp_path / "alt-src" + (alt / "container_gateway").mkdir(parents=True) + done = run("start", tmp_path, {"MAGPIE_CONTAINER_GATEWAY_SRC": str(alt), "MAGPIE_CONTAINER_GATEWAY_ARGS": "--egress require"}, home=fake_home) + assert done.stdout.strip().endswith(f"serve --project={tmp_path} --daemon --egress require") + assert f"PYTHONPATH={alt}" in done.stdout + + +def test_stop_command(tmp_path: Path) -> None: + tmp_path = tmp_path.resolve() + fake_home = tmp_path / "home" + fake_home.mkdir() + src = tmp_path / ".apache-magpie" / "tools" / "container-gateway" / "src" / "container_gateway" + src.mkdir(parents=True) + done = run("stop", tmp_path, home=fake_home) + assert done.stdout.strip().endswith(f"stop --project={tmp_path}") + + +def test_no_sources_is_silent_success(tmp_path: Path) -> None: + fake_home = tmp_path / "home" + fake_home.mkdir() + done = run("start", tmp_path, home=fake_home) + assert done.returncode == 0 and done.stdout == "" + + +def test_in_repo_sources_are_ignored(tmp_path: Path) -> None: + """In-repo sources are never trusted, even when present.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + (tmp_path / "tools" / "container-gateway" / "src" / "container_gateway").mkdir(parents=True) + done = run("start", tmp_path, home=fake_home) + assert done.returncode == 0 and done.stdout == "" + + +def test_user_scope_copy_is_preferred_over_snapshot(tmp_path: Path) -> None: + """Operator-installed copy in $HOME/.claude/scripts takes precedence over snapshot.""" + tmp_path = tmp_path.resolve() + fake_home = tmp_path / "home" + fake_home.mkdir() + proj = tmp_path / "proj" + proj.mkdir() + # Create snapshot under proj/.apache-magpie + snap_src = proj / ".apache-magpie" / "tools" / "container-gateway" / "src" / "container_gateway" + snap_src.mkdir(parents=True) + # Create HOME copy under fake_home/.claude/scripts + user_src = fake_home / ".claude" / "scripts" / "container-gateway" / "src" / "container_gateway" + user_src.mkdir(parents=True) + subprocess.run(["git", "init", "-q", str(proj)], check=True) + done = run("start", proj, home=fake_home) + assert done.returncode == 0, done.stderr + # Should use the user-scope path, not the snapshot + assert f"PYTHONPATH={fake_home}/.claude/scripts/container-gateway/src" in done.stdout + + +def test_snapshot_used_when_user_copy_absent(tmp_path: Path) -> None: + """When only the snapshot exists, it is used.""" + tmp_path = tmp_path.resolve() + fake_home = tmp_path / "home" + fake_home.mkdir() + proj = tmp_path / "proj" + proj.mkdir() + # Create only snapshot under proj/.apache-magpie + snap_src = proj / ".apache-magpie" / "tools" / "container-gateway" / "src" / "container_gateway" + snap_src.mkdir(parents=True) + subprocess.run(["git", "init", "-q", str(proj)], check=True) + done = run("start", proj, home=fake_home) + assert done.returncode == 0, done.stderr + # Should use the snapshot path + assert f"PYTHONPATH={snap_src.parent}" in done.stdout + + +def test_bad_action_exits_zero_with_message(tmp_path: Path) -> None: + fake_home = tmp_path / "home" + fake_home.mkdir() + done = run("frobnicate", tmp_path, home=fake_home) + assert done.returncode == 0 and "expected start|stop" in done.stderr + + +# --- I5: the inherited environment is not part of the trust model --- + + +def _project_with_snapshot(tmp_path: Path) -> tuple[Path, Path, Path]: + tmp_path = tmp_path.resolve() + fake_home = tmp_path / "home" + fake_home.mkdir() + src = tmp_path / ".apache-magpie" / "tools" / "container-gateway" / "src" + (src / "container_gateway").mkdir(parents=True) + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + return tmp_path, fake_home, src + + +def test_inherited_pythonpath_is_replaced_not_extended(tmp_path: Path) -> None: + """A repo-settable PYTHONPATH could shadow a stdlib module the package imports.""" + project, fake_home, src = _project_with_snapshot(tmp_path) + shadow = project / "evil" + shadow.mkdir() + done = run("start", project, {"PYTHONPATH": str(shadow)}, home=fake_home) + assert done.returncode == 0, done.stderr + assert done.stdout.startswith(f"PYTHONPATH={src} ") + assert str(shadow) not in done.stdout + + +@pytest.mark.parametrize( + "args", + [ + "--extra-bind-root /", + "--extra-bind-root=/Users", + "--project /elsewhere", + "--run-dir /tmp/x", + "--egress require --extra-bind-root /", + "--pid-file /tmp/x.pid", + "not-a-flag", + "--egress", # a flag whose value never arrives + ], +) +def test_unvetted_extra_args_are_ignored_whole(tmp_path: Path, args: str) -> None: + project, fake_home, _ = _project_with_snapshot(tmp_path) + done = run("start", project, {"MAGPIE_CONTAINER_GATEWAY_ARGS": args}, home=fake_home) + assert done.returncode == 0, done.stderr + assert done.stdout.strip().endswith(f"serve --project={project} --daemon") + assert "ignoring MAGPIE_CONTAINER_GATEWAY_ARGS" in done.stderr + + +@pytest.mark.parametrize( + "args", + [ + "--egress require", + "--egress=require", + "--egress-port 8899", + "--egress-host 10.88.0.1", + "--backend podman --log-level DEBUG", + "--idle-timeout 60 --backend-timeout 30", + ], +) +def test_vetted_extra_args_are_passed_through(tmp_path: Path, args: str) -> None: + project, fake_home, _ = _project_with_snapshot(tmp_path) + done = run("start", project, {"MAGPIE_CONTAINER_GATEWAY_ARGS": args}, home=fake_home) + assert done.returncode == 0, done.stderr + assert done.stdout.strip().endswith(f"--daemon {args}") + assert done.stderr == "" diff --git a/tools/container-gateway/.gitignore b/tools/container-gateway/.gitignore new file mode 100644 index 000000000..a230a78ae --- /dev/null +++ b/tools/container-gateway/.gitignore @@ -0,0 +1,2 @@ +.venv/ +__pycache__/ diff --git a/tools/container-gateway/README.md b/tools/container-gateway/README.md new file mode 100644 index 000000000..0a2717a45 --- /dev/null +++ b/tools/container-gateway/README.md @@ -0,0 +1,149 @@ + + + + +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [container-gateway](#container-gateway) + - [Prerequisites](#prerequisites) + - [Run it](#run-it) + - [Point the CLIs at it](#point-the-clis-at-it) + - [What the policy refuses](#what-the-policy-refuses) + - [Egress modes](#egress-modes) + - [Socket paths](#socket-paths) + - [Test](#test) + - [Caveat — containers only, not a container security boundary](#caveat--containers-only-not-a-container-security-boundary) + - [Limits and residual risks](#limits-and-residual-risks) + + + +# container-gateway + +**Capability:** substrate:sandbox + +**Harness:** agnostic + +A per-project **policy proxy in front of the container daemon socket**. +Sandboxed shell commands talk to it through `CONTAINER_HOST` / `DOCKER_HOST`; it forwards the Docker-compatible API to podman or docker after labelling every resource with the project, filtering every call to that label, and refusing any request that would turn a container into host access. +Companion to [`tools/egress-gateway`](../egress-gateway/): that one bounds which hosts tools may reach, this one bounds what containers may touch. +The contract (what / why) is in [`tool.md`](tool.md); this file is the how-to. + +## Prerequisites + +- **Runtime:** Python 3.11+ stdlib only; run with `python3 -m container_gateway` from `src/`, or `uv run --directory tools/container-gateway container-gateway`. +- **CLIs:** `podman` and/or `docker` on the host (each optional); on macOS a running Podman machine or Docker Desktop. +- **Credentials / auth:** None. The gateway never reads the machine ssh identity, `~/.docker/config.json` or `~/.config/containers/auth.json`; pulls are anonymous. +- **Network:** None of its own. Connects only to the local daemon unix socket and, once at start, probes the egress gateway on loopback. +- **Optional:** the `dev` dependency group (pytest, ruff, mypy); a running egress gateway for the proxy-injection mode. + +## Run it + +One gateway process per project, keyed by the project root. +It must run **outside** the sandbox. +It connects to the real daemon socket, which the sandbox denies by design. + +```bash +uv run --project tools/container-gateway container-gateway --project . +``` + +It listens on two unix sockets under `/.apache-magpie-local/run/`: `podman.sock` (libpod + compat API, for the podman CLI) and `docker.sock` (compat API, for the docker CLI). +Configuration is CLI flags with environment-variable equivalents and no config file: `--project`, `--run-dir`, `--backend podman|docker|auto` (repeatable), `--egress inject-if-available|require|off`, `--egress-port`, `--extra-bind-root` (repeatable), `--idle-timeout`, `--log-level`, `--pid-file`. +A second start for the same project is a no-op when the pid file names a live process. +It exits on `SessionEnd`, on `SIGTERM`, or after an idle timeout (default 4h) as a backstop for sessions that end without the hook firing. + +## Point the CLIs at it + +```bash +export CONTAINER_HOST=unix://./.apache-magpie-local/run/podman.sock +export DOCKER_HOST=unix://./.apache-magpie-local/run/docker.sock +``` + +The podman CLI needs the libpod API and therefore only ever talks to a podman backend. +The docker CLI talks to a docker backend when one exists, otherwise to podman's compat API. +Persist these per-machine in `.claude/settings.local.json`'s `env` block, and allow the two sockets in `sandbox.network.allowUnixSockets`, never the real daemon socket. + +## What the policy refuses + +The policy is a pure function over the parsed request (method, normalised path, query, JSON body), applied identically to the compat and libpod path families. +Every container and pod is labelled with the project slug, and every list / act call is filtered to that label. + +**The create body, the exec body, the update body and the build query are allow-lists.** +A create body may carry only the fields the gateway has learned — the ones it reasons about, plus the inert ones a real `docker run` / `podman run` sends — and any other key, at the top level or under `HostConfig`, comes back as `unknown-field`. +The fields that turn a container into host access on their own (`rootfs`, `overlay_volumes`, `env_host`, `log_configuration`, `secret_env` / `secrets`, `cni_networks`, compat `Links` / `Cgroup` / `ContainerIDFile` / `VolumeDriver`) are refused with a reason of their own, and only when they carry a value: both CLIs send the zero value of every field on every create. + +The create-time rules below apply to containers and pods (the same fields under `HostConfig` in compat and at top level in libpod): + +| Field | Rule | +|---|---| +| `Privileged` | deny | +| `CapAdd` | deny any; `CapDrop` allowed | +| `Devices`, `DeviceRequests`, `DeviceCgroupRules` | deny | +| `PidMode`, `IpcMode`, `UTSMode`, `UsernsMode`, `CgroupnsMode` | deny `host` and `container:` unless `` carries the label | +| `NetworkMode` | deny `host`; `container:` only with the label; named networks must carry the label | +| `SecurityOpt` | deny `seccomp=unconfined`, `apparmor=unconfined`, `label=disable`, `no-new-privileges=false`, `systempaths=unconfined` | +| `Sysctls`, `CgroupParent`, `Runtime`, `Isolation` | deny | +| `MaskedPaths`, `ReadonlyPaths` | deny when set to an empty list | +| `Binds`, `Mounts[type=bind]`, libpod `mounts` | source must resolve (symlinks followed, on the host) under the project root or a `--extra-bind-root`; anything else denied. `tmpfs` allowed | +| `Mounts[type=volume]`, named volumes in `Binds`, `VolumesFrom` | the volume / container must carry the label; a volume driver configuration (compat `VolumeOptions.DriverConfig`, libpod `volume-opt=`) is refused whatever the driver, since `local` with `type=none,device=/,o=bind` is a host-root bind | +| `PortBindings` / `publish` | allowed; an empty `HostIp` is rewritten to `127.0.0.1` | +| `LogConfig` | deny any driver but `json-file`, `local`, `none` or unset; options (`--log-opt`, compose `logging.options`) only on `json-file` / `local`, and only rotation-shaped keys — `path` is refused | +| `Env` | proxy variables injected per the egress rule below; a client-supplied value for the same names is replaced | + +`POST /build` gets the same treatment: `volume`, `remote`, `securityopt`, `cgroupparent`, `ulimits`, `devices`, `secrets`, `ssh`, `session`, podman's `addcaps` / `labelopts` / `extrahosts`, a `networkmode` outside the keyword set above, an `nsoptions` entry joining a host namespace other than `user`, and an `output` / `outputs` that names a filesystem destination — a path-shaped bare value, a `local` / `tar` / `oci` exporter, any `dest=` attribute, anything but `type=image` / `type=registry` in the JSON or comma form — are all refused, as is any parameter the gateway has not learned. +A build also gets the egress proxy merged into its `buildargs`, so `RUN` obeys the same allow-list a container does. +An exec body is allow-listed to the exec fields, with `Privileged: true` refused; an update body to resource limits and the restart policy. + +A denial comes back as `403` with a one-line reason both CLIs print verbatim. +`auth` (registry login), image push, swarm, services, tasks, nodes, plugins, secrets, configs, distribution, session and `system/dial-stdio` are denied outright, along with any path not in the allowed families. +So are `checkpoint`, `restore`, `generate` and `play` (the first two write a host path daemon-side, the last two hand the daemon a Kubernetes manifest), and `export` / `import` as query parameters on any route. + +## Egress modes + +At start the gateway resolves the egress gateway address for each backend and probes it once. +`inject-if-available` (the default) injects `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` into every container it creates when the probe succeeded, and logs one warning per session otherwise. +`require` refuses container creation with `403` while the egress gateway is unreachable. +`off` never injects, for adopters who run their own filtering. +This is the extent of the network control. +`--network host` is denied above, but a raw socket or custom DNS from inside a container is not intercepted. + +## Socket paths + +Verification of project-relative socket paths is pending; see the implementation plan's Task 1. +Task 12 records the result here. + +## Test + +```bash +uv run --project tools/container-gateway --group dev pytest +``` + +Unit tests are table-driven over the policy families and need no backend. +Integration tests (`-m integration`) exercise whichever real backend is installed and auto-skip when none is. + +## Caveat — containers only, not a container security boundary + +The gateway keeps the agent off the daemon socket and off resources outside its own project's label; it does not harden the container runtime itself. +The runtime remains the real boundary between a container and the VM or host kernel. +A malicious image that escapes its container is not this gateway's problem to solve. +Network filtering is limited to the proxy-variable injection above; raw sockets and DNS from inside a container are not intercepted. + +## Limits and residual risks + +Known and accepted, in the order you are likely to meet them: + +- **An unknown field is refused, so a new daemon feature is unavailable until the gateway learns it.** + That is the allow-list working as designed; the `403` names the field, which is the signal to add it to `policy_shape.py` (create / exec / update) or to `decisions.py` (build query) with a test. +- **The run directory's trust anchor is `--project` (resolved), or a custom `--run-dir`'s own parent (resolved).** + A symlink in the ancestor chain above that anchor is the host's own layout — `/tmp` and `/var` are symlinks on macOS, a home directory can sit on a linked volume — and is followed, not refused. + Below the anchor, every component the gateway itself creates is checked with `lstat`: a symlink, a foreign owner or a group- or world-writable mode there is refused, and the pid file and the daemon log are opened `O_NOFOLLOW` regardless. +- **A bind source is checked on the host at decision time and re-resolved by the daemon at mount time.** + A symlink swapped between those two moments is not caught — the check and the mount are two separate resolutions of the same path, and the gateway holds no lock on the filesystem in between. +- **Images are shared across projects by design.** + Pull, list, inspect, history, save and build are allowed on any image on the host; only remove and tag are label-checked. A project can therefore see, and run, an image another project pulled. +- **`/info`, `/version` and `/_ping` return host-level daemon facts** — the daemon's version, its storage driver, the number of containers on the whole host — not a per-project view. +- **Container egress is a friction layer, not a wall.** + The gateway injects proxy variables; a tool that ignores them, or a raw socket, or DNS, goes straight out. RFC-AI-0004 says the same of the permission layer. +- **Backend discovery happens at start.** + A Podman machine or Docker Desktop started after the gateway is not picked up until the gateway restarts, which normally means the next session. +- **A bare relative build-output value with no leading `.`, `~` or `/` (`-o outdir`) is indistinguishable from an image reference and passes**, since it is resolved against the daemon's own working directory, not a path the agent chose. diff --git a/tools/container-gateway/pyproject.toml b/tools/container-gateway/pyproject.toml new file mode 100644 index 000000000..152e34c34 --- /dev/null +++ b/tools/container-gateway/pyproject.toml @@ -0,0 +1,62 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "container-gateway" +version = "0.1.0" +description = "Per-project policy proxy in front of the podman / docker daemon socket, so sandboxed shell commands can drive containers without reaching the daemon, the machine identity or the host filesystem." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +dependencies = [] + +[project.scripts] +container-gateway = "container_gateway.__main__:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/container_gateway"] + +[tool.ruff] +line-length = 110 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "C4", "RUF"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.11" +files = ["src"] +warn_unused_ignores = true +strict = true + +[tool.pytest.ini_options] +minversion = "8.0" +# A wedged test must dump every thread's stack and fail the run in a +# minute, not sit there until CI's job limit cancels the job with no +# output at all. pytest built-in; no extra dependency. +faulthandler_timeout = 60 +addopts = "-ra -q -m 'not integration'" +testpaths = ["tests"] +markers = ["integration: needs a real podman or docker backend; run with -m integration"] + +[dependency-groups] +dev = ["magpie-dev"] diff --git a/tools/container-gateway/src/container_gateway/__init__.py b/tools/container-gateway/src/container_gateway/__init__.py new file mode 100644 index 000000000..34a2243a4 --- /dev/null +++ b/tools/container-gateway/src/container_gateway/__init__.py @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Per-project policy proxy in front of the podman / docker daemon socket.""" + +__version__ = "0.1.0" diff --git a/tools/container-gateway/src/container_gateway/__main__.py b/tools/container-gateway/src/container_gateway/__main__.py new file mode 100644 index 000000000..f47be8a73 --- /dev/null +++ b/tools/container-gateway/src/container_gateway/__main__.py @@ -0,0 +1,291 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""``python3 -m container_gateway`` -- serve, stop, status.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import re +import signal +import sys +import time +from pathlib import Path + +from . import backends as _backends +from . import daemon + + +def _common(p: argparse.ArgumentParser) -> None: + p.add_argument("--project", type=Path, default=Path.cwd(), help="project root (default: cwd)") + p.add_argument( + "--run-dir", type=Path, help="socket + pid directory (default: /.apache-magpie-local/run)" + ) + p.add_argument( + "--pid-file", + type=Path, + help="pid file (default: /container-gateway.pid); must not be a symlink; created 0600 with O_NOFOLLOW", + ) + + +def _absolute(path: Path) -> Path: + """Make ``path`` absolute without resolving symlinks (unlike ``Path.resolve()``). + + Every path this module hands to ``daemon``'s guards must still show + every symlink a path component might be, so those guards can refuse + one; lexical normalisation (joining onto the cwd) is safe, chasing + symlinks to find out where they really point is exactly what must + not happen before the guard runs. + """ + return path if path.is_absolute() else Path.cwd() / path + + +def _config(ns: argparse.Namespace) -> daemon.Config: + root = ns.project.resolve() # the trust anchor: the operator's own --project value + run_dir = _absolute(ns.run_dir) if ns.run_dir is not None else root / ".apache-magpie-local" / "run" + pid_file = _absolute(ns.pid_file) if ns.pid_file is not None else run_dir / "container-gateway.pid" + return daemon.Config( + project_root=root, + run_dir=run_dir, + backends=tuple(ns.backend) if getattr(ns, "backend", None) else ("podman", "docker"), + egress_mode=getattr(ns, "egress", "inject-if-available"), + egress_port=getattr(ns, "egress_port", 8899), + egress_host=getattr(ns, "egress_host", None), + extra_bind_roots=tuple(getattr(ns, "extra_bind_root", None) or ()), + idle_timeout=getattr(ns, "idle_timeout", 4 * 3600.0), + log_level=getattr(ns, "log_level", "INFO"), + pid_file=pid_file, + backend_timeout=getattr(ns, "backend_timeout", 60.0), + ) + + +def _open_log_fd(log_path: Path) -> int: + """Open the daemon log, never following a symlink at that exact path.""" + try: + return os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600) + except OSError as exc: + print(f"container-gateway: {log_path} could not be opened safely (symlink?): {exc}", file=sys.stderr) + raise SystemExit(2) from exc + + +def _daemonize(log_path: Path) -> None: + """Detach from the controlling terminal: the classic double-fork. + + Not exercised by this unit-test suite -- the sandbox denies the + ``bind()`` a real ``serve()`` needs downstream of this anyway, and a + ``fork()`` that then races the test process's own event loop and file + descriptors is not something a unit test can observe safely. Covered + by the Task 15 integration run instead. ``_open_log_fd`` above, the + one part of this that can raise on a hostile input, is unit-tested + directly. + """ + if os.fork(): + os._exit(0) + os.setsid() + if os.fork(): + os._exit(0) + os.chdir("/") + os.umask(0o077) + log_fd = _open_log_fd(log_path) + devnull_fd = os.open(os.devnull, os.O_RDONLY) + os.dup2(devnull_fd, 0) + os.dup2(log_fd, 1) + os.dup2(log_fd, 2) + os.close(log_fd) + os.close(devnull_fd) + + +def cmd_serve(ns: argparse.Namespace) -> int: + cfg = _config(ns) + daemon.check_run_dir(cfg.run_dir, cfg.project_root) + running, _ = daemon.probe_pid_lock(cfg.pid_file) + if running: + return 0 # already running for this project + if ns.daemon: + _daemonize(cfg.run_dir / "container-gateway.log") # Task 15 integration run covers this path + return asyncio.run(daemon.run(cfg)) + + +# Matches only the argv shapes `cmd_serve`/the hook actually invoke: a +# python interpreter -- optionally through a runner like `uv run` and/or +# interpreter flags such as `-u` -- running the `container_gateway` module, +# or a `container-gateway` console-script executable, each optionally +# through a path prefix and followed by more argv (or end of line). A plain +# substring check (`"container_gateway" in line`) would also match an +# editor opened on a file named `container_gateway.py`, which is not this +# gateway. +_GATEWAY_ARGV_RE = re.compile( + r"^(?:\S*/)?(?:uv\s+run\s+)?(?:\S*/)?python\d*(?:\.\d+)?(?:\s+-\S+)*\s+-m\s+container_gateway(?:\s|$)" + r"|^(?:\S*/)?container-gateway(?:\s|$)" +) + + +def _looks_like_a_gateway_process(pid: int) -> bool: + """Refuse to signal anything that does not look like this gateway. + + ``stop`` reads its target pid out of a file under a run directory the + sandboxed agent can otherwise only plant, not forge (D1's validation + plus a pid file that must be a regular, euid-owned, mode-0600 file + closes every forging route) -- but the agent could still start some + other long-lived process of its own, of a pid the operator's own + ``container-gateway serve`` later happens to reuse after the agent + wrote it into a (by then legitimately 0600) pid file through a prior + run's cleanup race. Checking the live process's own command line + before ever signalling it guards against that accidental reuse; an + unreadable command line (``ps`` unavailable, the process already + gone) refuses rather than guesses. This is a guard, not a security + boundary: a process's command line (``argv[0]`` included) is + attacker-settable, so it cannot stop a deliberate adversary already + running arbitrary code from shaping its own command line to match. + + The check is an argv-shape match against the first line of ``ps`` + output only, not a substring search: a substring match would also + treat ``vim container_gateway.py`` (an editor opened on a source + file) as the gateway. + """ + line = _backends.default_runner(["ps", "-o", "command=", "-p", str(pid)]) + if not line: + return False + first_line = line.splitlines()[0].strip() + return _GATEWAY_ARGV_RE.match(first_line) is not None + + +def cmd_stop(ns: argparse.Namespace) -> int: + cfg = _config(ns) + if not cfg.project_root.is_dir(): + # A missing project root and a missing run directory both make + # validate_run_dir return False, but they are different situations + # to report: this one means the --project value itself is wrong. + print(f"container-gateway: no project root at {cfg.project_root}: nothing to stop") + return 0 + if not daemon.validate_run_dir(cfg.run_dir, cfg.project_root): + # Never served, or the run directory itself no longer exists. Not an + # error -- but silent success here reads identically to "stopped it + # successfully", which is misleading when nothing was ever running. + print(f"container-gateway: no run directory at {cfg.run_dir}: nothing to stop") + return 0 + trust = daemon.pid_file_is_trustworthy(cfg.pid_file) + if trust is None: + return 0 # no pid file yet: nothing to stop + if trust is False: + print( + f"container-gateway: {cfg.pid_file} is not a plain, owned, 0600 pid file; refusing", + file=sys.stderr, + ) + raise SystemExit(2) + running, pid = daemon.probe_pid_lock(cfg.pid_file) + if not running: + return 0 + if pid is None: + # Something holds the lock but the pid file's content is missing + # or unsafe to trust -- there is nothing we can safely signal. + return 1 + if not _looks_like_a_gateway_process(pid): + print( + f"container-gateway: pid {pid} does not look like a container-gateway process; refusing to signal", + file=sys.stderr, + ) + return 1 + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + running, _ = daemon.probe_pid_lock(cfg.pid_file) + if not running: + return 0 + time.sleep(0.1) + running, _ = daemon.probe_pid_lock(cfg.pid_file) + if running: + # pid_alive is a fallback log detail here, never the decision -- + # the lock probe just above is what running/not-running means. + print( + f"container-gateway: pid {pid} still holds the lock 5s after SIGTERM (pid_alive={daemon.pid_alive(pid)})", + file=sys.stderr, + ) + return 0 if not running else 1 + + +def _status_payload(running: bool, pid: int | None, run_dir: Path) -> dict[str, object]: + p = daemon.paths(run_dir) + serving = [k for k in ("podman", "docker") if running and p[k].exists()] + sockets = {k: (str(p[k]) if k in serving else None) for k in ("podman", "docker")} + return {"running": running, "pid": pid if running else None, "sockets": sockets, "serving": serving} + + +def cmd_status(ns: argparse.Namespace) -> int: + cfg = _config(ns) + if not daemon.validate_run_dir(cfg.run_dir, cfg.project_root): + print(json.dumps(_status_payload(False, None, cfg.run_dir))) + return 3 + trust = daemon.pid_file_is_trustworthy(cfg.pid_file) + if trust is not True: + # Missing: never served. Untrustworthy: report not-running without + # touching a file that failed the ownership/type/mode check -- + # `status` never deletes something it does not trust. + print(json.dumps(_status_payload(False, None, cfg.run_dir))) + return 3 + running, pid = daemon.probe_pid_lock(cfg.pid_file) + print(json.dumps(_status_payload(running, pid, cfg.run_dir))) + return 0 if running else 3 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="container-gateway") + sub = parser.add_subparsers(dest="cmd", required=True) + s = sub.add_parser("serve", help="run the gateway for one project") + _common(s) + s.add_argument( + "--backend", + action="append", + choices=["podman", "docker"], + help="serve only this backend (repeatable)", + ) + s.add_argument( + "--egress", choices=["inject-if-available", "require", "off"], default="inject-if-available" + ) + s.add_argument("--egress-port", type=int, default=8899) + s.add_argument("--egress-host", help="override the in-container address of the egress gateway") + s.add_argument( + "--extra-bind-root", + action="append", + type=Path, + help="additional allowed bind-mount root (repeatable)", + ) + s.add_argument( + "--idle-timeout", type=float, default=4 * 3600.0, help="seconds without a connection before exiting" + ) + s.add_argument( + "--backend-timeout", + type=float, + default=60.0, + help="seconds before a stalled backend call becomes a 502", + ) + s.add_argument("--log-level", default="INFO") + s.add_argument("--daemon", action="store_true", help="detach and log to /container-gateway.log") + s.set_defaults(fn=cmd_serve) + for name, fn in (("stop", cmd_stop), ("status", cmd_status)): + q = sub.add_parser(name) + _common(q) + q.set_defaults(fn=fn) + ns = parser.parse_args(argv) + return int(ns.fn(ns)) + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/tools/container-gateway/src/container_gateway/backends.py b/tools/container-gateway/src/container_gateway/backends.py new file mode 100644 index 000000000..ce07d80ae --- /dev/null +++ b/tools/container-gateway/src/container_gateway/backends.py @@ -0,0 +1,89 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Find the daemon sockets the gateway may forward to. Each backend is optional.""" + +from __future__ import annotations + +import subprocess +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path + +Runner = Callable[[list[str]], str | None] + + +@dataclass(frozen=True) +class Backend: + kind: str + socket: Path + host_alias: str + started_service: bool = False + + +def default_runner(argv: list[str]) -> str | None: + try: + done = subprocess.run(argv, capture_output=True, text=True, timeout=10, check=False) + except (OSError, subprocess.TimeoutExpired): + return None + return done.stdout.strip() if done.returncode == 0 else None + + +def host_alias(kind: str, platform: str) -> str: + if platform == "Darwin": + return "host.containers.internal" if kind == "podman" else "host.docker.internal" + return "10.88.0.1" if kind == "podman" else "172.17.0.1" + + +def _podman_socket(platform: str, env: Mapping[str, str], run: Runner) -> Path | None: + if platform == "Darwin": + out = run(["podman", "machine", "inspect", "--format", "{{.ConnectionInfo.PodmanSocket.Path}}"]) + return Path(out) if out else None + runtime_dir = env.get("XDG_RUNTIME_DIR") + return Path(runtime_dir, "podman", "podman.sock") if runtime_dir else None + + +def _docker_socket(platform: str, env: Mapping[str, str], run: Runner) -> Path | None: + if platform == "Darwin": + out = run(["docker", "context", "inspect", "--format", '{{(index .Endpoints "docker").Host}}']) + if out and out.startswith("unix://"): + return Path(out[len("unix://") :]) + home = env.get("HOME") + return Path(home, ".docker", "run", "docker.sock") if home else None + return Path("/var/run/docker.sock") + + +def discover( + platform: str, + env: Mapping[str, str], + run: Runner, + exists: Callable[[Path], bool], + wanted: frozenset[str], +) -> list[Backend]: + found: list[Backend] = [] + for kind, finder in (("podman", _podman_socket), ("docker", _docker_socket)): + if kind not in wanted: + continue + sock = finder(platform, env, run) + if sock is not None and exists(sock): + found.append(Backend(kind, sock, host_alias(kind, platform))) + return found + + +def egress_proxy_env(backend: Backend, port: int) -> dict[str, str]: + url = f"http://{backend.host_alias}:{port}" + return {"HTTP_PROXY": url, "HTTPS_PROXY": url, "NO_PROXY": f"localhost,127.0.0.1,{backend.host_alias}"} diff --git a/tools/container-gateway/src/container_gateway/daemon.py b/tools/container-gateway/src/container_gateway/daemon.py new file mode 100644 index 000000000..345c1ca21 --- /dev/null +++ b/tools/container-gateway/src/container_gateway/daemon.py @@ -0,0 +1,654 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Own the run directory, the pid file, the sockets and the process lifetime. + +The daemon runs OUTSIDE the sandbox, with the operator's own privileges, +while the run directory it serves out of (``.apache-magpie-local/run/`` +by default) lives inside the project tree the sandboxed agent can write, +delete and symlink freely. Every guard in this module exists to stop a +planted symlink, a pre-existing non-directory, or a stale/foreign pid +file from turning "start the gateway" into "the daemon opens, writes or +binds something the agent chose instead of something the operator +chose". See the Task 10 round-1 review findings for the threat model +each function below closes. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import fcntl +import logging +import os +import platform as _platform +import re +import signal +import stat +import sys +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any, NoReturn + +from . import backends as _backends +from .labels import project_slug +from .policy import PolicyContext +from .relay import Handler, Relay, serve_unix, unix_connector + +log = logging.getLogger("container-gateway") +MAX_SUN_PATH = 103 +_PID_RE = re.compile(r"^[0-9]{1,10}$") +# How long shutdown waits for a listening socket to finish closing once +# its handlers have been cancelled. Nothing should reach it: the wait is +# a backstop against an unexpected straggler holding the process open, +# not part of the normal path. +_CLOSE_TIMEOUT = 5.0 + + +@dataclass +class Config: + project_root: Path + run_dir: Path + backends: tuple[str, ...] + egress_mode: str + egress_port: int + egress_host: str | None + extra_bind_roots: tuple[Path, ...] + idle_timeout: float + log_level: str + pid_file: Path + # Bounds every short backend round trip the relay makes on the + # client's behalf (label-check inspects, the connect, the first + # response head). Never bounds a streamed body or a hijacked pipe. + # See Task 10 addendum Ruling A. + backend_timeout: float = 60.0 + + +def paths(run_dir: Path) -> dict[str, Path]: + return { + "podman": run_dir / "podman.sock", + "docker": run_dir / "docker.sock", + "pid": run_dir / "container-gateway.pid", + } + + +def check_socket_path(p: Path) -> None: + if len(str(p).encode()) > MAX_SUN_PATH: + print(f"container-gateway: socket path too long ({p}); pass a shorter --run-dir", file=sys.stderr) + raise SystemExit(2) + + +def _refuse(message: str) -> NoReturn: + print(f"container-gateway: {message}", file=sys.stderr) + raise SystemExit(2) + + +def _lstat_or_none(path: Path) -> os.stat_result | None: + try: + return os.lstat(path) + except FileNotFoundError: + return None + + +def _refuse_if_symlink(path: Path, label: str) -> None: + st = _lstat_or_none(path) + if st is not None and stat.S_ISLNK(st.st_mode): + _refuse(f"{label} ({path}) is a symlink; refusing") + + +def _resolved_existing_project_root(project_root: Path) -> Path: + resolved = project_root.resolve() + if not resolved.is_dir(): + _refuse(f"project root ({resolved}) does not exist") + return resolved + + +def _owned_private_dir_status(path: Path, label: str) -> bool: + """Whether ``path`` exists and is a safe, private directory -- without creating it. + + ``True``: exists, is a directory, owned by the current effective + user, not group- or world-writable. ``False``: does not exist yet + (not an attack -- just "nothing here"). Refuses (``SystemExit(2)``) + for every other shape a present path could have: a symlink, a + regular file, a foreign owner, a group/world-writable mode. + """ + _refuse_if_symlink(path, label) + st = _lstat_or_none(path) + if st is None: + return False + if not stat.S_ISDIR(st.st_mode): + _refuse(f"{label} ({path}) is not a directory; refusing") + if st.st_uid != os.geteuid(): + _refuse(f"{label} ({path}) is not owned by the current user; refusing") + if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + _refuse(f"{label} ({path}) is group- or world-writable; refusing") + return True + + +def _ensure_owned_private_dir(path: Path, label: str) -> None: + """``path`` must not be a symlink; created 0700 when absent. + + An explicit ``mkdir()``, never ``parents=True`` -- each component is + checked and created one at a time by the caller so a symlink planted + at an intermediate component is never silently traversed. When it + already exists it must be a directory owned by the current effective + user and not group- or world-writable (``_owned_private_dir_status``). + A ``FileExistsError`` from the ``mkdir`` itself (something else + created -- or planted -- this path between our check and this call) + re-runs that same check against whatever is actually there now, + rather than trusting the race's winner. + """ + if _owned_private_dir_status(path, label): + return + try: + path.mkdir(mode=0o700) + except FileExistsError: + if not _owned_private_dir_status(path, label): + _refuse(f"{label} ({path}) could not be created or inspected") + + +def _project_relative_parts(run_dir: Path, resolved_root: Path, project_root: Path) -> tuple[str, ...] | None: + """``run_dir``'s components below the project root, or ``None`` when it sits outside it. + + Both spellings of the root are tried -- the resolved one and the one + the caller passed -- because a caller may have derived ``run_dir`` + from an unresolved ``--project`` value, in which case the two share + no common prefix even though they name the same directory. The parts + come back relative either way, to be rebuilt under the *resolved* + root so the walk below never starts from an unresolved path. + + A ``..`` among the parts gives up on the component-by-component walk + and reports "outside": ``lstat`` resolves ``..`` in the kernel, so a + path built one component at a time out of them would not be checking + what it appears to be checking. Such a ``--run-dir`` is handled by + the outside branch instead, which resolves the whole parent chain + once and then ``lstat``s a single final component. + """ + for base in (resolved_root, project_root): + try: + parts = run_dir.relative_to(base).parts + except ValueError: + continue + if parts and ".." not in parts: + return parts + return None + + +def _owned_components( + run_dir: Path, resolved_root: Path, project_root: Path +) -> list[tuple[Path, str]] | None: + """Every path component the gateway itself creates and opens, top-down. + + The trust anchor is the operator's own ``--project`` value, resolved + once. A symlink anywhere in the ancestor chain *above* that anchor is + the operating system's or the operator's own layout -- ``/tmp`` and + ``/var`` are symlinks on macOS, a home directory can sit on a linked + volume -- and is followed, not treated as hostile. What the sandboxed + agent can actually plant is a component the gateway creates *below* + the anchor, and each of those is checked one at a time with ``lstat``. + + Inside the project tree the components are every step from the + resolved project root down to the run directory -- + ``.apache-magpie-local`` then ``run`` in the default layout. For a + custom ``--run-dir`` outside the project tree the anchor is that + directory's own parent, which this module never creates and which is + resolved rather than refused; the run directory itself is then the + only owned component. Either way the pid file and the sockets inside + the run directory are guarded separately, by ``check_socket_type`` + and the ``O_NOFOLLOW`` opens. + + ``None`` means an ancestor the gateway does not own is simply absent: + a refusal for ``check_run_dir``, "nothing has ever been served here" + for ``validate_run_dir``. + """ + parts = _project_relative_parts(run_dir, resolved_root, project_root) + if parts is not None: + components: list[tuple[Path, str]] = [] + current = resolved_root + for part in parts[:-1]: + current = current / part + components.append((current, f"the project's {part} directory")) + components.append((current / parts[-1], "the run directory")) + return components + parent = run_dir.parent + if _lstat_or_none(parent) is None: + return None + resolved_parent = parent.resolve() + if not resolved_parent.is_dir(): + _refuse(f"the run directory's parent ({parent}) is not a directory; refusing") + return [(resolved_parent / run_dir.name, "the run directory")] + + +def check_run_dir(run_dir: Path, project_root: Path) -> None: + """Guarantee ``run_dir`` is a real, owned, non-symlinked, private directory + at the moment this check runs. + + ``project_root`` is resolved once -- a symlinked *project root*, or a + symlink anywhere above it, is the operator's or the host's own + layout, and it must already exist (this function creates directories + below the anchor, never the anchor itself). Every component the + gateway owns below that anchor is then walked top-down with + ``lstat``, refusing a symlink, a non-directory, a foreign owner or a + group/world-writable mode on each one before creating or trusting the + next: see ``_owned_components`` for which components those are. + + This closes the symlink-plant attack *at check time*; it does not by + itself pin the directory components against a race between this + check and a later operation inside them -- an attacker who can still + write to a checked-safe parent after this call returns could still + swap a directory for a symlink before the next thing that touches it. + What IS pinned across that gap is the *file* opens downstream of this + check: the pid file and the daemon log are opened with ``O_NOFOLLOW``, + which atomically refuses a symlink at the exact moment of that open, + independent of whatever this function saw a moment earlier. + """ + resolved_root = _resolved_existing_project_root(project_root) + components = _owned_components(run_dir, resolved_root, project_root) + if components is None: + _refuse(f"the run directory's parent ({run_dir.parent}) does not exist") + for path, label in components: + _ensure_owned_private_dir(path, label) + + +def validate_run_dir(run_dir: Path, project_root: Path) -> bool: + """Read-only counterpart to ``check_run_dir``, for ``status``/``stop``. + + Those commands must never create anything -- inspecting a project + that has never been served should have no side effects -- but they + must not silently walk *through* a planted symlink just because they + only read. Returns ``True`` when every relevant component exists and + passes the same checks ``check_run_dir`` enforces; ``False`` when a + component is simply missing, which callers read as "not running", not + as an attack. A symlink, wrong owner, wrong type or wrong mode on a + component that DOES exist still refuses with ``SystemExit(2)``. + """ + resolved_root = project_root.resolve() + if not resolved_root.is_dir(): + return False # nothing has ever been served from a project that is not there + components = _owned_components(run_dir, resolved_root, project_root) + if components is None: + return False + # `all` short-circuits, so the first missing component stops the walk + # rather than reporting on paths below one that is not there yet. + return all(_owned_private_dir_status(path, label) for path, label in components) + + +def check_socket_type(p: Path) -> None: + """Refuse to bind over anything but a stale unix socket or nothing at all. + + A symlink, a regular file or a directory sitting at a gateway socket + path is not something ``run()`` should ever silently remove and + replace -- it only unlinks a path after this check has confirmed + whatever is there really is a stale socket (or nothing). + """ + st = _lstat_or_none(p) + if st is not None and not stat.S_ISSOCK(st.st_mode): + _refuse(f"{p} exists and is not a socket; refusing to bind over it") + + +def read_pid(pid_file: Path) -> int | None: + """The pid recorded in ``pid_file``, or ``None`` if it is missing or unsafe. + + Only a bare, base-10, 1-to-10-digit integer greater than 1 is + accepted -- ``-1``, ``0``, ``1``, non-numeric content and anything + with trailing garbage all read as ``None``. This is what keeps a + corrupted or hostile pid file from ever reaching ``os.kill()``: + ``kill(-1, ...)`` signals every process the caller owns, and ``kill(1, + ...)`` targets init. + """ + try: + text = pid_file.read_text().strip() + except OSError: + return None + if not _PID_RE.fullmatch(text): + return None + pid = int(text) + return pid if pid > 1 else None + + +def pid_file_is_trustworthy(pid_file: Path) -> bool | None: + """Whether ``pid_file`` is safe for ``status``/``stop`` to act on. + + ``None``: does not exist yet -- nothing has been distrusted, there is + simply nothing there. ``True``: exists, is a regular file (not a + symlink), owned by the current effective user, mode exactly ``0600`` + -- exactly the shape ``acquire_pid_lock`` creates. ``False``: exists + but fails one of those checks -- a symlink, someone else's file, or a + mode the agent (or anything else) widened or narrowed after the fact. + Callers never signal or otherwise trust a file this returns ``False`` + for, and never need to distinguish "doesn't exist" from "exists but + is fine" for their own decision -- both are handled by the ``bool()`` + of this return value except where the caller needs to tell "never + served" apart from "untrustworthy", which is why this returns + ``None`` rather than folding that case into ``False``. + """ + st = _lstat_or_none(pid_file) + if st is None: + return None + if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode): + return False + if st.st_uid != os.geteuid(): + return False + return st.st_mode & 0o777 == 0o600 + + +def pid_alive(pid: int) -> bool: + """A cheap liveness probe -- kept only as a fast-path helper for the + ``stop`` wait loop. The authoritative liveness signal is the pid-file + flock (see ``acquire_pid_lock`` / ``probe_pid_lock``), not this. + """ + if pid <= 1: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _open_pid_fd(pid_file: Path, flags: int) -> int: + """Open the pid file, never following a symlink at that exact path.""" + return os.open(pid_file, flags | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600) + + +def acquire_pid_lock(pid_file: Path) -> int | None: + """Become the single serving instance for ``pid_file``, or find out we are not. + + On success, returns an fd holding ``LOCK_EX`` for as long as it stays + open, with our pid already written into it. The caller keeps this fd + open (and never closes it) for the process's entire life -- closing + it, or letting it be garbage collected, drops the lock. Returns + ``None`` when another live instance already holds the lock. + + The fd is opened WITHOUT ``O_TRUNC`` -- truncating unconditionally at + open time would blank a live daemon's pid file the instant a second, + losing ``serve`` invocation merely probes it, even though that + second invocation never wins the lock. The file's content is only + ever rewritten *after* the lock is actually won, so a contended probe + leaves the live instance's displayed pid untouched. + """ + try: + fd = _open_pid_fd(pid_file, os.O_WRONLY | os.O_CREAT) + except OSError as exc: + _refuse(f"{pid_file} could not be opened safely (symlink?): {exc}") + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + os.close(fd) + return None + os.ftruncate(fd, 0) + os.lseek(fd, 0, 0) + os.write(fd, f"{os.getpid()}\n".encode()) + os.fsync(fd) + return fd + + +def probe_pid_lock(pid_file: Path) -> tuple[bool, int | None]: + """Whether some instance currently holds ``pid_file``'s lock, and its pid. + + ``(False, None)``: nothing has ever served here (the run directory + does not exist yet), or nothing holds the lock -- a stale pid file, + if any, is removed in that second case. ``(True, pid)``: the lock is + held; ``pid`` is whatever ``read_pid`` can validate out of the file, + which may itself be ``None`` even while the lock is held (unreadable + or invalid content) -- callers must not signal in that case. + """ + try: + fd = _open_pid_fd(pid_file, os.O_RDWR | os.O_CREAT) + except FileNotFoundError: + return False, None + except OSError as exc: + _refuse(f"{pid_file} could not be opened safely (symlink?): {exc}") + try: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return True, read_pid(pid_file) + fcntl.flock(fd, fcntl.LOCK_UN) + pid_file.unlink(missing_ok=True) + return False, None + finally: + os.close(fd) + + +async def probe_egress(host: str, port: int) -> bool: + """Whether something is listening on the host loopback at ``port``. + + ``host`` is the in-container alias (``host.containers.internal`` and + friends) -- it is not dialled directly. The probe checks the gateway + from the host side, on ``127.0.0.1``, since that is where the + listener actually binds; the alias only matters once the request is + inside a container's own network namespace. + """ + try: + _, w = await asyncio.wait_for(asyncio.open_connection("127.0.0.1", port), 1.0) + except OSError: + return False + w.close() + with contextlib.suppress(OSError): + await w.wait_closed() + return True + + +def build_context(cfg: Config, backend: _backends.Backend, proxy_env: dict[str, str] | None) -> PolicyContext: + """The policy context for one backend's relay. + + The project root and whatever ``--extra-bind-root`` names are the only + bind-mount roots. ``TMPDIR`` was one too and is not any more: on macOS + it is per-*user*, not per-project (``/var/folders/<..>/T/``), so every + project on the machine shared one bind root and could mount another + project's scratch tree -- and the agent's own scratch directory sits + inside it. An adopter whose tests need a directory outside the project + tree names it explicitly with ``--extra-bind-root``, which is logged at + start. + """ + roots = [cfg.project_root.resolve()] + roots.extend(root.resolve() for root in cfg.extra_bind_roots) + return PolicyContext( + project_slug(cfg.project_root), cfg.project_root.resolve(), tuple(roots), proxy_env, cfg.egress_mode + ) + + +class _Activity: + """Idle tracking: every accepted connection bumps the clock.""" + + def __init__(self) -> None: + self.last = time.monotonic() + + def wrap(self, relay: Relay) -> Handler: + async def handler(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None: + self.last = time.monotonic() + await relay.handle(r, w) + self.last = time.monotonic() + + return handler + + +class _Handlers: + """Every live connection handler, so shutdown can end them itself. + + Since Python 3.12.1 ``asyncio.Server.wait_closed()`` returns only + once every accepted connection's handler has finished, and the + relay's handler sits reading the next request for as long as a + keep-alive client keeps its connection open. + A gateway that closed its listening sockets and then waited would + therefore never exit while any client is connected -- not on its + idle timeout, not on SIGTERM -- so ``container-gateway stop`` would + wait out its timeout and the session hook would leave a daemon + behind. + Shutting down means ending the handlers, not waiting for clients to + go away on their own: the gateway exits promptly whatever a client + is doing, and an in-flight request losing its connection at that + moment is the correct outcome. + """ + + def __init__(self) -> None: + self._tasks: set[asyncio.Task[Any]] = set() + + def track(self, handler: Handler) -> Handler: + """``handler``, with each invocation's own task registered while it runs.""" + + async def tracked(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + task = asyncio.current_task() + if task is not None: + self._tasks.add(task) + try: + await handler(reader, writer) + finally: + if task is not None: + self._tasks.discard(task) + + return tracked + + async def cancel_all(self) -> None: + """Cancel every live handler and wait for it to unwind. + + A cancelled handler discards itself as it unwinds, so a second + pass can only ever see a connection accepted while the first + pass was running -- which the caller has already made impossible + by closing the listening sockets before calling this. + """ + while self._tasks: + pending = list(self._tasks) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + + +async def run( + cfg: Config, + *, + discover_fn: Callable[..., list[_backends.Backend]] = _backends.discover, + platform: str = _platform.system(), +) -> int: + logging.basicConfig(level=cfg.log_level.upper(), format="%(asctime)s %(name)s %(levelname)s %(message)s") + check_run_dir(cfg.run_dir, cfg.project_root) + p = paths(cfg.run_dir) + for key in ("podman", "docker"): + check_socket_path(p[key]) + + pid_fd = acquire_pid_lock(cfg.pid_file) + if pid_fd is None: + log.info("another instance already holds the pid lock at %s; exiting", cfg.pid_file) + return 0 + + try: + found = discover_fn( + platform, os.environ, _backends.default_runner, lambda x: x.exists(), frozenset(cfg.backends) + ) + if not found: + log.info("no podman or docker backend found; nothing to serve") + return 0 + by_kind = {b.kind: b for b in found} + podman = by_kind.get("podman") + docker = by_kind.get("docker") or podman # docker CLI speaks the compat API on podman too + + servers: list[asyncio.AbstractServer] = [] + bound_sockets: list[Path] = [] + activity = _Activity() + handlers = _Handlers() + signal_handlers_installed: list[signal.Signals] = [] + loop = asyncio.get_running_loop() + try: + for key, backend in (("podman", podman), ("docker", docker)): + if backend is None: + continue + proxy_env: dict[str, str] | None = None + if cfg.egress_mode != "off": + alias_backend = ( + backend + if cfg.egress_host is None + else _backends.Backend(backend.kind, backend.socket, cfg.egress_host) + ) + if await probe_egress(alias_backend.host_alias, cfg.egress_port): + proxy_env = _backends.egress_proxy_env(alias_backend, cfg.egress_port) + elif cfg.egress_mode == "require": + log.warning( + "egress gateway unreachable; every container create will be refused (--egress require)" + ) + else: + log.warning( + "egress gateway not reachable on 127.0.0.1:%s; containers get no proxy (--egress %s)", + cfg.egress_port, + cfg.egress_mode, + ) + relay = Relay( + unix_connector(backend.socket), + build_context(cfg, backend, proxy_env), + backend_label=str(backend.socket), + backend_timeout=cfg.backend_timeout, + ) + sock_path = p[key] + check_socket_type(sock_path) + # The lock is already held (we would not be here + # otherwise) and `check_socket_type` just confirmed + # anything present really is a stale socket -- only now + # is it safe to remove it. `serve_unix` itself performs + # no unlink of its own. + sock_path.unlink(missing_ok=True) + server = await serve_unix(sock_path, handlers.track(activity.wrap(relay))) + servers.append(server) + bound_sockets.append(sock_path) + log.info("%s CLI -> %s (backend %s at %s)", key, sock_path, backend.kind, backend.socket) + + stop = asyncio.Event() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, stop.set) + signal_handlers_installed.append(sig) + while not stop.is_set(): + try: + await asyncio.wait_for(stop.wait(), timeout=1.0) + except TimeoutError: + if time.monotonic() - activity.last > cfg.idle_timeout: + log.info("idle for %.0fs; exiting", cfg.idle_timeout) + break + finally: + for sig in signal_handlers_installed: + loop.remove_signal_handler(sig) + for s in servers: + s.close() + # Only once nothing new can be accepted are the handlers + # ended; `wait_closed` waits for them, so cancelling them + # first is what lets that wait return at all. + await handlers.cancel_all() + for s in servers: + try: + await asyncio.wait_for(s.wait_closed(), _CLOSE_TIMEOUT) + except TimeoutError: + log.warning( + "a listening socket did not finish closing within %.0fs; exiting anyway", + _CLOSE_TIMEOUT, + ) + # Only the sockets *this process* bound -- a partial bind + # failure must not delete a sibling socket another (already + # running) instance might still be serving from. + for sock_path in bound_sockets: + sock_path.unlink(missing_ok=True) + finally: + # Unlink FIRST, while the lock is still held (the fd is still + # open): once the fd is closed the lock is gone, and any window + # between that and the unlink is a window where a racing `serve` + # could win the lock on a *new* inode while this pid file is + # still the old one on disk. Unlinking before closing removes + # that window instead of merely narrowing it. + cfg.pid_file.unlink(missing_ok=True) + os.close(pid_fd) + return 0 diff --git a/tools/container-gateway/src/container_gateway/decisions.py b/tools/container-gateway/src/container_gateway/decisions.py new file mode 100644 index 000000000..8a8e4ec7e --- /dev/null +++ b/tools/container-gateway/src/container_gateway/decisions.py @@ -0,0 +1,581 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""``decide()``: the single entry point the relay calls for every request. + +Split out of ``policy.py`` to keep that module below its target size. +``decide()`` is the request-level counterpart to ``check_create`` / +``apply_create_rewrites``: it classifies a parsed request via +``routes.route``, applies the deny lists, injects the project label into +create bodies / list filters / build labels, and marks which act-by-name +requests still need the relay's backend label check. + +Dependencies are strictly one-way: this module imports from ``policy.py`` +(which imports from ``policy_shape.py``); neither of those two imports +anything back from here. Callers reach ``Allow``, ``Request`` and +``decide`` through ``container_gateway.decisions`` directly — there is no +re-export via ``container_gateway.policy``. + +Like ``check_create``, ``decide`` is defensively layered against +attacker-controlled input it cannot fully type-check statically: every +JSON-parsing branch (``build`` labels, list-filters) denies on invalid JSON +or the wrong top-level type rather than letting the exception propagate, +and a total ``try/except`` backstop around the whole function denies with a +generic ``malformed`` reason rather than ever raising into the relay. +""" + +from __future__ import annotations + +import json as _json +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote + +from .labels import LABEL_KEY, merge_filters, with_label +from .policy import ( + NETWORK_MODE_KEYWORDS, + PROXY_VARS, + Deny, + PolicyContext, + apply_create_rewrites, + check_create, + check_exec_create, + check_update, + resource_create_spelling_violation, +) +from .routes import ACT_BY_NAME, LIST_LIKE, Family, Route +from .routes import route as _route + +__all__ = ["Allow", "Request", "decide"] + +_PROXY_VARS_CASEFOLD = frozenset(v.casefold() for v in PROXY_VARS) + +# Control characters (C0 plus DEL) that make a path suspect regardless of +# where they appear. +_CONTROL_CHARS = frozenset(chr(c) for c in range(0x20)) | {"\x7f"} + + +@dataclass +class Request: + """One parsed request, as the relay hands it to ``decide()``. + + ``path`` is the raw request-target path exactly as received on the + wire — not percent-decoded, not otherwise normalised. ``decide()`` + refuses (rather than routes) anything that is not already normalised + (see ``_path_is_malformed``); the relay must not percent-decode ``path`` + before calling ``decide()``, or a malicious `%2e%2e` segment would + already look safe by the time this module ever sees it. + + ``decide()`` mutates ``query`` and ``body`` on this object in place (to + inject the project label / proxy env / loopback host, or to merge a + filter) rather than returning a copy; the ``request`` carried on the + returned ``Allow`` is this same object, already rewritten. + """ + + method: str + path: str + query: dict[str, list[str]] + headers: dict[str, str] + body: Any | None + + def raw_target(self) -> str: + # Guard on the built `pairs`, not on `self.query` being non-empty: a + # query dict like {"a": []} is a non-empty dict with nothing to + # render, and must still produce the bare path, not "path?". + pairs = [f"{quote(k, safe='')}={quote(v, safe='')}" for k, vs in self.query.items() for v in vs] + if not pairs: + return self.path + return f"{self.path}?{'&'.join(pairs)}" + + +@dataclass(frozen=True) +class Allow: + """A request the policy admits, and what (if anything) the relay must still label-check. + + ``request`` is the same ``Request`` object passed to ``decide()`` — see + ``Request``'s docstring on in-place mutation. + """ + + request: Request + route: Route + label_check: str | None = None + # When the resource the relay must label-check is named by a query + # parameter rather than by a path segment (compat + # ``POST /commit?container=``), this is that parameter's name: the + # relay splices the resolved id back into it instead of into the path. + name_in_query: str | None = None + + +# Images are shared across every project on the host: reading one, pulling +# one, or tagging *from* one needs no label check. Only removing an image or +# retagging it (the ``tag`` verb's target, handled via ACT_BY_NAME below) +# touches what another project might still be using. +_IMAGE_READS = frozenset({"list", "pull", "inspect", "history", "save", "search", "load"}) + +# Endpoints that are reachable by name but do something the gateway cannot +# bound: checkpoint / restore name a host path to write the archive to +# (``?export=/Users/me/x.tar``, acted on by the daemon), and generate / +# play take a Kubernetes YAML the daemon reads and acts on wholesale. They +# stay in ``routes._VERBS`` so name parsing is unchanged, and are refused +# here before anything else looks at them. +_DENIED_ACTIONS: frozenset[tuple[Family, str]] = frozenset( + {(f, a) for f in (Family.CONTAINERS, Family.PODS) for a in ("checkpoint", "restore", "generate", "play")} +) + +# Query parameters that name a host path for the daemon to read or write, +# refused on every route as a backstop for a verb this module has not +# enumerated. +_DENIED_QUERY_PARAMS = frozenset({"export", "import"}) + +# --- Build-query policy (C2) ------------------------------------------- +# +# `POST /build` is a create in disguise: its `RUN` steps execute with +# whatever the query asks for. The query is therefore an allow-list, like +# the create body: these parameters are forwarded, the table below is +# refused by name, and anything else is refused as unknown. The list +# covers what the docker CLI's classic builder and podman 6.x's remote +# client actually send, plus the flags that are bounded by construction. +_BUILD_ALLOWED_PARAMS = frozenset( + name.casefold() + for name in ( + "additionalbuildcontexts", + "allplatforms", + "annotations", + "buildargs", + "buildid", + "buildkit", + "cachefrom", + "cacheto", + "cachettl", + "compressionFormat", + "createdannotation", + "cpuperiod", + "cpuquota", + "cpusetcpus", + "cpusetmems", + "cpushares", + "dnsoptions", + "dnssearch", + "dnsservers", + "dockerfile", + "dropcaps", + "excludes", + "forceCompressionFormat", + "forcerm", + "from", + "httpproxy", + "identitylabel", + "idmappingoptions", + "ignorefile", + "inheritannotations", + "isolation", + "jobs", + "labelannotations", + "labels", + "layerlabels", + "layers", + "manifest", + "memory", + "memswap", + "nocache", + "nohosts", + "omithistory", + "outputformat", + "platform", + "pull", + "pullpolicy", + "q", + "quiet", + "retry", + "retry-delay", + "rewritetimestamp", + "rm", + "shmsize", + "skipunusedstages", + "squash", + "squashlayers", + "t", + "tag", + "target", + "timestamp", + "unsetlabel", + "version", + ) +) + +# Refused by name, with the reason the client sees. Each is refused only +# when it carries a value: both CLIs send some of these empty on every +# build (`cgroupparent=`), and an empty one asks for nothing. +_BUILD_DENIED_PARAMS: dict[str, str] = { + "addcaps": "added capabilities", + "addhost": "extra hosts", + "apparmor": "an apparmor profile", + "cgroupparent": "a custom cgroup parent", + "device": "host devices", + "devices": "host devices", + "extrahosts": "extra hosts", + "labelopts": "selinux label options", + "remote": "a remote build context", + "runtime": "an alternative OCI runtime", + "runtimeflags": "OCI runtime flags", + "seccomp": "a seccomp profile", + "secrets": "build secrets", + "securityopt": "security options", + "session": "a buildkit session", + "sessionid": "a buildkit session", + "ssh": "an ssh agent forward", + "ulimits": "ulimits", + "unmask": "unmasked kernel paths", + "unsetenv": "unset environment variables", + "volume": "a host bind mount", + "volumes": "a host bind mount", +} + +# Values that mean "this parameter asks for nothing", so a denied +# parameter carrying one is left alone rather than refused. +_EMPTY_QUERY_VALUES = frozenset({"", "0", "false", "null", "[]", "{}"}) + +# libpod's build `networkmode` is buildah's integer policy (0 default, +# 1 disabled, 2 enabled); compat's is the string NetworkMode. Host +# networking for a libpod build rides in `nsoptions`, not here. +_BUILD_NETWORKMODE_INTS = frozenset({"0", "1", "2"}) +_BUILD_NETWORKMODE_KEYWORDS = frozenset(k for k in NETWORK_MODE_KEYWORDS if k != "host") + +# The one `nsoptions` entry a rootless build legitimately carries: podman +# sends `{"Name": "user", "Host": true}` on every build. Any other +# host-joined namespace, and any namespace joined by path, is refused. +_NSOPTION_HOST_ALLOWED = frozenset({"user"}) + +# Exporter types that always write to a host path, regardless of what other +# attributes accompany them -- buildkit's `local` and `tar` exporters. `image` +# and `registry` (and anything else not in this set) stay inside the daemon +# unless an explicit destination attribute says otherwise (see +# `_BUILD_OUTPUT_DEST_KEYS`). +_BUILD_OUTPUT_FS_TYPES = frozenset({"local", "tar"}) + +# Attribute keys that name a host destination on any exporter type -- present +# alongside `type=image` this is still a write to a host path (buildah's +# `-o ` behaves this way), so it is refused independent of the type. +_BUILD_OUTPUT_DEST_KEYS = frozenset({"dest", "output"}) + +# A bare `output` value that names a place on the host rather than an image. +# podman puts the *image name* in `output` on every `podman build -t x`, so a +# bare value is refused only when it is path-shaped: absolute, home- or +# dot-relative, a Windows path, or `-` (stdout). An image reference can carry +# slashes (`quay.io/me/img`) but never starts with one. +_BUILD_OUTPUT_PATH_PREFIXES = ("/", "~", ".", "\\") + + +def _first(query: dict[str, list[str]], key: str) -> str | None: + """The first value of a (possibly absent, possibly empty) query parameter.""" + values = query.get(key) + return values[0] if values else None + + +def _path_is_malformed(path: str) -> bool: + """A path this module refuses to route rather than classify. + + Percent-encoding, backslashes, control characters, and doubled slashes + are all ways a client (or a proxy ahead of this one) could smuggle a + segment past the plain ``/``-split classifier in ``routes.route`` — a + ``%2e%2e`` or a doubled slash can decode differently downstream than it + parses here. A ``.``/``..`` path segment is refused outright rather than + resolved, since resolving it would mean this module's classification and + the backend's own path handling could disagree about what request is + actually being made. + """ + if "%" in path or "\\" in path or "//" in path: + return True + if any(c in _CONTROL_CHARS for c in path): + return True + return any(segment in (".", "..") for segment in path.split("/")) + + +def _is_empty_value(value: str) -> bool: + return value.strip().casefold() in _EMPTY_QUERY_VALUES + + +def _nsoptions_deny(raw: str) -> Deny | None: + """Refuse a libpod build that joins a host (or path-named) namespace.""" + try: + parsed = _json.loads(raw) + except _json.JSONDecodeError as exc: + return Deny(f"malformed: build nsoptions is not valid JSON: {exc}") + if not isinstance(parsed, list): + return Deny("malformed: build nsoptions must be a JSON array") + for entry in parsed: + if not isinstance(entry, dict): + return Deny("malformed: build nsoptions entries must be objects") + name = str(entry.get("Name", "")).casefold() + if entry.get("Host") and name not in _NSOPTION_HOST_ALLOWED: + return Deny(f"denied-build-parameter: the host {name or 'unnamed'} namespace is refused") + if entry.get("Path"): + return Deny( + f"denied-build-parameter: joining the {name or 'unnamed'} namespace by path is refused" + ) + return None + + +def _build_output_deny(value: str) -> Deny | None: + """Refuse a build ``output`` / ``outputs`` that names a host destination. + + Three spellings reach these parameters: buildkit's JSON array + (``[{"Type":"local","Attrs":{"dest":"/Users/me"}}]``), the comma form + (``type=local,dest=/Users/me``), and a bare value (``-o + /Users/me/out``). The first two are judged on the destination, not on + the attribute set: refused when the exporter type itself always writes + to the host (``local``, ``tar`` -- see ``_BUILD_OUTPUT_FS_TYPES``), or + when any attribute names a destination (``dest``, ``output`` -- see + ``_BUILD_OUTPUT_DEST_KEYS``), including when no ``type`` is given at + all. Any other attribute (``name``, ``push``, ``compression``, + ``oci-mediatypes``, ...) alongside a safe type such as ``image`` or + ``registry`` is a normal ``buildx`` invocation and is allowed. A bare + value is podman's image name on every ``podman build -t x`` and is + refused only when it is path-shaped (see + ``_BUILD_OUTPUT_PATH_PREFIXES``). + """ + if _is_empty_value(value): + return None + refused = Deny(f"denied-build-parameter: writing build output to {value} is refused") + stripped = value.strip() + if stripped.startswith(("[", "{")): + try: + parsed = _json.loads(stripped) + except _json.JSONDecodeError: + return refused + entries = parsed if isinstance(parsed, list) else [parsed] + for entry in entries: + if not isinstance(entry, dict): + return refused + entry_type = str(entry.get("Type") or entry.get("type") or "").strip().casefold() + attr_keys = {str(k).strip().casefold() for k in entry} - {"type", "attrs"} + attrs = entry.get("Attrs") + if attrs is None: + attrs = entry.get("attrs") + if isinstance(attrs, dict): + attr_keys |= {str(k).strip().casefold() for k in attrs} + elif attrs: + # Attrs present but not an object this parser can inspect -- + # refuse rather than guess whether it names a destination. + return refused + if attr_keys & _BUILD_OUTPUT_DEST_KEYS: + return refused + if entry_type in _BUILD_OUTPUT_FS_TYPES: + return refused + return None + if "=" not in stripped: + if stripped == "-" or stripped.startswith(_BUILD_OUTPUT_PATH_PREFIXES): + return refused + return None + seen_type = "" + attr_keys = set() + for directive in stripped.split(","): + item = directive.strip() + if not item: + continue + name, sep, attr_value = item.partition("=") + key = name.strip().casefold() + if not sep or not key: + return refused + if key == "type": + seen_type = attr_value.strip().casefold() + else: + attr_keys.add(key) + if attr_keys & _BUILD_OUTPUT_DEST_KEYS: + return refused + if seen_type in _BUILD_OUTPUT_FS_TYPES: + return refused + return None + + +def _build_query_deny(query: dict[str, list[str]]) -> Deny | None: + """Allow-list the build query: anything not enumerated is refused.""" + for key, values in query.items(): + casefolded = key.casefold() + what = _BUILD_DENIED_PARAMS.get(casefolded) + if what is not None: + if any(not _is_empty_value(v) for v in values): + return Deny(f"denied-build-parameter: {what} ({key}) is refused") + continue + if casefolded == "networkmode": + for value in values: + if value.strip() in _BUILD_NETWORKMODE_INTS: + continue + if value.strip().casefold() in _BUILD_NETWORKMODE_KEYWORDS: + continue + return Deny(f"network: build networkmode={value} is refused") + continue + if casefolded == "nsoptions": + for value in values: + denial = _nsoptions_deny(value) + if denial is not None: + return denial + continue + if casefolded in ("output", "outputs"): + for value in values: + denial = _build_output_deny(value) + if denial is not None: + return denial + continue + if casefolded not in _BUILD_ALLOWED_PARAMS: + return Deny(f"denied-build-parameter: {key} is not accepted by the gateway") + return None + + +def _label_build(req: Request, ctx: PolicyContext, *, libpod: bool) -> Deny | None: + """Inject the project label into the build query, in the shape that API takes. + + The compat build takes ``labels`` as a JSON object; libpod's takes a + JSON array of ``k=v`` strings. The shape the client sent wins when it + sent one, so a client using the other spelling than its URL suggests + still gets a body its daemon can parse. + """ + raw_labels = _first(req.query, "labels") + try: + labels = _json.loads(raw_labels) if raw_labels else ([] if libpod else {}) + except _json.JSONDecodeError as exc: + return Deny(f"malformed: build labels is not valid JSON: {exc}") + if isinstance(labels, list): + as_list = [str(item) for item in labels] + as_list.append(f"{LABEL_KEY}={ctx.slug}") + req.query["labels"] = [_json.dumps(as_list, separators=(",", ":"))] + return None + if not isinstance(labels, dict): + return Deny("malformed: build labels must be a JSON object") + req.query["labels"] = [_json.dumps(with_label(labels, ctx.slug), separators=(",", ":"))] + return None + + +def _inject_build_proxy(query: dict[str, list[str]], ctx: PolicyContext) -> Deny | None: + """Put the egress proxy into ``buildargs`` so ``RUN`` obeys it too. + + Client-supplied values for the same names are dropped first, exactly + as the create rewrite does for ``Env``. podman's own ``httpproxy=1`` + (the daemon adding *its* proxy variables) is turned off for the same + reason: the gateway decides the container's egress. + """ + if not ctx.proxy_env or ctx.egress_mode == "off": + return None + raw = _first(query, "buildargs") + try: + args = _json.loads(raw) if raw else {} + except _json.JSONDecodeError as exc: + return Deny(f"malformed: build buildargs is not valid JSON: {exc}") + if not isinstance(args, dict): + return Deny("malformed: build buildargs must be a JSON object") + merged = {k: v for k, v in args.items() if str(k).casefold() not in _PROXY_VARS_CASEFOLD} + merged.update(ctx.proxy_env) + query["buildargs"] = [_json.dumps(merged, separators=(",", ":"))] + if "httpproxy" in query: + query["httpproxy"] = ["0"] + return None + + +def decide(req: Request, ctx: PolicyContext) -> Allow | Deny: + try: + return _decide(req, ctx) + except (TypeError, AttributeError, ValueError, KeyError): + return Deny("malformed: unexpected request shape") + + +def _decide(req: Request, ctx: PolicyContext) -> Allow | Deny: + if _path_is_malformed(req.path): + return Deny("malformed: path is not normalised") + + r = _route(req.method, req.path) + if r.family is Family.DENIED: + return Deny(f"denied-endpoint: {r.action} is not available through the gateway") + if r.family is Family.UNKNOWN or r.action == "unknown": + return Deny(f"unknown-endpoint: {req.method} {req.path} is not available through the gateway") + + key = (r.family, r.action) + if key in _DENIED_ACTIONS: + return Deny(f"denied-endpoint: {r.action} is not available through the gateway") + for name in req.query: + if name.casefold() in _DENIED_QUERY_PARAMS: + return Deny(f"denied-query: {name} is not available through the gateway") + if key in ((Family.CONTAINERS, "create"), (Family.PODS, "create")): + denied = check_create(req.body, ctx, libpod=r.libpod) + if denied: + return denied + # check_create only returns None for a body that is already a dict + # (see its docstring); this narrows the type for apply_create_rewrites + # without pre-filtering req.body before check_create sees it (I4). + assert isinstance(req.body, dict) + req.body = apply_create_rewrites(req.body, ctx, libpod=r.libpod) + return Allow(req, r) + if key in ((Family.VOLUMES, "create"), (Family.NETWORKS, "create")): + if not isinstance(req.body, dict): + return Deny("malformed: request body must be a JSON object") + spelling_violation = resource_create_spelling_violation(req.body, r.libpod) + if spelling_violation is not None: + return spelling_violation + body = dict(req.body) + k = "labels" if r.libpod else "Labels" + body[k] = with_label(body.get(k), ctx.slug) + req.body = body + return Allow(req, r) + if key in ((Family.CONTAINERS, "exec"), (Family.PODS, "exec")): + exec_denied = check_exec_create(req.body) + if exec_denied is not None: + return exec_denied + if key in ((Family.CONTAINERS, "update"), (Family.PODS, "update")): + update_denied = check_update(req.body) + if update_denied is not None: + return update_denied + if r.family is Family.BUILD: + build_denied = _build_query_deny(req.query) + if build_denied is not None: + return build_denied + label_denied = _label_build(req, ctx, libpod=r.libpod) + if label_denied is not None: + return label_denied + proxy_denied = _inject_build_proxy(req.query, ctx) + if proxy_denied is not None: + return proxy_denied + return Allow(req, r) + + if key in LIST_LIKE: + raw_filters = _first(req.query, "filters") + try: + merged = merge_filters(raw_filters, ctx.slug) + if key == (Family.IMAGES, "prune"): + f = _json.loads(merged) + f["dangling"] = ["true"] + merged = _json.dumps(f, separators=(",", ":")) + except (ValueError, TypeError) as exc: + return Deny(f"malformed: {exc}") + req.query["filters"] = [merged] + return Allow(req, r) + + if key == (Family.CONTAINERS, "commit") and r.name is None: + # The compat spelling: the container is in the query. + container = _first(req.query, "container") + if not container: + return Deny("malformed: commit needs a container") + return Allow(req, r, label_check=container, name_in_query="container") + if r.family is Family.IMAGES and r.action in _IMAGE_READS: + return Allow(req, r) + if key in ACT_BY_NAME: + return Allow(req, r, label_check=r.name) + if r.name is not None: + # Fail closed: any other named route (a verb ``routes._VERBS`` knows + # about but this module's tables do not yet enumerate) still gets + # label-checked by name. The bare ``Allow`` below is reached only by + # unnamed routes (system endpoints, bare-family list calls already + # handled above). + return Allow(req, r, label_check=r.name) + return Allow(req, r) diff --git a/tools/container-gateway/src/container_gateway/http.py b/tools/container-gateway/src/container_gateway/http.py new file mode 100644 index 000000000..cc1fa39d7 --- /dev/null +++ b/tools/container-gateway/src/container_gateway/http.py @@ -0,0 +1,393 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Just enough HTTP/1.1 to relay the Docker API over unix streams. + +Principle enforced throughout this module: anything the gateway cannot frame +identically to the daemon is refused with :class:`HttpError`, never forwarded +as best-effort. That covers request-smuggling shapes (bare CR/LF/NUL inside a +header, a request line the policy layer would parse differently than the +daemon, duplicate or ambiguous Content-Length/Transfer-Encoding) as well as +plain transport failures (``ValueError`` from ``int()``, +``asyncio.IncompleteReadError``, ``asyncio.LimitOverrunError``) -- every one +of those is caught at the boundary and re-raised as ``HttpError`` so the relay +has exactly one exception type to catch and answer with ``error_response``. +""" + +from __future__ import annotations + +import asyncio +import json +import re +from dataclasses import dataclass, field +from urllib.parse import parse_qs, urlsplit + +_REASONS = { + 200: "OK", + 400: "Bad Request", + 403: "Forbidden", + 404: "Not Found", + 500: "Internal Server Error", + 502: "Bad Gateway", +} + +# Control characters (excluding the CR/LF that terminate a line, which are +# handled structurally by the line-splitting itself) that must never appear +# inside a header name, header value, or start line. Includes CR/LF/NUL so +# that a value smuggled *inside* a single already-split line -- a bare LF or +# CR not paired as "\r\n", or an embedded NUL -- is caught explicitly, not +# just control characters in general. +_CTL = frozenset(chr(c) for c in (*range(0x00, 0x20), 0x7F)) + +_METHOD_RE = re.compile(r"[A-Z]+") +_VERSION_RE = re.compile(r"HTTP/1\.[01]") +_STATUS_RE = re.compile(r"[1-5][0-9]{2}") +_CONTENT_LENGTH_RE = re.compile(r"[0-9]{1,18}") +_CHUNK_SIZE_RE = re.compile(rb"[0-9A-Fa-f]{1,8}") + + +class HttpError(Exception): + pass + + +def _check_no_control_chars(s: str, what: str) -> None: + if any(c in _CTL for c in s): + raise HttpError(f"control character in {what}: {s!r}") + + +def _check_header_name(name: str) -> None: + if not name: + raise HttpError("empty header name") + _check_no_control_chars(name, "header name") + if any(c.isspace() for c in name): + raise HttpError(f"whitespace in header name: {name!r}") + + +def _check_header_value(value: str) -> None: + _check_no_control_chars(value, "header value") + try: + value.encode("latin-1") + except UnicodeEncodeError as exc: + raise HttpError(f"non-latin-1 header value: {value!r}") from exc + + +def _validate_head_start_line(line: str) -> None: + """Validate a request- or status-line the way the daemon would parse it. + + ``read_head`` parses both requests (sent to the daemon) and responses + (received back from it), so this accepts either shape. The shape is + detected from the *first* token: if it looks like an HTTP version, this + is a status line (``HTTP/1.x SP status [SP reason]``), whose reason + phrase is free-form -- it may be empty or contain further spaces + (``404 Not Found``, `` 500 Internal Server Error``) -- so it is split + with ``maxsplit=2`` rather than demanding exactly three tokens. Anything + else is a request line (``METHOD SP target SP HTTP/1.x``), which keeps + the strict exactly-three-tokens rule: a request target legitimately + never contains an unencoded space, so a fourth token there is always a + smuggling shape, not a value the gateway should tolerate. Control + characters are rejected in either shape, over the whole line up front, + so a tab or bogus extra space cannot silently shift which token is + which. + """ + _check_no_control_chars(line, "start line") + head_token = line.split(" ", 1)[0] + if _VERSION_RE.fullmatch(head_token): + parts = line.split(" ", 2) + if len(parts) < 2 or not _STATUS_RE.fullmatch(parts[1]): + raise HttpError(f"bad status line: {line!r}") + return + parts = line.split(" ") + if len(parts) != 3 or not _METHOD_RE.fullmatch(parts[0]) or not _VERSION_RE.fullmatch(parts[2]): + raise HttpError(f"bad start line: {line!r}") + + +@dataclass +class Head: + start_line: str + headers: list[tuple[str, str]] = field(default_factory=list) + + def get(self, name: str) -> str | None: + name = name.lower() + for k, v in self.headers: + if k.lower() == name: + return v + return None + + def set(self, name: str, value: str) -> None: + _check_header_name(name) + _check_header_value(value) + self.remove(name) + self.headers.append((name, value)) + + def remove(self, name: str) -> None: + name = name.lower() + self.headers = [(k, v) for k, v in self.headers if k.lower() != name] + + @property + def content_length(self) -> int | None: + v = self.get("content-length") + if v is None or not _CONTENT_LENGTH_RE.fullmatch(v): + return None + return int(v) + + @property + def chunked(self) -> bool: + v = self.get("transfer-encoding") + return v is not None and v.casefold() == "chunked" + + @property + def upgrade(self) -> bool: + return self.get("upgrade") is not None or "upgrade" in (self.get("connection") or "").lower() + + def validate_framing(self) -> None: + """Reject the request-smuggling shapes RFC 7230 §3.3.3 permits refusing. + + Called by ``read_head`` before it hands a ``Head`` back to a caller, so + every parsed head -- request or response -- has already been checked + by the time policy code sees it. Synthesised heads (``error_response``) + are not run through this automatically; nothing this module builds + itself needs it. + """ + cls = [v for k, v in self.headers if k.lower() == "content-length"] + tes = [v for k, v in self.headers if k.lower() == "transfer-encoding"] + if len(cls) > 1: + raise HttpError("duplicate Content-Length") + if len(tes) > 1: + raise HttpError("duplicate Transfer-Encoding") + if cls and not _CONTENT_LENGTH_RE.fullmatch(cls[0]): + raise HttpError(f"bad Content-Length: {cls[0]!r}") + if tes and tes[0].casefold() != "chunked": + raise HttpError(f"bad Transfer-Encoding: {tes[0]!r}") + if cls and tes: + raise HttpError("Content-Length and Transfer-Encoding both present") + + def encode(self) -> bytes: + # Defence in depth: re-check the start line and headers even though + # `set()` and `read_head` already validate on the way in, so a `Head` + # built by setting `.start_line`/appending to `.headers` directly + # (bypassing both) still cannot smuggle a control character or a + # non-latin-1 value out onto the wire. + _check_no_control_chars(self.start_line, "start line") + for k, v in self.headers: + _check_header_name(k) + _check_header_value(v) + lines = [self.start_line, *[f"{k}: {v}" for k, v in self.headers], "", ""] + try: + return "\r\n".join(lines).encode("latin-1") + except UnicodeEncodeError as exc: + raise HttpError(f"non-latin-1 start line: {self.start_line!r}") from exc + + +async def read_head(reader: asyncio.StreamReader, limit: int = 65536) -> Head | None: + """Read and validate one HTTP head (request or response). + + ``limit`` is enforced against the fully-buffered head (``len(raw) > + limit``) independently of the ``asyncio.StreamReader``'s own internal + buffer limit (set at construction, default 64 KiB): a `limit` larger + than the reader's own cannot be honoured -- ``readuntil`` will raise + ``asyncio.LimitOverrunError`` (wrapped below as ``HttpError``) before + this function's own check ever runs. Callers that want *this* function's + limit to be the one that fires must construct the reader with a + correspondingly larger internal limit. + """ + try: + raw = await reader.readuntil(b"\r\n\r\n") + except asyncio.IncompleteReadError as exc: + if not exc.partial.strip(): + return None + raise HttpError("truncated head") from exc + except asyncio.LimitOverrunError as exc: + raise HttpError("head too large") from exc + if len(raw) > limit: + raise HttpError("head too large") + text = raw.decode("latin-1") + first, _, rest = text.partition("\r\n") + _validate_head_start_line(first) + headers: list[tuple[str, str]] = [] + for line in rest.split("\r\n"): + if not line: + continue + if line[0] in " \t": + raise HttpError(f"obsolete line folding: {line!r}") + k, sep, v = line.partition(":") + if not sep: + raise HttpError(f"bad header line: {line!r}") + name, value = k.strip(), v.strip() + _check_header_name(name) + _check_header_value(value) + headers.append((name, value)) + head = Head(first, headers) + head.validate_framing() + return head + + +def parse_request_line(line: str) -> tuple[str, str, dict[str, list[str]]]: + _check_no_control_chars(line, "request line") + parts = line.split(" ") + if len(parts) != 3 or not _METHOD_RE.fullmatch(parts[0]) or not _VERSION_RE.fullmatch(parts[2]): + raise HttpError(f"bad request line: {line!r}") + target = parts[1] + if not target or "\\" in target: + raise HttpError(f"bad request target: {line!r}") + url = urlsplit(target) + return parts[0], url.path or "/", parse_qs(url.query, keep_blank_values=True) + + +async def _read_chunked(reader: asyncio.StreamReader, sink: asyncio.StreamWriter | None, limit: int) -> bytes: + out = bytearray() + while True: + try: + size_line = await reader.readuntil(b"\r\n") + except asyncio.IncompleteReadError as exc: + raise HttpError("truncated chunk size") from exc + except asyncio.LimitOverrunError as exc: + raise HttpError("chunk size line too large") from exc + size_token = size_line.split(b";", 1)[0].split(b"\r", 1)[0] + if not _CHUNK_SIZE_RE.fullmatch(size_token): + raise HttpError(f"bad chunk size: {size_token!r}") + size = int(size_token, 16) + if sink is None and len(out) + size > limit: + raise HttpError("body too large") + chunk = b"" + if size: + try: + chunk = await reader.readexactly(size + 2) + except asyncio.IncompleteReadError as exc: + raise HttpError("truncated chunk data") from exc + if chunk[-2:] != b"\r\n": + raise HttpError("bad chunk terminator") + if sink is not None: + sink.write(size_line + chunk) + await sink.drain() + else: + out += chunk[:-2] if chunk else b"" + if size == 0: + # trailers, if any, end with an empty line + while True: + try: + line = await reader.readuntil(b"\r\n") + except (asyncio.IncompleteReadError, asyncio.LimitOverrunError) as exc: + raise HttpError("bad chunk trailer") from exc + if sink is not None: + sink.write(line) + if line == b"\r\n": + break + if sink is not None: + await sink.drain() + return bytes(out) + + +async def read_body(reader: asyncio.StreamReader, head: Head, limit: int) -> bytes: + if head.chunked: + return await _read_chunked(reader, None, limit) + n = head.content_length or 0 + if n > limit: + raise HttpError("body too large") + if not n: + return b"" + try: + return await reader.readexactly(n) + except asyncio.IncompleteReadError as exc: + raise HttpError("body truncated") from exc + + +async def pump(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, head: Head) -> None: + """Stream a body from ``reader`` to ``writer`` exactly as framed by ``head``.""" + if head.chunked: + await _read_chunked(reader, writer, 0) + return + n = head.content_length + if n is None: + while chunk := await reader.read(65536): + writer.write(chunk) + await writer.drain() + return + while n > 0: + chunk = await reader.read(min(65536, n)) + if not chunk: + raise HttpError("body truncated") + n -= len(chunk) + writer.write(chunk) + await writer.drain() + + +def _try_write_eof(writer: asyncio.StreamWriter) -> None: + try: + if writer.can_write_eof(): + writer.write_eof() + except (OSError, RuntimeError, NotImplementedError): + # Best effort by construction: half-closing is a courtesy to the + # peer, not part of the relay's contract. The transport may + # already be gone (OSError), the writer already closing + # (RuntimeError), or the transport may not implement EOF at all + # (NotImplementedError) -- in every one of those the connection + # is over anyway, and raising here would mask the real error the + # caller's `finally` is unwinding from. + pass + + +async def _copy(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while chunk := await reader.read(65536): + writer.write(chunk) + await writer.drain() + finally: + _try_write_eof(writer) + + +async def pipe( + a_reader: asyncio.StreamReader, + b_writer: asyncio.StreamWriter, + b_reader: asyncio.StreamReader, + a_writer: asyncio.StreamWriter, +) -> None: + """Relay both directions until each reaches EOF; a real failure tears both down. + + Each direction's ``_copy`` half-closes its own target writer and returns + normally on EOF, so one side finishing does not cut off the other: ``pipe`` + waits for *both* to finish. Only an exception (not a clean EOF) triggers + cancelling the still-running direction early. Either way, the cancelled or + finished tasks are always awaited (never leaked/dropped), and a real + exception from either direction surfaces as ``HttpError`` so the relay can + log and close instead of silently swallowing it. + """ + tasks = [asyncio.create_task(_copy(a_reader, b_writer)), asyncio.create_task(_copy(b_reader, a_writer))] + results: list[object] = [] + try: + await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) + finally: + for t in tasks: + t.cancel() + results = await asyncio.gather(*tasks, return_exceptions=True) + for w in (a_writer, b_writer): + w.close() + for result in results: + if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError): + raise HttpError(f"pipe direction failed: {result}") from result + + +def error_response(status: int, message: str) -> bytes: + body = json.dumps({"message": message}).encode() + head = Head( + f"HTTP/1.1 {status} {_REASONS.get(status, 'Error')}", + [ + ("Content-Type", "application/json"), + ("Content-Length", str(len(body))), + ("Connection", "close"), + ], + ) + return head.encode() + body diff --git a/tools/container-gateway/src/container_gateway/labels.py b/tools/container-gateway/src/container_gateway/labels.py new file mode 100644 index 000000000..68873d8ff --- /dev/null +++ b/tools/container-gateway/src/container_gateway/labels.py @@ -0,0 +1,77 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Project identity for the gateway: one label, one slug, one filter.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from pathlib import Path + +LABEL_KEY = "org.apache.magpie.project" + + +def project_slug(root: Path) -> str: + """The project root as Claude Code's scratch-tree slug: resolved path, ``/`` -> ``-``.""" + return str(Path(root).resolve()).replace("/", "-") + + +def label_filter_value(slug: str) -> str: + return f"{LABEL_KEY}={slug}" + + +def has_label(labels: Mapping[str, str] | None, slug: str) -> bool: + if not labels: + return False + return labels.get(LABEL_KEY) == slug + + +def with_label(labels: Mapping[str, str] | None, slug: str) -> dict[str, str]: + out = dict(labels or {}) + out[LABEL_KEY] = slug + return out + + +def merge_filters(raw: str | None, slug: str) -> str: + """Add the project label to a Docker ``filters`` query value. + + Accepts the list form ``{"label": ["k=v"]}`` and the legacy map form + ``{"label": {"k=v": true}}``; always emits the list form. Filters use + AND logic by the daemon, so a client-supplied label for another project + simply matches nothing. + """ + filters: dict[str, object] = {} + if raw: + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"filters is not valid JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise ValueError("filters must be a JSON object") + filters = parsed + existing = filters.get("label", []) + if isinstance(existing, dict): + labels = [k for k, v in existing.items() if v] + elif isinstance(existing, list): + labels = [str(x) for x in existing] + else: + raise ValueError("filters.label must be a list or an object") + wanted = label_filter_value(slug) + if wanted not in labels: + labels.append(wanted) + filters["label"] = labels + return json.dumps(filters, separators=(",", ":")) diff --git a/tools/container-gateway/src/container_gateway/policy.py b/tools/container-gateway/src/container_gateway/policy.py new file mode 100644 index 000000000..798b039ca --- /dev/null +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -0,0 +1,939 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The gateway's policy: what a request may ask for, and what it gets rewritten to. + +Everything here is a pure function over parsed requests, with one +exception: ``resolve_bind_source`` stats the host filesystem (it follows +symlinks to decide whether a bind source resolves under a bind root). +Nothing here talks to the backend; the label pre-check that needs +backend I/O lives in relay.py. Canonical-spelling enforcement (case and +duplicate-key ambiguity) lives in ``policy_shape.py``, imported below. + +``check_create`` is defensively layered: a total shape guard denies a +non-dict body outright, ``_malformed_shape`` type-guards every field the +rest of the checks read (in both shapes, regardless of which one the URL +says — several checks below read both spellings unconditionally, so the +guard must too), the spelling check then runs, and a final +``try/except`` backstop denies with a generic reason rather than letting +any residual exception cross this function. ``apply_create_rewrites`` is +not similarly hardened: its contract is that the relay calls it only +after ``check_create`` returned ``None`` for the same body and ``libpod`` +flag, and it does not re-validate what ``check_create`` already accepted. +""" + +from __future__ import annotations + +import copy +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .labels import with_label +from .policy_shape import ( + CATALOG_ANCHOR, + CREATE_ALLOWED_FIELDS, + EXEC_ALLOWED_FIELDS, + EXEC_KNOWN_KEYS, + UPDATE_ALLOWED_FIELDS, + UPDATE_KNOWN_KEYS, + Deny, + allow_list_violation, + canonical_spelling_violation, + object_spelling_violation, + resource_create_spelling_violation, +) + +__all__ = [ + "CATALOG_ANCHOR", + "NETWORK_MODE_KEYWORDS", + "PROXY_VARS", + "Deny", + "PolicyContext", + "apply_create_rewrites", + "check_create", + "check_exec_create", + "check_update", + "named_networks", + "named_volumes", + "resolve_bind_source", + "resource_create_spelling_violation", +] + +PROXY_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy") +_PROXY_VARS_CASEFOLD = frozenset(v.casefold() for v in PROXY_VARS) + +# PidMode / IpcMode / UTSMode / UsernsMode / CgroupnsMode and their libpod +# equivalents (pidns / ipcns / utsns / userns / cgroupns): an allow-list, not +# a deny-list, so an unrecognised mode (a new backend feature, a typo, an +# attempt at obfuscation) is refused rather than silently passed through. +_NAMESPACE_ALLOWED_MODES = frozenset({"", "private", "pod", "auto", "keep-id", "nomap", "shareable"}) + +# A dict-shaped namespace mode (``{"nsmode": ..., "value": ...}``) whose keys +# are not a subset of this set is not a namespace object the policy +# recognises; `_nsmode()` maps it to a sentinel that is in no allow-list, so +# the namespace/network rule denies it even if the spelling check (which +# would normally catch a case-variant or extra key first) were skipped. +_NSMODE_OBJECT_KEYS = frozenset({"nsmode", "value"}) +_NSMODE_MALFORMED_SENTINEL = "" + +# NetworkMode / netns: a fixed set of safe keywords is allowed outright +# (exact match, casefolded); anything that casefold-starts with one of these +# prefixes targets a host, foreign, or otherwise unsafe namespace and is +# refused regardless of spelling case; everything else must look like a +# real network name (see ``_NETWORK_NAME_RE``) to be treated as a named +# network, allowed here and left to the relay's label check (Task 9). +_NETWORK_MODE_KEYWORDS = frozenset( + {"", "default", "bridge", "none", "private", "slirp4netns", "pasta", "pod"} +) +# The same set under a public name: the build-query check in decisions.py +# allows exactly these (minus ``host``, which is not in the set to begin +# with) as a build's ``networkmode``. +NETWORK_MODE_KEYWORDS = _NETWORK_MODE_KEYWORDS +_NETWORK_DENIED_PREFIXES = ("host", "container", "ns", "path", "from-") + +# The docker/podman network-name grammar: an unrecognised value that does not +# even look like a network name is refused rather than treated as one. Always +# matched with `.fullmatch()` (never `.match()`): in a non-MULTILINE regex `$` +# matches just before a trailing newline, so `.match()` would let a value like +# "mynet\n" through. +_NETWORK_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*") + +# NetworkingConfig.EndpointsConfig / libpod networks keys: `host`/`none` (any +# case) are not real networks a project could create — moby promotes a lone +# EndpointsConfig entry to the effective network mode, so attaching to a +# network literally named `host`/`none` is the same host/no-network escape +# NetworkMode itself refuses. `bridge`/`podman` are real built-in networks +# every project can already reach without a label check; a project-created +# network literally named `default` is not the built-in default network and +# must reach the relay's label check like any other named network, so it is +# deliberately absent from this allow-list (unlike `_NETWORK_MODE_KEYWORDS`, +# where a bare `NetworkMode: "default"` genuinely means the built-in one). +_NETWORK_HOST_LIKE_KEY_NAMES = frozenset({"host", "none"}) +# `default` is the docker CLI's sentinel for "the default bridge", not a +# user-creatable network: a plain `docker run` sends +# `EndpointsConfig: {"default": {}}` on every create. Treating it as a named +# network made the relay inspect a network that does not exist and refuse +# every create. A project-created network literally named `default` therefore +# goes unchecked here; that is the accepted trade, and such a network is +# still only reachable by a container this project created. +_ENDPOINT_KEY_ALLOWED_KEYWORDS = frozenset({"bridge", "podman", "default"}) + +# Built-in network names every project can already reach; never treated as a +# *named* (foreign) network the relay needs to label-check. `default` is here +# for the reason given above. +_BUILTIN_NETWORK_NAMES = frozenset({"bridge", "podman", "host", "none", "default"}) + +# SecurityOpt keys the policy recognises at all; every other key (including +# unmask, proc-opts) is refused outright. +_SECCOMP_ALLOWED_VALUES = frozenset({"", "default"}) + +_MOUNT_PASSTHROUGH_TYPES = frozenset({"tmpfs"}) +_MOUNT_REFUSED_TYPES = frozenset({"image", "devpts", "npipe"}) + +# --- _malformed_shape's field tables ----------------------------------- +# Checked via `host.get(name)`: for compat this is HostConfig, for libpod +# this is the body itself, so a single unconditional check per name covers +# both shapes' real location for that field (and harmlessly looks in the +# "wrong" object for the other shape's spelling, which is never read from +# there anyway). +_LIST_OR_NONE_HOST_FIELDS = ( + "Binds", + "CapAdd", + "cap_add", + "CapDrop", + "cap_drop", + "SecurityOpt", + "security_opt", + "selinux_opts", + "Devices", + "DeviceRequests", + "DeviceCgroupRules", + "device_cgroup_rule", + "MaskedPaths", + "ReadonlyPaths", + "mask", + "unmask", + "VolumesFrom", + "volumes_from", +) +_MOUNT_LIST_HOST_FIELDS = ("Mounts", "mounts", "portmappings", "volumes") +_DICT_OR_NONE_HOST_FIELDS = ("Sysctls", "sysctl", "LogConfig", "log_configuration") +_NAMESPACE_HOST_FIELDS = ( + "PidMode", + "IpcMode", + "UTSMode", + "UsernsMode", + "CgroupnsMode", + "pidns", + "ipcns", + "utsns", + "userns", + "cgroupns", + "NetworkMode", + "netns", +) +# Checked via `body.get(name)`: these are always at the top of the body +# (Env/Labels/NetworkingConfig for compat, env/labels/networks for libpod — +# and for libpod the body *is* `host`, so this is equivalent to host.get() +# there too). +_DICT_OR_NONE_BODY_FIELDS = ("NetworkingConfig", "networks", "Networks") +_STR_LIST_BODY_FIELDS = ("Env",) +_STR_DICT_BODY_FIELDS = ("env", "Labels", "labels") + + +@dataclass(frozen=True) +class PolicyContext: + slug: str + project_root: Path + bind_roots: tuple[Path, ...] + proxy_env: dict[str, str] | None + egress_mode: str = "inject-if-available" + + +def _host(body: dict[str, Any], libpod: bool) -> dict[str, Any]: + if libpod: + return body + host_config = body.get("HostConfig") + return host_config if isinstance(host_config, dict) else {} + + +def _nsmode(value: Any) -> str: + """Namespace mode as a string for both shapes: ``"host"`` or ``{"nsmode": "host"}``. + + A dict whose keys are not a subset of ``{"nsmode", "value"}`` is not a + namespace object the policy recognises and fails closed to a sentinel + (see ``_NSMODE_MALFORMED_SENTINEL``) rather than reading ``nsmode`` out + of it anyway. + """ + if isinstance(value, dict): + if not set(value).issubset(_NSMODE_OBJECT_KEYS): + return _NSMODE_MALFORMED_SENTINEL + return str(value.get("nsmode", "")) + return str(value or "") + + +def _is_path_like(spec: str) -> bool: + """A ``Binds`` source containing a path separator is a path; otherwise a named volume.""" + return "/" in spec or "\\" in spec + + +def resolve_bind_source(src: str, ctx: PolicyContext) -> bool: + try: + real = Path(src).resolve(strict=False) + except (OSError, RuntimeError): + return False + return any(real == root.resolve() or root.resolve() in real.parents for root in ctx.bind_roots) + + +def _list_of_dicts_deny(name: str, value: Any) -> Deny | None: + if value is None: + return None + if not isinstance(value, list): + return Deny(f"malformed: {name} has the wrong type") + for entry in value: + if not isinstance(entry, dict): + return Deny(f"malformed: {name} entries must be objects") + return None + + +def _malformed_shape(body: dict[str, Any], libpod: bool) -> Deny | None: + """Type-guard every field the rest of ``check_create`` reads, in both shapes. + + Never lets a wrongly-typed field reach an iteration, ``.items()``, or + ``.get()`` call that would raise; every such shape is refused as + ``malformed`` instead. Runs before the spelling check, which itself + assumes these types are already sound. + """ + host_config = body.get("HostConfig") + if host_config is not None and not isinstance(host_config, dict): + return Deny("malformed: HostConfig has the wrong type") + + host = _host(body, libpod) + + for name in _LIST_OR_NONE_HOST_FIELDS: + value = host.get(name) + if value is not None and not isinstance(value, list): + return Deny(f"malformed: {name} has the wrong type") + + for name in _NAMESPACE_HOST_FIELDS: + value = host.get(name) + if value is not None and not isinstance(value, dict | str): + return Deny(f"malformed: {name} has the wrong type") + + for name in _DICT_OR_NONE_HOST_FIELDS: + value = host.get(name) + if value is not None and not isinstance(value, dict): + return Deny(f"malformed: {name} has the wrong type") + + for name in _DICT_OR_NONE_BODY_FIELDS: + value = body.get(name) + if value is not None and not isinstance(value, dict): + return Deny(f"malformed: {name} has the wrong type") + + for name in _MOUNT_LIST_HOST_FIELDS: + denial = _list_of_dicts_deny(name, host.get(name)) + if denial is not None: + return denial + + port_bindings = host.get("PortBindings") + if port_bindings is not None: + if not isinstance(port_bindings, dict): + return Deny("malformed: PortBindings has the wrong type") + for bindings in port_bindings.values(): + denial = _list_of_dicts_deny("PortBindings", bindings) + if denial is not None: + return denial + + for name in _STR_LIST_BODY_FIELDS: + value = body.get(name) + if value is not None and ( + not isinstance(value, list) or not all(isinstance(item, str) for item in value) + ): + return Deny(f"malformed: {name} has the wrong type") + + for name in _STR_DICT_BODY_FIELDS: + value = body.get(name) + if value is not None and ( + not isinstance(value, dict) + or not all(isinstance(k, str) and isinstance(v, str) for k, v in value.items()) + ): + return Deny(f"malformed: {name} has the wrong type") + + return None + + +def _split_security_opt(opt: str) -> tuple[str, str]: + """Split on the first ``=`` or ``:``, whichever comes first (both separators are legal).""" + indices = [i for i in (opt.find("="), opt.find(":")) if i != -1] + if not indices: + return opt, "" + idx = min(indices) + return opt[:idx], opt[idx + 1 :] + + +def _security_opt_denied(opt: str) -> bool: + raw_key, raw_value = _split_security_opt(opt) + key = raw_key.casefold().strip() + value = raw_value.casefold().strip() + if key == "seccomp": + return value not in _SECCOMP_ALLOWED_VALUES + if key == "apparmor": + return value == "unconfined" + if key == "label": + return value == "disable" + if key == "no-new-privileges": + return value not in ("", "true") + if key == "systempaths": + return value != "" + # unmask, proc-opts, and any key the policy does not explicitly allow. + return True + + +def _endpoint_maps(body: dict[str, Any]) -> list[dict[str, Any]]: + """Every per-network map in this body, in either shape. + + Three positions carry one: libpod ``Networks`` (podman's own spelling + since the new network stack), libpod ``networks`` (the older spelling, + still accepted -- see ``policy_shape.LIBPOD_TOP_KEYS``), and compat + ``NetworkingConfig.EndpointsConfig``. All three are read regardless of + which URL flavour the request came in on, like every other rule here. + """ + maps: list[dict[str, Any]] = [] + for key in ("networks", "Networks"): + value = body.get(key) + if isinstance(value, dict): + maps.append(value) + networking_config = body.get("NetworkingConfig") + if isinstance(networking_config, dict): + endpoints_config = networking_config.get("EndpointsConfig") + if isinstance(endpoints_config, dict): + maps.append(endpoints_config) + return maps + + +# Compat ``LogConfig.Type`` values that cannot name a host path. Everything +# else (``k8s-file``, ``journald`` with a path option, a plugin driver) is +# the same escape libpod's ``log_configuration`` is refused for. +_LOG_DRIVER_ALLOWED = frozenset({"", "json-file", "local", "none"}) + +# The drivers whose options are worth reading at all: the two that write +# into the daemon's own log store, plus the unset one. ``none`` discards +# the stream, so an option on it asks for nothing this policy can honour +# and is refused with the rest. +_LOG_OPTION_DRIVERS = frozenset({"", "json-file", "local"}) + +# The rotation / formatting options ``docker run --log-opt`` and compose's +# ``logging.options`` carry. An allow-list, not a deny-list, for the reason +# the create body is one: the option map is the one place a log driver takes +# a host path. ``path`` is deliberately absent -- podman's compat endpoint +# maps a ``path`` option straight onto libpod's ``log_configuration.path``, +# which this policy refuses by name. +_LOG_OPTION_ALLOWED_KEYS = frozenset( + { + "compress", + "env", + "env-regex", + "labels", + "labels-regex", + "max-buffer-size", + "max-file", + "max-size", + "mode", + "size", + "tag", + } +) + + +def _log_config_deny(host: dict[str, Any]) -> Deny | None: + """Compat ``LogConfig``: a safe driver, and only rotation-shaped options. + + libpod's ``log_configuration`` is refused by name (it carries a + ``path``); compat's ``LogConfig`` is the same capability spelled with a + driver plus an options map, and podman's compat endpoint maps it onto + the same libpod field. It cannot be refused by name, because the docker + CLI sends ``{"Type": "", "Config": {}}`` on every create -- and refusing + a set ``Config`` outright refuses ``docker run --log-opt max-size=10m`` + and every compose service with a ``logging.options`` block, which is a + lot of day-one breakage for a field whose danger is one option key. + """ + log_config = host.get("LogConfig") + if not isinstance(log_config, dict): + return None + driver = str(log_config.get("Type") or "").casefold() + if driver not in _LOG_DRIVER_ALLOWED: + return Deny(f"log-configuration: log driver {log_config.get('Type')} is refused") + options = log_config.get("Config") + if not options: + return None + if driver not in _LOG_OPTION_DRIVERS: + return Deny("log-configuration: log-driver options are refused") + if not isinstance(options, dict): + return Deny("malformed: LogConfig.Config has the wrong type") + for key in options: + if str(key).strip().casefold() not in _LOG_OPTION_ALLOWED_KEYS: + return Deny(f"log-configuration: log-driver option {key} is refused") + return None + + +# podman's own annotation namespace: `io.podman.annotations.privileged` +# and its siblings are how podman records (and, on some paths, restores) +# the very flags this policy refuses, so an annotation in that namespace +# is refused whatever its value. +_PODMAN_ANNOTATION_PREFIX = "io.podman.annotations." + + +def _annotations_deny(body: dict[str, Any], host: dict[str, Any]) -> Deny | None: + for source in (body.get("annotations"), host.get("Annotations")): + if not isinstance(source, dict): + continue + for key in source: + if str(key).casefold().startswith(_PODMAN_ANNOTATION_PREFIX): + return Deny(f"annotations: {key} is podman's own control namespace and is refused") + return None + + +def _security_opt_deny(host: dict[str, Any]) -> Deny | None: + for opt in (host.get("SecurityOpt") or []) + (host.get("security_opt") or []): + opt_str = str(opt) + if _security_opt_denied(opt_str): + return Deny(f"security-opt: {opt_str} is refused") + + for opt in host.get("selinux_opts") or []: + if str(opt).casefold().strip() == "disable": + return Deny(f"security-opt: selinux_opts={opt} is refused") + apparmor_profile = host.get("apparmor_profile") + if apparmor_profile is not None and str(apparmor_profile).casefold() == "unconfined": + return Deny(f"security-opt: apparmor_profile={apparmor_profile} is refused") + seccomp_policy = host.get("seccomp_policy") + if seccomp_policy is not None and str(seccomp_policy).casefold() not in _SECCOMP_ALLOWED_VALUES: + return Deny(f"security-opt: seccomp_policy={seccomp_policy} is refused") + if host.get("seccomp_profile_path"): + return Deny(f"security-opt: seccomp_profile_path={host['seccomp_profile_path']} is refused") + return None + + +# A volume driver configuration is the ``VolumeDriver`` capability reached +# by another name: ``{"type": "none", "device": "/", "o": "bind"}`` handed to +# the stock ``local`` driver is a host-root bind mount, and an anonymous +# volume carrying it has no name for the relay to label-check. Refused +# whatever the driver and whatever the options, since any driver +# configuration is a reference to a host resource. +_VOLUME_DRIVER_DENY_REASON = "volume-driver: a volume driver configuration is refused; use a named volume" + +# libpod passes a driver configuration as option strings on the mount (or on +# the named volume): ``volume-opt=device=/``. +_VOLUME_DRIVER_OPTION_KEYS = frozenset({"volume-opt", "volume-driver", "driver"}) + + +def _volume_driver_options(options: Any) -> bool: + """True when a libpod option list carries a volume driver configuration.""" + if not isinstance(options, list): + return False + return any( + str(option).split("=", 1)[0].strip().casefold() in _VOLUME_DRIVER_OPTION_KEYS for option in options + ) + + +def _volume_driver_config(entry: dict[str, Any]) -> bool: + """True when a volume mount entry carries a driver configuration, in either shape. + + compat spells it ``VolumeOptions.DriverConfig``; libpod spells it as + ``volume-opt=`` entries in the mount's ``options`` list. Both are read + unconditionally, regardless of which URL flavour the request came in on, + like every other rule here. ``VolumeOptions``'s own inner keys are not + covered by the canonical-spelling check (only the mount's own keys are), + so the inner lookup is casefolded. + """ + for key, value in entry.items(): + if str(key).casefold() != "volumeoptions" or not isinstance(value, dict): + continue + for inner_key, inner_value in value.items(): + if str(inner_key).casefold() == "driverconfig" and inner_value: + return True + return _volume_driver_options(entry.get("options")) + + +def _mount_type_deny( + entry: dict[str, Any], type_key: str, source_key: str, ctx: PolicyContext +) -> Deny | None: + raw_type = entry.get(type_key) + mtype = str(raw_type).casefold() if raw_type else "" + if mtype == "bind": + source = str(entry.get(source_key, "")) + if not resolve_bind_source(source, ctx): + roots = ", ".join(str(r) for r in ctx.bind_roots) + return Deny(f"bind-mount: {source} is outside the allowed roots ({roots})") + return None + if mtype == "volume": + if _volume_driver_config(entry): + return Deny(_VOLUME_DRIVER_DENY_REASON) + return None # a named volume; the relay label-checks it (Task 9) + if mtype in _MOUNT_PASSTHROUGH_TYPES: + return None + if mtype in _MOUNT_REFUSED_TYPES: + return Deny(f"mount-type: {raw_type} is refused") + return Deny("mount-type: every mount needs an explicit type of bind, volume or tmpfs") + + +def _mounts_deny( + body: dict[str, Any], host: dict[str, Any], ctx: PolicyContext, *, libpod: bool +) -> Deny | None: + if not libpod: + for spec in host.get("Binds") or []: + src = str(spec).split(":", 1)[0] + if src and _is_path_like(src) and not resolve_bind_source(src, ctx): + roots = ", ".join(str(r) for r in ctx.bind_roots) + return Deny(f"bind-mount: {src} is outside the allowed roots ({roots})") + + type_key, source_key = ("type", "source") if libpod else ("Type", "Source") + mounts = body.get("mounts") if libpod else host.get("Mounts") + for entry in mounts or []: + # _malformed_shape has already rejected non-dict entries by this point. + denial = _mount_type_deny(entry, type_key, source_key, ctx) + if denial is not None: + return denial + # libpod's top-level named-volume list carries the same driver + # configuration under its own key, and an entry with no ``Name`` is + # invisible to the relay's label check. + for volume in body.get("volumes") or []: + if isinstance(volume, dict) and _volume_driver_options(volume.get("Options")): + return Deny(_VOLUME_DRIVER_DENY_REASON) + return None + + +def _network_mode_deny(key: str, net: str) -> Deny | None: + """Exact classifier for a ``NetworkMode``/``netns`` value. + + keyword -> allow; the ``_nsmode`` malformed sentinel -> deny (the netns + object itself is malformed, not merely a bad name, so this message names + the object rather than a value); a host/foreign-namespace prefix -> deny; + otherwise the value must look like a real network name (see + ``_NETWORK_NAME_RE``) to be treated as a named network, allowed here and + left to the relay's label check (Task 9) — anything else is refused + rather than passed through. + """ + net_cf = net.casefold() + if net_cf in _NETWORK_MODE_KEYWORDS: + return None + if net == _NSMODE_MALFORMED_SENTINEL: + return Deny(f"network: {key} netns object has unexpected keys") + if net_cf.startswith(_NETWORK_DENIED_PREFIXES): + return Deny(f"network: {key}={net} is refused") + if not _NETWORK_NAME_RE.fullmatch(net): + return Deny(f"network: {net} is not a valid network name") + return None + + +def _network_key_deny(name: str) -> Deny | None: + """Exact classifier for one ``NetworkingConfig.EndpointsConfig`` / libpod ``networks`` key. + + Mirrors ``_network_mode_deny`` but with a key-shaped allow-list: real + built-in networks (``bridge``/``podman``) allow, ``host``/``none`` deny + (moby promotes a lone ``EndpointsConfig`` entry to the effective network + mode, so this is the same escape ``NetworkMode`` itself refuses), and the + same denied-prefix / name-grammar checks apply to everything else. Unlike + ``_network_mode_deny``, a key is always a plain string (a JSON object + key), never a netns object, so there is no malformed-sentinel branch here + — a key that happened to equal the sentinel text would fail the grammar + check below anyway. + """ + name_cf = name.casefold() + if name_cf in _ENDPOINT_KEY_ALLOWED_KEYWORDS: + return None + if name_cf in _NETWORK_HOST_LIKE_KEY_NAMES or name_cf.startswith(_NETWORK_DENIED_PREFIXES): + return Deny(f"network: network name {name} is refused") + if not _NETWORK_NAME_RE.fullmatch(name): + return Deny(f"network: {name} is not a valid network name") + return None + + +def _named_network_candidate(host: dict[str, Any], libpod: bool) -> str | None: + """The named-network value of ``NetworkMode``/``netns``, if any. + + Deliberately distinct from ``_network_mode_deny`` returning ``None``: a + fixed keyword (``""``, ``bridge``, ...) is *allowed* by the policy but is + not a *named* network to report — only a value that clears every check + and matches the network-name grammar is a genuine candidate. + """ + net = _nsmode(host.get("netns") if libpod else host.get("NetworkMode")) + net_cf = net.casefold() + if net_cf in _NETWORK_MODE_KEYWORDS or net == _NSMODE_MALFORMED_SENTINEL: + return None + if net_cf.startswith(_NETWORK_DENIED_PREFIXES): + return None + if not _NETWORK_NAME_RE.fullmatch(net): + return None + return net + + +def named_volumes(body: dict[str, Any], libpod: bool) -> list[str]: + """Named volumes this create body references: the relay label-checks each one (Task 9).""" + host = _host(body, libpod) + names: list[str] = [] + if not libpod: + for spec in host.get("Binds") or []: + src = str(spec).split(":", 1)[0] + if src and not _is_path_like(src): + names.append(src) + type_key, source_key = ("type", "source") if libpod else ("Type", "Source") + mounts = body.get("mounts") if libpod else host.get("Mounts") + for entry in mounts or []: + if isinstance(entry, dict) and str(entry.get(type_key, "")).casefold() == "volume": + source = str(entry.get(source_key, "")) + if source: + # An empty source is an *anonymous* volume: the daemon + # invents a fresh name for it, so there is nothing for the + # relay to label-check and no name to inspect. + names.append(source) + if libpod: + for entry in body.get("volumes") or []: + if isinstance(entry, dict) and entry.get("Name"): + names.append(str(entry["Name"])) + return names + + +def named_networks(body: dict[str, Any], libpod: bool) -> list[str]: + """Named (foreign) networks this create body attaches to: the relay label-checks each one (Task 9). + + Covers the ``NetworkMode``/``netns`` named-network value, compat + ``NetworkingConfig.EndpointsConfig`` keys, and libpod top-level + ``networks`` keys — reading both endpoint-map shapes unconditionally, + regardless of which URL flavour this request came in on, exactly like + ``check_create``'s equivalent check. Built-in network names (``bridge``, + ``podman``, ``host``, ``none``) are excluded — ``check_create`` already + refuses ``host``/``none`` outright, whether as a ``NetworkMode``/``netns`` + value or as an ``EndpointsConfig``/``networks`` key (moby promotes a lone + ``EndpointsConfig`` entry to the effective network mode, so a `host`/ + `none` key is the same escape), and ``bridge``/``podman`` are real + built-in networks every project can already reach without a label check. + A network literally named ``default`` is a normal named (foreign) network + like any other — not a built-in — so it is deliberately not excluded here + and does get a label check; see ``_ENDPOINT_KEY_ALLOWED_KEYWORDS``. This + helper assumes the same precondition as ``apply_create_rewrites``: it is + only meaningful on a body ``check_create`` already accepted. Deduplicated, + first-seen order. + """ + host = _host(body, libpod) + candidates: list[str] = [] + + network_mode = _named_network_candidate(host, libpod) + if network_mode is not None: + candidates.append(network_mode) + + for endpoint_map in _endpoint_maps(body): + candidates.extend(str(k) for k in endpoint_map) + + seen: set[str] = set() + result: list[str] = [] + for name in candidates: + if name.casefold() in _BUILTIN_NETWORK_NAMES or name in seen: + continue + seen.add(name) + result.append(name) + return result + + +def check_create(body: Any, ctx: PolicyContext, *, libpod: bool) -> Deny | None: + """Entry point: accepts whatever the client sent, unnarrowed. + + ``body`` is typed ``Any`` (not ``dict[str, Any]``) deliberately: callers + (``decide()`` in ``decisions.py``) pass the request body through exactly + as received, including a non-dict JSON value, and rely on the + ``isinstance`` guard below to deny it rather than pre-filtering it + themselves. ``_check_create`` below is the narrowed, dict-only worker. + """ + if not isinstance(body, dict): + return Deny("malformed: request body must be a JSON object") + try: + return _check_create(body, ctx, libpod=libpod) + except (TypeError, AttributeError, ValueError, KeyError): + return Deny("malformed: unexpected request shape") + + +def _check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> Deny | None: + malformed = _malformed_shape(body, libpod) + if malformed is not None: + return malformed + + spelling_violation = canonical_spelling_violation(body, libpod) + if spelling_violation is not None: + return spelling_violation + + # The allow-list (C1): every key the gateway has not learned is refused + # here, so the value rules below are a second layer over a bounded set + # of fields rather than the only thing standing between a client and a + # field nobody thought of. + field_violation = allow_list_violation(body, CREATE_ALLOWED_FIELDS) + if field_violation is not None: + return field_violation + host_config = body.get("HostConfig") + if isinstance(host_config, dict): + field_violation = allow_list_violation(host_config, CREATE_ALLOWED_FIELDS) + if field_violation is not None: + return field_violation + + host = _host(body, libpod) + + if host.get("Privileged") or host.get("privileged"): + return Deny("privileged: drop --privileged; the gateway never grants it") + if host.get("CapAdd") or host.get("cap_add"): + return Deny("cap-add: added capabilities are refused; run without --cap-add") + if any( + host.get(k) + for k in ("Devices", "DeviceRequests", "DeviceCgroupRules", "devices", "device_cgroup_rule") + ): + return Deny("devices: host devices are refused; run without --device / --gpus") + + for key in ( + "PidMode", + "IpcMode", + "UTSMode", + "UsernsMode", + "CgroupnsMode", + "pidns", + "ipcns", + "utsns", + "userns", + "cgroupns", + ): + mode = _nsmode(host.get(key)) + if mode.casefold() not in _NAMESPACE_ALLOWED_MODES: + return Deny( + f"namespace: {key}={mode} is refused; " + "only private, pod, auto, keep-id, nomap and shareable are allowed" + ) + + for key in ("NetworkMode", "netns"): + net_deny = _network_mode_deny(key, _nsmode(host.get(key))) + if net_deny is not None: + return net_deny + + # Read every endpoint-map shape unconditionally, regardless of which URL + # flavour this request came in on — like every other rule in this + # function (see the module docstring): a client could smuggle the + # "other" shape's field past a check gated on `libpod`. + for endpoint_map in _endpoint_maps(body): + for network_key in endpoint_map: + key_deny = _network_key_deny(str(network_key)) + if key_deny is not None: + return key_deny + + security_opt_deny = _security_opt_deny(host) + if security_opt_deny is not None: + return security_opt_deny + + log_config_deny = _log_config_deny(host) + if log_config_deny is not None: + return log_config_deny + + annotations_deny = _annotations_deny(body, host) + if annotations_deny is not None: + return annotations_deny + + if host.get("Sysctls") or host.get("sysctl"): + return Deny("sysctls: kernel parameters are refused") + if host.get("CgroupParent") or host.get("cgroup_parent"): + return Deny("cgroup-parent: custom cgroup parents are refused") + if host.get("Runtime") or host.get("oci_runtime"): + return Deny("runtime: alternative OCI runtimes are refused") + if host.get("Isolation"): + return Deny("isolation: the Isolation field is refused") + if host.get("MaskedPaths") is not None: + return Deny("masked-paths: MaskedPaths is refused") + if host.get("mask") is not None: + return Deny("masked-paths: mask is refused") + if host.get("ReadonlyPaths") is not None: + return Deny("readonly-paths: ReadonlyPaths is refused") + if host.get("unmask") is not None: + return Deny("security-opt: unmask is refused") + + if host.get("PublishAllPorts") or host.get("publish_image_ports"): + return Deny("publish-all: publishing every exposed port binds 0.0.0.0; publish ports explicitly") + + mounts_deny = _mounts_deny(body, host, ctx, libpod=libpod) + if mounts_deny is not None: + return mounts_deny + + if host.get("VolumesFrom") or host.get("volumes_from"): + # The relay would have to label-check every listed container; refuse + # outright until that is needed — named volumes cover the common case. + return Deny("volumes-from: --volumes-from is refused; share a named volume instead") + + if ctx.egress_mode == "require" and not ctx.proxy_env: + return Deny("egress-required: the egress gateway is unreachable and --egress require is set") + return None + + +# Fields an exec / update body may not carry, with the same slugs the +# create rules use, so a client sees one vocabulary across endpoints. +_EXEC_DENIED_FIELDS: dict[str, str] = { + "capadd": "cap-add: added capabilities are refused; run without --cap-add", + "cap_add": "cap-add: added capabilities are refused; run without --cap-add", + "capdrop": "cap-drop: capability changes are not accepted on exec", + "cap_drop": "cap-drop: capability changes are not accepted on exec", + "devices": "devices: host devices are refused; run without --device / --gpus", + "devicerequests": "devices: host devices are refused; run without --device / --gpus", + "devicecgrouprules": "devices: host devices are refused; run without --device / --gpus", + "device_cgroup_rule": "devices: host devices are refused; run without --device / --gpus", + "pidmode": "namespace: a namespace change is not accepted on exec", + "ipcmode": "namespace: a namespace change is not accepted on exec", + "utsmode": "namespace: a namespace change is not accepted on exec", + "usernsmode": "namespace: a namespace change is not accepted on exec", + "cgroupnsmode": "namespace: a namespace change is not accepted on exec", + "networkmode": "network: a network change is not accepted on exec", + "pidns": "namespace: a namespace change is not accepted on exec", + "ipcns": "namespace: a namespace change is not accepted on exec", + "utsns": "namespace: a namespace change is not accepted on exec", + "userns": "namespace: a namespace change is not accepted on exec", + "cgroupns": "namespace: a namespace change is not accepted on exec", + "netns": "network: a network change is not accepted on exec", + "securityopt": "security-opt: security options are not accepted on exec", + "security_opt": "security-opt: security options are not accepted on exec", + "unmask": "security-opt: unmask is refused", +} + + +def check_exec_create(body: Any) -> Deny | None: + """``POST /containers//exec``: an allow-list over the exec body. + + The exec body is a second create surface — ``{"Privileged": true}`` + grants the exec process everything a privileged container would have — + and was forwarded unexamined. Both APIs use moby's spelling here + (libpod's exec endpoint decodes the same struct), so one table covers + both. + """ + if not isinstance(body, dict): + return Deny("malformed: request body must be a JSON object") + try: + spelling = object_spelling_violation(body, EXEC_KNOWN_KEYS) + if spelling is not None: + return spelling + denial = allow_list_violation(body, EXEC_ALLOWED_FIELDS, denied=_EXEC_DENIED_FIELDS) + if denial is not None: + return denial + if body.get("Privileged"): + return Deny("privileged: drop --privileged; the gateway never grants it") + except (TypeError, AttributeError, ValueError, KeyError): + return Deny("malformed: unexpected request shape") + return None + + +def check_update(body: Any) -> Deny | None: + """``POST /containers//update``: resource limits and restart policy only. + + moby's ``UpdateConfig`` is ``Resources`` plus ``RestartPolicy``; a + daemon that accepted more from this endpoint would let a client raise + limits the create policy bounded, so anything outside that set is + refused rather than forwarded. + """ + if not isinstance(body, dict): + return Deny("malformed: request body must be a JSON object") + try: + spelling = object_spelling_violation(body, UPDATE_KNOWN_KEYS) + if spelling is not None: + return spelling + return allow_list_violation(body, UPDATE_ALLOWED_FIELDS, denied=_EXEC_DENIED_FIELDS) + except (TypeError, AttributeError, ValueError, KeyError): + return Deny("malformed: unexpected request shape") + + +def apply_create_rewrites(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> dict[str, Any]: + """Apply the label / HostIp / proxy-env rewrites. + + Precondition: ``check_create(body, ctx, libpod=libpod)`` must already + have returned ``None`` for this exact body — the relay always calls the + two in that order. This function does not re-validate shapes + ``check_create`` already rejected (e.g. a non-dict ``Env``/``Labels``). + """ + out = copy.deepcopy(body) + if libpod: + out["labels"] = with_label(out.get("labels"), ctx.slug) + for pm in out.get("portmappings") or []: + if not pm.get("host_ip"): + pm["host_ip"] = "127.0.0.1" + if ctx.proxy_env and ctx.egress_mode != "off": + env = { + k: v for k, v in (out.get("env") or {}).items() if k.casefold() not in _PROXY_VARS_CASEFOLD + } + env.update(ctx.proxy_env) + out["env"] = env + # podman's CLI sets `httpproxy: true` on every run, which asks + # the *daemon* to add its own proxy variables to the container. + # The gateway is the one deciding the container's egress, so + # the daemon's copy is turned off whenever ours goes in. + out["httpproxy"] = False + else: + out["Labels"] = with_label(out.get("Labels"), ctx.slug) + # `out.setdefault` only fills in an *absent* key; an explicit + # `"HostConfig": null` (which check_create allows - see + # `_malformed_shape`) leaves `hc` as `None` and the `.get()` below + # raises. `or {}` normalises both "absent" and "explicit null". + hc = out.get("HostConfig") or {} + out["HostConfig"] = hc + for bindings in (hc.get("PortBindings") or {}).values(): + for b in bindings or []: + if not b.get("HostIp"): + b["HostIp"] = "127.0.0.1" + if ctx.proxy_env and ctx.egress_mode != "off": + env_list: list[str] = [ + e for e in out.get("Env") or [] if e.split("=", 1)[0].casefold() not in _PROXY_VARS_CASEFOLD + ] + env_list.extend(f"{k}={v}" for k, v in ctx.proxy_env.items()) + out["Env"] = env_list + return out diff --git a/tools/container-gateway/src/container_gateway/policy_shape.py b/tools/container-gateway/src/container_gateway/policy_shape.py new file mode 100644 index 000000000..d56d6e99e --- /dev/null +++ b/tools/container-gateway/src/container_gateway/policy_shape.py @@ -0,0 +1,713 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Canonical-spelling enforcement for create-request bodies. + +Both dockerd and podman decode JSON with Go's ``encoding/json``: object +keys match struct fields case-insensitively, and when two keys collide +under that comparison the last one wins. A client can therefore smuggle +a policy-relevant field past every check in ``policy.py`` by spelling it +differently than the checks look for (``hostconfig`` instead of +``HostConfig``, ``PRIVILEGED`` instead of ``Privileged``), or can make +the gateway rewrite one spelling of a field while the daemon honours a +second, client-supplied spelling of the same field (``Labels`` and +``labels`` both present). + +This module is pure and stateless: it only inspects key spellings, never +values. It is split out of ``policy.py`` to keep that module below the +file's target size. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +CATALOG_ANCHOR = "docs/setup/sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error" + + +@dataclass(frozen=True) +class Deny: + """A create/act request the policy refuses, and why. + + Defined here (not in ``policy.py``) so this module and ``policy.py`` + can each import it without a circular import between the two. + """ + + reason: str + status: int = 403 + + @property + def message(self) -> str: + return f"container-gateway: {self.reason}; see {CATALOG_ANCHOR}" + + +# Every key the policy in policy.py reads or rewrites, by shape. A client +# key that casefold-matches one of these but is not spelled exactly like +# this is ambiguous: the daemon's case-insensitive decoder might bind it +# to the same struct field, or might not, depending on decode order. +COMPAT_TOP_KEYS = frozenset( + { + "Image", + "Labels", + "Env", + "HostConfig", + "NetworkingConfig", + "Volumes", + "User", + "Entrypoint", + "Cmd", + "ExposedPorts", + } +) + +COMPAT_HOSTCONFIG_KEYS = frozenset( + { + "Privileged", + "CapAdd", + "CapDrop", + "Devices", + "DeviceRequests", + "DeviceCgroupRules", + "PidMode", + "IpcMode", + "UTSMode", + "UsernsMode", + "CgroupnsMode", + "NetworkMode", + "SecurityOpt", + "Sysctls", + "CgroupParent", + "Runtime", + "Isolation", + "MaskedPaths", + "ReadonlyPaths", + "Binds", + "Mounts", + "VolumesFrom", + "PortBindings", + "PublishAllPorts", + "Tmpfs", + } +) + +COMPAT_MOUNT_KEYS = frozenset( + {"Type", "Source", "Target", "ReadOnly", "BindOptions", "VolumeOptions", "TmpfsOptions"} +) + +COMPAT_PORT_BINDING_KEYS = frozenset({"HostIp", "HostPort"}) + +LIBPOD_TOP_KEYS = frozenset( + { + "image", + "labels", + "env", + "privileged", + "cap_add", + "cap_drop", + "devices", + "device_cgroup_rule", + "pidns", + "ipcns", + "utsns", + "userns", + "cgroupns", + "netns", + "networks", + "selinux_opts", + "apparmor_profile", + "seccomp_policy", + "seccomp_profile_path", + "no_new_privileges", + "mask", + "unmask", + "sysctl", + "cgroup_parent", + "oci_runtime", + "mounts", + "volumes", + "volumes_from", + "portmappings", + "publish_image_ports", + "security_opt", + # podman's SpecGenerator spells the per-network map ``Networks`` + # (capital N, the Go field name); older clients send ``networks``. + # Both are canonical -- see ``_spelling_violation_in``. + "Networks", + } +) + +LIBPOD_MOUNT_KEYS = frozenset({"type", "source", "destination", "options"}) + +LIBPOD_PORTMAPPING_KEYS = frozenset({"host_ip", "host_port", "container_port", "protocol", "range"}) + +# The six libpod namespace objects (``{"nsmode": "...", "value": "..."}``): +# NetworkMode's libpod sibling ``netns`` is namespace-shaped too, so it is +# inspected the same way even though the network check itself lives in +# policy.py. +NAMESPACE_OBJECT_FIELDS = ("pidns", "ipcns", "utsns", "userns", "cgroupns", "netns") +NAMESPACE_OBJECT_KEYS = frozenset({"nsmode", "value"}) + +# libpod's top-level named-volume list: ``volumes: [{"Name": ..., "Dest": ...}]``. +# Mixed-case keys inside an otherwise snake_case body — that is podman's own +# SpecGenerator shape, not a spelling choice made here. +LIBPOD_VOLUME_KEYS = frozenset({"Name", "Dest", "Options"}) + +# The (much smaller) volume/network create body shape: every key the gateway +# reads or rewrites there, by shape. +VOLUME_NETWORK_COMPAT_KEYS = frozenset({"Name", "Labels", "Driver", "DriverOpts"}) +VOLUME_NETWORK_LIBPOD_KEYS = frozenset({"name", "labels", "driver", "options"}) + + +def _spelling_violation_in(obj: dict[str, Any], known: frozenset[str]) -> Deny | None: + """Ambiguous-spelling check for one object's own keys. + + Two rules, checked in order so a duplicate is reported as a duplicate + even when one of the two spellings happens to be canonical: + + 1. Two present keys casefold to the same value (a duplicate the + daemon's decoder would silently resolve one way or the other). + 2. A present key casefolds to a key in ``known`` but is not spelled + exactly like one of the accepted spellings for it (an aliased field + the policy would not recognise). + + ``known`` may carry more than one accepted spelling for the same + casefolded name (``networks`` and ``Networks``): podman's own + SpecGenerator renamed that field's JSON tag between releases, so both + spellings are canonical for some client the gateway must serve. Rule 1 + still refuses a body carrying *both* at once. + """ + by_casefold: dict[str, list[str]] = {} + for key in obj: + if isinstance(key, str): + by_casefold.setdefault(key.casefold(), []).append(key) + + for keys in by_casefold.values(): + if len(keys) > 1: + a, b = sorted(keys)[:2] + return Deny(f"ambiguous-field: {a} and {b} name the same field") + + known_by_casefold: dict[str, set[str]] = {} + for k in known: + known_by_casefold.setdefault(k.casefold(), set()).add(k) + for cf, keys in by_casefold.items(): + accepted = known_by_casefold.get(cf) + if accepted is not None and keys[0] not in accepted: + canonical = "/".join(sorted(accepted)) + return Deny(f"ambiguous-field: {keys[0]} is not the canonical spelling of {canonical}") + return None + + +def object_spelling_violation(obj: dict[str, Any], known: frozenset[str]) -> Deny | None: + """``_spelling_violation_in`` for callers outside this module. + + Used by the exec / update body checks in ``policy.py``, whose bodies + are a single flat object rather than the nested create shape + ``canonical_spelling_violation`` walks. + """ + return _spelling_violation_in(obj, known) + + +def canonical_spelling_violation(body: dict[str, Any], libpod: bool) -> Deny | None: + """Deny a body carrying a case-variant or duplicate spelling of a known field. + + Inspected objects: the top-level body, ``HostConfig`` (compat only), + every entry of ``Mounts`` / ``mounts``, every entry of the + ``PortBindings`` value lists / ``portmappings``, every libpod namespace + object (``pidns``/``ipcns``/``utsns``/``userns``/``cgroupns``/``netns``), + every entry of libpod ``volumes``, libpod ``networks``, and + ``NetworkingConfig`` / its ``EndpointsConfig`` if present + (collision-only for both of those — the API does not fix their key set). + """ + if libpod: + violation = _spelling_violation_in(body, LIBPOD_TOP_KEYS) + if violation is not None: + return violation + for mount in body.get("mounts") or []: + if isinstance(mount, dict): + violation = _spelling_violation_in(mount, LIBPOD_MOUNT_KEYS) + if violation is not None: + return violation + for mapping in body.get("portmappings") or []: + if isinstance(mapping, dict): + violation = _spelling_violation_in(mapping, LIBPOD_PORTMAPPING_KEYS) + if violation is not None: + return violation + for volume in body.get("volumes") or []: + if isinstance(volume, dict): + violation = _spelling_violation_in(volume, LIBPOD_VOLUME_KEYS) + if violation is not None: + return violation + for field in NAMESPACE_OBJECT_FIELDS: + namespace_obj = body.get(field) + if isinstance(namespace_obj, dict): + violation = _spelling_violation_in(namespace_obj, NAMESPACE_OBJECT_KEYS) + if violation is not None: + return violation + for key in ("networks", "Networks"): + networks = body.get(key) + if isinstance(networks, dict): + violation = _spelling_violation_in(networks, frozenset()) + if violation is not None: + return violation + return None + + violation = _spelling_violation_in(body, COMPAT_TOP_KEYS) + if violation is not None: + return violation + + host_config = body.get("HostConfig") + if isinstance(host_config, dict): + violation = _spelling_violation_in(host_config, COMPAT_HOSTCONFIG_KEYS) + if violation is not None: + return violation + for mount in host_config.get("Mounts") or []: + if isinstance(mount, dict): + violation = _spelling_violation_in(mount, COMPAT_MOUNT_KEYS) + if violation is not None: + return violation + port_bindings = host_config.get("PortBindings") + if isinstance(port_bindings, dict): + for bindings in port_bindings.values(): + for binding in bindings or []: + if isinstance(binding, dict): + violation = _spelling_violation_in(binding, COMPAT_PORT_BINDING_KEYS) + if violation is not None: + return violation + + networking_config = body.get("NetworkingConfig") + if isinstance(networking_config, dict): + violation = _spelling_violation_in(networking_config, frozenset()) + if violation is not None: + return violation + endpoints_config = networking_config.get("EndpointsConfig") + if isinstance(endpoints_config, dict): + violation = _spelling_violation_in(endpoints_config, frozenset()) + if violation is not None: + return violation + + return None + + +def resource_create_spelling_violation(body: dict[str, Any], libpod: bool) -> Deny | None: + """Ambiguous-spelling check for a volume/network create body. + + Mirrors ``canonical_spelling_violation`` for containers/pods, but for the + much smaller volume/network create shape (``Name``/``Labels``/``Driver``/ + ``DriverOpts`` compat, ``name``/``labels``/``driver``/``options`` libpod). + Without this, a second spelling of the label field (``labels`` *and* + ``Labels`` both present, or ``LABELS``) would let a client's value + collide with the gateway's injected project label under the daemon's + case-insensitive JSON decode — the same escape + ``canonical_spelling_violation`` already closes for container/pod create. + """ + known = VOLUME_NETWORK_LIBPOD_KEYS if libpod else VOLUME_NETWORK_COMPAT_KEYS + return _spelling_violation_in(body, known) + + +# --- The create-body allow-list ---------------------------------------- +# +# The posture is an allow-list, not a deny-list: a create body may carry +# only the fields enumerated here, and every other key is refused as +# ``unknown-field``. A deny-list over an unbounded JSON body cannot be +# right by construction -- both daemons grow fields faster than this +# policy can learn them, and several of the ones already shipped (libpod +# ``rootfs``, ``overlay_volumes``, ``env_host``, ``log_configuration``) +# turn a container into host access on their own. +# +# The cost of the posture is that a field the gateway has not learned is +# unavailable through it until it is added here; that is deliberate and +# documented in the tool's "Limits and residual risks" section. +# +# The three sets below are merged into one casefolded allow-list applied +# to the top level of both shapes and to compat's ``HostConfig``. They are +# not kept apart per position because the policy already reads both +# shapes' spellings out of both positions unconditionally (see the module +# docstring in policy.py): a field in the "wrong" position is inert for +# the daemon, which decodes into one struct or the other, and splitting +# the tables would only add a way for the two halves to disagree. + +# moby's container.Config plus the create request's own keys. Every one of +# these is sent (at its Go zero value) by a plain ``docker run``. +COMPAT_TOP_ALLOWED = frozenset( + { + "ArgsEscaped", + "AttachStderr", + "AttachStdin", + "AttachStdout", + "Cmd", + "Domainname", + "Entrypoint", + "Env", + "ExposedPorts", + "Healthcheck", + "HostConfig", + "Hostname", + "Image", + "Labels", + "MacAddress", + "NetworkDisabled", + "NetworkingConfig", + "OnBuild", + "OpenStdin", + "Platform", + "Shell", + "StdinOnce", + "StopSignal", + "StopTimeout", + "Tty", + "User", + "Volumes", + "WorkingDir", + "name", + } +) + +# moby's container.HostConfig. The dangerous members are in the table +# above's company only by name: they stay in this set because a real +# ``docker run`` sends every one of them at its zero value, and the +# value-level rules in policy.py are what actually refuse them. +COMPAT_HOSTCONFIG_ALLOWED = frozenset( + { + "Annotations", + "AutoRemove", + "Binds", + "BlkioDeviceReadBps", + "BlkioDeviceReadIOps", + "BlkioDeviceWriteBps", + "BlkioDeviceWriteIOps", + "BlkioWeight", + "BlkioWeightDevice", + "CapAdd", + "CapDrop", + "Capabilities", + "Cgroup", + "CgroupParent", + "CgroupnsMode", + "ConsoleSize", + "ContainerIDFile", + "CpuCount", + "CpuPercent", + "CpuPeriod", + "CpuQuota", + "CpuRealtimePeriod", + "CpuRealtimeRuntime", + "CpuShares", + "CpusetCpus", + "CpusetMems", + "DeviceCgroupRules", + "DeviceRequests", + "Devices", + "Dns", + "DnsOptions", + "DnsSearch", + "ExtraHosts", + "GroupAdd", + "IOMaximumBandwidth", + "IOMaximumIOps", + "Init", + "IpcMode", + "Isolation", + "KernelMemory", + "KernelMemoryTCP", + "Links", + "LogConfig", + "MaskedPaths", + "Memory", + "MemoryReservation", + "MemorySwap", + "MemorySwappiness", + "Mounts", + "NanoCpus", + "NetworkMode", + "OomKillDisable", + "OomScoreAdj", + "PidMode", + "PidsLimit", + "PortBindings", + "Privileged", + "PublishAllPorts", + "ReadonlyPaths", + "ReadonlyRootfs", + "RestartPolicy", + "Runtime", + "SecurityOpt", + "ShmSize", + "StorageOpt", + "Sysctls", + "Tmpfs", + "UTSMode", + "Ulimits", + "UsernsMode", + "VolumeDriver", + "VolumesFrom", + } +) + +# podman's SpecGenerator (container create) and PodSpecGenerator (pod +# create). Taken from the bodies podman 6.1's remote client actually +# sends, plus the SpecGenerator members a flag can set. +LIBPOD_TOP_ALLOWED = frozenset( + { + "Networks", + "annotations", + "apparmor_profile", + "cap_add", + "cap_drop", + "cgroup_parent", + "cgroupns", + "cgroups_mode", + "command", + "conmon_pid_file", + "containerCreateCommand", + "dependencyContainers", + "device_cgroup_rule", + "devices", + "dns_option", + "dns_search", + "dns_server", + "entrypoint", + "env", + "env_merge", + "expose", + "groups", + "healthLogDestination", + "healthMaxLogCount", + "healthMaxLogSize", + "health_check_on_failure_action", + "health_config", + "healthconfig", + "startupHealthConfig", + "base_hosts_file", + "hostadd", + "hostname", + "hostusers", + "httpproxy", + "idmappings", + "image", + "image_arch", + "image_os", + "image_variant", + "image_volume_mode", + "image_volumes", + "init", + "init_container_type", + "ipcns", + "labels", + "manage_password", + "mask", + "mounts", + "name", + "netns", + "networkOrder", + "networks", + "no_hosts", + "no_new_privileges", + "oci_runtime", + "oom_score_adj", + "passwd_entry", + "pidns", + "pod", + "portmappings", + "privileged", + "publish_image_ports", + "raw_image_name", + "read_only_filesystem", + "read_write_tmpfs", + "remove", + "remove_image", + "resource_limits", + "restart_policy", + "restart_tries", + "sdnotifyMode", + "seccomp_policy", + "seccomp_profile_path", + "security_opt", + "selinux_opts", + "shm_size", + "shm_size_systemd", + "static_ip", + "static_ipv6", + "static_mac", + "stdin", + "stop_signal", + "stop_timeout", + "sysctl", + "systemd", + "terminal", + "timeout", + "timezone", + "umask", + "unified", + "unmask", + "unsetenv", + "unsetenvall", + "use_image_hostname", + "use_image_hosts", + "use_image_resolve_conf", + "user", + "userns", + "utsns", + "volatile", + "volumes", + "volumes_from", + "weight_device", + "work_dir", + # ``podman run --ulimit`` (and every run at all when containers.conf + # sets ``default_ulimits``); the camelCase device-limit members of + # podman's resource spec, which the snake_case spellings above miss. + "r_limits", + "weightDevice", + "throttleReadBpsDevice", + "throttleWriteBpsDevice", + "throttleReadIOPSDevice", + "throttleWriteIOPSDevice", + "personality", + # PodSpecGenerator's own members (pod create shares this policy). + "exit_policy", + "infra_command", + "infra_image", + "infra_name", + "no_infra", + "no_manage_hostname", + "no_manage_hosts", + "no_manage_resolv_conf", + "pid", + "pod_create_command", + "serviceContainerID", + "share_parent", + "shared_namespaces", + } +) + +# Fields refused with a reason of their own rather than a bare +# ``unknown-field``. Each is refused only when it carries a *set* value: +# both CLIs serialise the zero value of every member of their create +# struct on every request (``"env_host": false``, ``"log_configuration": +# {}``), so refusing on mere presence would refuse every create. +# +# Keys are casefolded, because that is the only form ``allow_list_violation`` +# looks them up by: a mixed-case key here would be reachable only by a +# client that spelled the field exactly that way, and ``LINKS`` -- which +# Go's decoder binds to the same struct field -- would walk past the table +# into the allow-list and be forwarded. +DENIED_CREATE_FIELDS: dict[str, str] = { + "rootfs": "rootfs: a host path as the container root filesystem is refused", + "rootfs_overlay": "rootfs: an overlay over a host root filesystem is refused", + "overlay_volumes": "overlay-volumes: overlay mounts of host paths are refused", + "env_host": "env-host: exporting the host environment into the container is refused", + "log_configuration": "log-configuration: a custom log driver can write to a host path and is refused", + "secret_env": "secrets: daemon secrets are refused", + "secrets": "secrets: daemon secrets are refused", + "cni_networks": "network: cni_networks is refused; attach networks through networks", + "chroot_directories": "chroot-directories: host directories to chroot into are refused", + "init_path": "init-path: a host path as the container init binary is refused", + "conmon_pid_file": "pid-file: writing a pid file on the host is refused", + "infra_conmon_pid_file": "pid-file: writing a pid file on the host is refused", + "containeridfile": "container-id-file: writing a container id file on the host is refused", + "links": "links: --link reaches another project's container and is refused", + "cgroup": "cgroup-parent: joining another container's cgroup is refused", + "volumedriver": "volume-driver: a custom volume driver is refused", +} + +CREATE_ALLOWED_FIELDS = frozenset( + name.casefold() + for name in ( + COMPAT_TOP_ALLOWED | COMPAT_HOSTCONFIG_ALLOWED | LIBPOD_TOP_ALLOWED | frozenset(DENIED_CREATE_FIELDS) + ) +) + +# ``POST /containers//exec``: the whole body, in the one shape both +# APIs use (libpod's exec endpoint takes moby's spelling). +EXEC_KNOWN_KEYS = frozenset( + { + "AttachStderr", + "AttachStdin", + "AttachStdout", + "Cmd", + "ConsoleSize", + "DetachKeys", + "Env", + "Privileged", + "Tty", + "User", + "WorkingDir", + } +) +EXEC_ALLOWED_FIELDS = frozenset(name.casefold() for name in EXEC_KNOWN_KEYS) + +# ``POST /containers//update``: moby's UpdateConfig, which is +# container.Resources plus RestartPolicy. Nothing here can reach the host; +# everything else in an update body can. +UPDATE_KNOWN_KEYS = frozenset( + { + "BlkioDeviceReadBps", + "BlkioDeviceReadIOps", + "BlkioDeviceWriteBps", + "BlkioDeviceWriteIOps", + "BlkioWeight", + "BlkioWeightDevice", + "CpuCount", + "CpuPercent", + "CpuPeriod", + "CpuQuota", + "CpuRealtimePeriod", + "CpuRealtimeRuntime", + "CpuShares", + "CpusetCpus", + "CpusetMems", + "IOMaximumBandwidth", + "IOMaximumIOps", + "KernelMemory", + "KernelMemoryTCP", + "Memory", + "MemoryReservation", + "MemorySwap", + "MemorySwappiness", + "NanoCpus", + "OomKillDisable", + "PidsLimit", + "RestartPolicy", + "Ulimits", + } +) +UPDATE_ALLOWED_FIELDS = frozenset(name.casefold() for name in UPDATE_KNOWN_KEYS) + + +def allow_list_violation( + obj: dict[str, Any], allowed: frozenset[str], *, denied: dict[str, str] | None = None +) -> Deny | None: + """Refuse a key that is not in ``allowed``, or a ``denied`` one that is set. + + Keys are compared casefolded, because that is how both daemons' JSON + decoders bind an object key to a struct field: a deny keyed on the + exact spelling would be sidestepped by ``ROOTFS``. Every ``denied`` + table is therefore keyed casefolded too, and is looked up only that + way. The canonical-spelling check runs first and refuses a case + variant of a field the policy reasons about; this check is what + refuses everything the policy has never heard of. + """ + denied_fields = DENIED_CREATE_FIELDS if denied is None else denied + for key, value in obj.items(): + if not isinstance(key, str): + return Deny("malformed: object keys must be strings") + casefolded = key.casefold() + reason = denied_fields.get(casefolded) + if reason is not None and value: + return Deny(reason) + if casefolded not in allowed: + return Deny(f"unknown-field: {key} is not accepted by the gateway") + return None diff --git a/tools/container-gateway/src/container_gateway/relay.py b/tools/container-gateway/src/container_gateway/relay.py new file mode 100644 index 000000000..7f7f335ca --- /dev/null +++ b/tools/container-gateway/src/container_gateway/relay.py @@ -0,0 +1,611 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Relay one client connection to the backend under the policy in decisions.py. + +Two rules hold everywhere in this module: + +*Nothing is forwarded that the policy has not seen.* Every path out of +``_one``/``_forward`` either returns before the backend connection is +opened, or forwards exactly the request ``decide()`` approved -- the start +line is rebuilt from the parsed method and ``Request.raw_target()``, never +copied from the client's own start line, so a target the policy read +differently than the daemon would cannot survive the trip. + +*A failed label check answers 403, never 404.* The client is inside the +sandbox and the daemon is root-equivalent on the host; answering 404 for +"exists but belongs to another project" and 403 for "yours" would turn the +gateway into an existence oracle for every other project's containers. The +same reasoning makes every indeterminate backend answer (unreachable, +unparsable, an error status) deny rather than pass: the check fails +closed. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import functools +import json +import logging +import os +import re +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any + +from .decisions import Allow, Request, decide +from .http import Head, HttpError, error_response, parse_request_line, pipe, pump, read_body, read_head +from .labels import LABEL_KEY, has_label +from .policy import Deny, PolicyContext, named_networks, named_volumes +from .routes import Family, Route, name_span +from .routes import route as route_of + +log = logging.getLogger("container-gateway") + +# Actions whose request body is JSON the policy must see. Everything else +# (build contexts, archives, image loads) streams through after URL checks. +_BUFFERED_ACTIONS = frozenset( + { + "create", + "exec", + "exec_start", + "exec_resize", + "update", + "rename", + "connect", + "disconnect", + "pull", + "commit", + "wait", + } +) + +# A resource identifier read back out of a daemon inspect payload, before it +# is spliced into the forwarded request target. Digests (``sha256:...``) and +# fully-qualified image references are legal here; a path separator, a space +# or a percent sign is not -- those would change how the daemon parses the +# request line the gateway just rebuilt. +_IDENT_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:@-]{0,254}") + +# A volume / network name taken out of a create body, before it is spliced +# into an inspect URL. Narrower than `_IDENT_RE` (this one is entirely +# client-controlled, and `decide()` does not validate body-borne resource +# names the way it validates the request path). +_RESOURCE_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,254}") + +# Hop-by-hop headers belong to the client <-> gateway connection and must +# not be copied onto the gateway <-> daemon one (RFC 7230 6.1). `Connection` +# is dropped too unless it names the upgrade the relay is about to hijack. +_HOP_BY_HOP = ("Keep-Alive", "TE", "Trailer", "Proxy-Authorization", "Proxy-Connection") + +# How many interim (1xx) heads the relay will consume before deciding the +# backend is never going to produce a final one. A real daemon sends at +# most one 100 Continue; the cap keeps a misbehaving or hostile one from +# holding a client connection open indefinitely. +_MAX_INTERIM_HEADS = 8 + +Connector = Callable[[], Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]]] +Handler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], Awaitable[None]] + + +def unix_connector(path: Path) -> Connector: + """The production connector: a fresh unix-socket connection per backend call.""" + return functools.partial(asyncio.open_unix_connection, str(path)) + + +class Relay: + def __init__( + self, + connect: Connector, + ctx: PolicyContext, + *, + backend_label: str = "", + json_limit: int = 8 * 1024 * 1024, + backend_timeout: float | None = None, + ) -> None: + self.connect = connect + self.ctx = ctx + self.backend_label = backend_label + self.json_limit = json_limit + # Bounds every short request/response round trip the relay makes on + # its own behalf (label-check inspects, volume/network pre-creates) + # and the connect + first response head of a forwarded request. + # Streaming bodies and hijacked pipes are deliberately NOT bounded + # by this: a long ``logs -f`` or an attached shell is legitimate. + self.backend_timeout = backend_timeout + + # ------------------------------------------------------------ backend + async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + if self.backend_timeout is None: + return await self.connect() + return await asyncio.wait_for(self.connect(), self.backend_timeout) + + async def _read_first_head(self, backend_reader: asyncio.StreamReader) -> Head | None: + """Read the backend's first response head, bounded by ``backend_timeout``. + + Only the *first* head is bounded here -- a chatty backend sending + several interim (1xx) heads before its final one is read by the + unbounded loop in ``_exchange`` below, on the theory that a backend + that answered at all within the timeout is unlikely to then stall + indefinitely between interim heads. + """ + if self.backend_timeout is None: + return await read_head(backend_reader) + return await asyncio.wait_for(read_head(backend_reader), self.backend_timeout) + + async def _request_json( + self, method: str, target: str, payload: Any | None = None + ) -> tuple[int | None, Any]: + """One short JSON call the relay makes on its own behalf (never the client's). + + Returns ``(status, parsed body)``. ``status`` is ``None`` when the + call could not be completed at all -- the connector refused, the + response was unframable, the connection died mid-body, or the + round trip timed out -- and the body is ``None`` when it was absent + or not JSON. Every caller treats an unknown status as a failure, + which is what makes a timeout here fail closed the same way a + connection refusal does. + """ + try: + reader, writer = await self._connect() + except OSError as exc: + log.debug("backend call %s %s could not connect: %s", method, target, exc) + return None, None + status: int | None = None + + async def _round_trip() -> tuple[int | None, Any]: + nonlocal status + head = Head(f"{method} {target} HTTP/1.1", [("Host", "docker"), ("Connection", "close")]) + body = b"" + if payload is not None: + body = json.dumps(payload).encode() + head.set("Content-Type", "application/json") + head.set("Content-Length", str(len(body))) + writer.write(head.encode() + body) + await writer.drain() + resp = await read_head(reader) + if resp is None: + return None, None + status = int(resp.start_line.split(" ")[1]) + framed = resp.content_length is not None or resp.chunked + raw = await read_body(reader, resp, self.json_limit) if framed else await reader.read() + return status, (json.loads(raw) if raw else None) + + try: + if self.backend_timeout is None: + return await _round_trip() + return await asyncio.wait_for(_round_trip(), self.backend_timeout) + except (HttpError, OSError, ValueError, asyncio.IncompleteReadError) as exc: + # TimeoutError is a subclass of OSError (since Python 3.11 it is + # also what ``asyncio.wait_for`` raises), so it is caught here + # too and folded into the same "call did not complete" outcome. + log.debug("backend call %s %s failed: %s", method, target, exc) + return status, None + finally: + writer.close() + + async def _get_json(self, target: str) -> Any | None: + status, payload = await self._request_json("GET", target) + return payload if status is not None and 200 <= status < 300 else None + + async def resource_id_if_labelled(self, route: Route, name: str) -> str | None: + """The resource's ID when it carries this project's label, else ``None``. + + ``None`` covers "no such resource" and "someone else's resource" + alike -- the caller answers 403 either way. + """ + if route.family is Family.EXEC: + info = await self._get_json(_inspect_target(Family.EXEC, name, route.libpod)) + cid = info.get("ContainerID") if isinstance(info, dict) else None + if not isinstance(cid, str) or not _IDENT_RE.fullmatch(cid): + return None + owner = await self._get_json(_inspect_target(Family.CONTAINERS, cid, route.libpod)) + return name if has_label(_labels_of(owner), self.ctx.slug) else None + info = await self._get_json(_inspect_target(route.family, name, route.libpod)) + if not isinstance(info, dict) or not has_label(_labels_of(info), self.ctx.slug): + return None + ident = str(info.get("Id") or info.get("ID") or info.get("Name") or name) + return ident if _IDENT_RE.fullmatch(ident) else name + + async def _resource_denial(self, allow: Allow) -> bytes | None: + """403 payload for a named volume / network this project does not own. + + Only container and pod create bodies reference resources by name + without naming them in the path, so only those need this second + pass; ``label_check`` covers every act-by-name route. + """ + route = allow.route + if (route.family, route.action) not in ((Family.CONTAINERS, "create"), (Family.PODS, "create")): + return None + body = allow.request.body + if not isinstance(body, dict): + return None + # Networks first: they are never auto-created, so checking them + # before the volume pass means a request that is going to be refused + # leaves no freshly-created volume behind. + for name in named_networks(body, route.libpod): + denial = await self._network_denial(name, route.libpod) + if denial is not None: + return denial + for name in named_volumes(body, route.libpod): + denial = await self._volume_denial(name, route.libpod) + if denial is not None: + return denial + return None + + def _owned(self, status: int | None, info: Any) -> bool: + """A 2xx inspect payload carrying this project's label. Anything else is not ours.""" + return status is not None and 200 <= status < 300 and has_label(_labels_of(info), self.ctx.slug) + + async def _volume_denial(self, name: str, libpod: bool) -> bytes | None: + """An unknown volume is created labelled; a foreign one is refused.""" + if not _RESOURCE_NAME_RE.fullmatch(name): + return _label_denial("volume", name) + status, info = await self._request_json("GET", _inspect_target(Family.VOLUMES, name, libpod)) + if self._owned(status, info): + return None + if status != 404: + return _label_denial("volume", name) + return await self._precreate_volume(name, libpod) + + async def _precreate_volume(self, name: str, libpod: bool) -> bytes | None: + """Create the volume labelled, so the daemon cannot create it unlabelled. + + Fails closed: anything but a successful create refuses the request + that referenced the volume. A 409 means somebody won the race + between the inspect above and this create -- possibly another + project -- so the label test is re-run against whatever now exists + rather than assuming the winner was us. + """ + target = "/libpod/volumes/create" if libpod else "/volumes/create" + label = {LABEL_KEY: self.ctx.slug} + payload = {"name": name, "labels": label} if libpod else {"Name": name, "Labels": label} + status, created = await self._request_json("POST", target, payload) + if status is not None and 200 <= status < 300: + # A 201 is not proof the volume is ours: docker-compat's create + # is idempotent and answers 201 with the *existing* volume, so + # a name another project created between the inspect above and + # this call comes back looking like a success. Test the labels + # the daemon actually returned. + if self._owned(status, created): + return None + if _labels_of(created) is None: + return await self._recheck_volume(name, libpod) + return _label_denial("volume", name) + if status == 409: + log.info("volume %s was created concurrently; re-checking its label", name) + return await self._recheck_volume(name, libpod) + log.warning("pre-creating volume %s returned %s; refusing the request", name, status) + return _denial(f"label-check: volume {name} could not be prepared") + + async def _recheck_volume(self, name: str, libpod: bool) -> bytes | None: + """Re-inspect a volume whose create response did not settle ownership.""" + again = await self._request_json("GET", _inspect_target(Family.VOLUMES, name, libpod)) + return None if self._owned(*again) else _label_denial("volume", name) + + async def _network_denial(self, name: str, libpod: bool) -> bytes | None: + """Networks are never auto-created, so an unknown one is refused too.""" + if not _RESOURCE_NAME_RE.fullmatch(name): + return _label_denial("network", name) + status, info = await self._request_json("GET", _inspect_target(Family.NETWORKS, name, libpod)) + if status == 404: + return _denial(f"label-check: network {name} does not exist") + return None if self._owned(status, info) else _label_denial("network", name) + + # ------------------------------------------------------------- client + async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """Serve one client connection: keep-alive requests until close, upgrade or error.""" + try: + while True: + head = await read_head(reader) + if head is None or not await self._one(head, reader, writer): + return + except HttpError as exc: + log.info("framing error from the client: %s", exc) + with contextlib.suppress(OSError, RuntimeError): + writer.write(error_response(400, f"container-gateway: {exc}")) + except (OSError, asyncio.IncompleteReadError, ConnectionError) as exc: + log.debug("client connection dropped: %s", exc) + finally: + writer.close() + + async def _one(self, head: Head, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> bool: + """Serve one request. Returns False when the connection must not be reused.""" + method, path, query = parse_request_line(head.start_line) + req = Request(method, path, query, {k.lower(): v for k, v in head.headers}, None) + buffered = self._should_buffer(req) + raw_body = b"" + if buffered: + await _continue_if_expected(head, writer) + raw_body = await read_body(reader, head, self.json_limit) + if buffered and raw_body and _content_type(head) == "application/json": + try: + req.body = json.loads(raw_body) + except json.JSONDecodeError as exc: + raise HttpError(f"invalid JSON body: {exc}") from exc + + verdict = decide(req, self.ctx) + if isinstance(verdict, Deny): + log.info("deny %s %s: %s", method, path, verdict.reason) + return await _refuse(writer, error_response(verdict.status, verdict.message)) + return await self._forward(verdict, head, raw_body, buffered, reader, writer) + + def _should_buffer(self, req: Request) -> bool: + """Whether the policy needs the whole body in hand before anything is forwarded. + + The action decides, never the Content-Type: a client that labels a + build context or a tar archive ``application/json`` would otherwise + have it buffered (and re-encoded) on its say-so. + """ + return route_of(req.method, req.path).action in _BUFFERED_ACTIONS + + async def _forward( + self, + allow: Allow, + head: Head, + raw_body: bytes, + buffered: bool, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> bool: + req = allow.request + if allow.label_check is not None: + name = allow.label_check + if not name: + # `/containers/start` and friends: a verb with no name in + # front of it. Nothing to inspect, so nothing to authorise. + log.info("deny %s %s: empty resource name", req.method, req.path) + return await _refuse(writer, _denial("label-check: empty resource name")) + ident = await self.resource_id_if_labelled(allow.route, name) + if ident is None: + log.info("deny %s %s: label-check on %s", req.method, req.path, name) + return await _refuse(writer, _denial(f"label-check: {name} does not belong to this project")) + if allow.name_in_query is not None: + # The name came out of the query (compat + # `POST /commit?container=`), so the resolved id goes + # back there; the path carries no name to splice. + req.query[allow.name_in_query] = [ident] + else: + rewritten = _rewrite_name(req.method, req.path, name, ident) + if rewritten is None: + log.info( + "deny %s %s: target cannot be rewritten to the resolved id", req.method, req.path + ) + return await _refuse(writer, _denial("label-check: cannot rewrite request target")) + req.path = rewritten + denial = await self._resource_denial(allow) + if denial is not None: + log.info("deny %s %s: label-check on a named resource", req.method, req.path) + return await _refuse(writer, denial) + + try: + backend_reader, backend_writer = await self._connect() + except TimeoutError: + return await _refuse(writer, error_response(502, "container-gateway: backend timed out")) + except OSError as exc: + where = f" {self.backend_label}" if self.backend_label else "" + return await _refuse( + writer, error_response(502, f"container-gateway: backend{where} is unreachable ({exc})") + ) + try: + return await self._exchange( + req, head, raw_body, buffered, reader, writer, backend_reader, backend_writer + ) + finally: + backend_writer.close() + + async def _exchange( + self, + req: Request, + head: Head, + raw_body: bytes, + buffered: bool, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + backend_reader: asyncio.StreamReader, + backend_writer: asyncio.StreamWriter, + ) -> bool: + """Write the approved request to the backend and relay the response back.""" + out_head = Head(f"{req.method} {req.raw_target()} HTTP/1.1", list(head.headers)) + out_head.set("Host", "docker") + _strip_hop_by_hop(out_head) + # The relay answers the client's 100-continue handshake itself (see + # `_continue_if_expected`), so the daemon must not be asked for a + # second interim head that nobody is waiting for. + out_head.remove("Expect") + if buffered: + body = json.dumps(req.body).encode() if req.body is not None else raw_body + out_head.remove("Transfer-Encoding") + out_head.set("Content-Length", str(len(body))) + backend_writer.write(out_head.encode() + body) + await backend_writer.drain() + else: + backend_writer.write(out_head.encode()) + await backend_writer.drain() + if head.chunked or head.content_length: + await _continue_if_expected(head, writer) + # On a truncated client body this raises HttpError; the + # `finally` in _forward closes the half-written backend + # connection rather than leaving the daemon mid-request. + await pump(reader, backend_writer, head) + + try: + resp = await self._read_first_head(backend_reader) + except TimeoutError: + return await _refuse(writer, error_response(502, "container-gateway: backend timed out")) + interim = 0 + while resp is not None and _is_interim(resp): + # 100 Continue and friends are bookkeeping between the gateway + # and the daemon; the client is sent the final head only. + interim += 1 + if interim > _MAX_INTERIM_HEADS: + return await _refuse( + writer, + error_response(502, "container-gateway: backend sent too many interim responses"), + ) + resp = await read_head(backend_reader) + if resp is None: + return await _refuse( + writer, error_response(502, "container-gateway: backend closed without a response") + ) + writer.write(resp.encode()) + await writer.drain() + + status = resp.start_line.split(" ")[1] + if status == "101" or resp.upgrade: + try: + await pipe(reader, backend_writer, backend_reader, writer) + except HttpError as exc: + log.debug("hijacked connection ended: %s", exc) + return False + # Every other 1xx has been consumed by the interim loop above. + if status not in ("204", "304") and req.method != "HEAD": + # Neither Content-Length nor chunked means the body is delimited + # by EOF, so nothing can follow it on this connection. + eof_framed = resp.content_length is None and not resp.chunked + try: + await pump(backend_reader, writer, resp) + except HttpError as exc: + log.info("backend response body truncated: %s", exc) + return False + if eof_framed: + return False + return (resp.get("connection") or "").lower() != "close" + + +def _inspect_target(family: Family, name: str, libpod: bool) -> str: + """Where the backend exposes ``name``'s inspect payload. + + libpod spells every family the same way; the compat API drops the + ``/json`` verb for volumes and networks (``/volumes//json`` is a + 404 there, which would read as "not this project's" and deny a request + that should have been allowed). + """ + if libpod: + return f"/libpod/{family.value}/{name}/json" + if family in (Family.VOLUMES, Family.NETWORKS): + return f"/{family.value}/{name}" + return f"/{family.value}/{name}/json" + + +def _labels_of(inspect: Any) -> dict[str, str] | None: + """The label map of an inspect payload, in each of the three places it lives.""" + if not isinstance(inspect, dict): + return None + config = inspect.get("Config") + labels = (config or {}).get("Labels") if isinstance(config, dict) else None + labels = labels or inspect.get("Labels") or inspect.get("labels") + return labels if isinstance(labels, dict) else None + + +def _rewrite_name(method: str, path: str, name: str, ident: str) -> str | None: + """Point the forwarded path at the resolved ID instead of the client's name. + + Closes the window between the label check and the forwarded request in + which the client could rename the resource or recreate it under another + project's ownership. The ID is spliced over exactly the segments + ``routes.name_span`` reports, never over the first textual occurrence of + the name: a container legally named ``containers``, ``libpod`` or + ``v1.45`` appears earlier in its own request target than the position + the daemon acts on. Returns ``None`` -- and the caller refuses the + request -- when the span is missing or does not spell the name the + policy checked: the route and the policy disagreeing about which + segments are the name is exactly the situation in which forwarding the + client's own name would act on something nobody authorised. + """ + if ident == name: + return path + span = name_span(method, path) + if span is None: + return None + start, end = span + segments = [s for s in path.split("/") if s] + if "/".join(segments[start:end]) != name: + return None + segments[start:end] = [ident] + return "/" + "/".join(segments) + + +async def _continue_if_expected(head: Head, writer: asyncio.StreamWriter) -> None: + """Answer a client's ``Expect: 100-continue`` before its body is read. + + The relay -- not the daemon -- is this client's HTTP peer, and nothing + downstream reads the body until the relay has read it, so the handshake + has to be completed here or the client waits for an interim head that + never arrives (curl sends ``Expect`` by default for bodies over 1 KiB, + which is every real build context or archive upload). Sending 100 and + then a final status -- including a 403 for a request the policy refuses + -- is exactly what RFC 7231 5.1.1 allows. + """ + if "100-continue" in (head.get("expect") or "").lower(): + writer.write(b"HTTP/1.1 100 Continue\r\n\r\n") + await writer.drain() + + +def _strip_hop_by_hop(head: Head) -> None: + """Drop the headers that belong to the client's own connection, not the daemon's.""" + connection = (head.get("connection") or "").lower() + for name in _HOP_BY_HOP: + head.remove(name) + if "upgrade" not in connection: + head.remove("Connection") + + +def _is_interim(head: Head) -> bool: + """A 1xx response head the relay consumes itself -- everything but the 101 hijack.""" + status = head.start_line.split(" ")[1] + return status.startswith("1") and status != "101" + + +def _content_type(head: Head) -> str: + return (head.get("content-type") or "").split(";")[0].strip().lower() + + +def _denial(reason: str) -> bytes: + return error_response(403, Deny(reason).message) + + +def _label_denial(kind: str, name: str) -> bytes: + return _denial(f"label-check: {kind} {name} does not belong to this project") + + +async def _refuse(writer: asyncio.StreamWriter, payload: bytes) -> bool: + """Answer the client without ever having opened a backend connection.""" + writer.write(payload) + await writer.drain() + return False + + +async def serve_unix(path: Path, handler: Handler) -> asyncio.AbstractServer: + """Bind ``path`` with mode 0600 and serve ``handler`` on it. + + The caller (the daemon) guarantees ``path.parent`` already exists as a + checked, owned, non-symlinked directory -- this function does not + create it, so it never has to decide what to do about a parent that + does not yet exist or is something other than a plain directory. It + also does not unlink a pre-existing ``path`` itself: the daemon does + that (via ``check_socket_type`` first, then the unlink) only once the + pid-file lock is held, so nothing here silently removes a file the + caller had not already decided was safe to remove. + """ + old_umask = os.umask(0o177) + try: + server = await asyncio.start_unix_server(handler, path=str(path)) + finally: + os.umask(old_umask) + os.chmod(path, 0o600) + return server diff --git a/tools/container-gateway/src/container_gateway/routes.py b/tools/container-gateway/src/container_gateway/routes.py new file mode 100644 index 000000000..907707598 --- /dev/null +++ b/tools/container-gateway/src/container_gateway/routes.py @@ -0,0 +1,285 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Map Docker-compatible and libpod API paths to (family, action, name).""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum + +_VERSION_RE = re.compile(r"^/v\d+(?:\.\d+){1,2}(?=/|$)") + +# Whole families we never forward. Anything under these prefixes is DENIED. +_DENIED_PREFIXES = ( + "swarm", + "services", + "tasks", + "nodes", + "plugins", + "secrets", + "configs", + "distribution", + "session", +) + +# Single-segment system endpoints. +_SYSTEM = {"_ping": "ping", "version": "version", "info": "info", "events": "events"} + + +class Family(str, Enum): # noqa: UP042 + CONTAINERS = "containers" + PODS = "pods" + EXEC = "exec" + IMAGES = "images" + BUILD = "build" + VOLUMES = "volumes" + NETWORKS = "networks" + SYSTEM = "system" + DENIED = "denied" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class Route: + family: Family + action: str + name: str | None + libpod: bool + version: str | None + + +def strip_version(path: str) -> tuple[str, str | None, bool]: + version: str | None = None + m = _VERSION_RE.match(path) + if m: + version = m.group(0)[1:] + path = path[m.end() :] or "/" + libpod = False + if path.startswith("/libpod/"): + libpod = True + path = path[len("/libpod") :] + return path, version, libpod + + +# Trailing segments that are verbs. The name is everything between the family +# segment and the verb, which lets image names carry registries and tags. +_VERBS = { + "json", + "start", + "stop", + "kill", + "restart", + "pause", + "unpause", + "wait", + "logs", + "top", + "stats", + "rename", + "update", + "archive", + "export", + "commit", + "attach", + "exec", + "resize", + "changes", + "tag", + "history", + "push", + "get", + "connect", + "disconnect", + "healthcheck", + "mount", + "unmount", + "init", + "checkpoint", + "restore", + "generate", + "play", + "exists", +} + + +def _split(path: str) -> tuple[str, list[str]]: + parts = [p for p in path.split("/") if p] + return (parts[0] if parts else ""), parts[1:] + + +def route(method: str, path: str) -> Route: + clean, version, libpod = strip_version(path) + head, rest = _split(clean) + method = method.upper() + + if head in _SYSTEM and not rest: + return Route(Family.SYSTEM, _SYSTEM[head], None, libpod, version) + if head == "system" and rest and rest[0] in ("df", "events", "ping", "info", "version"): + return Route(Family.SYSTEM, rest[0], None, libpod, version) + if ( + head == "auth" + or head.startswith(_DENIED_PREFIXES) + or (head == "system" and rest and rest[0] == "dial-stdio") + ): + return Route(Family.DENIED, head if head != "system" else rest[0], None, libpod, version) + if head == "build": + return Route(Family.BUILD, "build", None, libpod, version) + if head == "commit" and not rest: + # The compat spelling of commit: `POST /commit?container=`, + # with the container in the query rather than the path. It is the + # same act-by-name call as `/containers//commit`; `decide()` + # reads the name out of the query and label-checks it there. + return Route(Family.CONTAINERS, "commit", None, libpod, version) + if head == "exec" and rest: + exec_verb = rest[-1] if len(rest) > 1 else "" + action = { + "start": "exec_start", + "json": "exec_inspect", + "resize": "exec_resize", + # podman ends every `podman exec` with `POST + # /libpod/exec//remove`; without this the instance is left + # behind and the client prints a 403 on an otherwise clean run. + "remove": "exec_remove", + }.get(exec_verb, "unknown") + return Route(Family.EXEC, action, rest[0], libpod, version) + + family = { + "containers": Family.CONTAINERS, + "pods": Family.PODS, + "images": Family.IMAGES, + "volumes": Family.VOLUMES, + "networks": Family.NETWORKS, + }.get(head) + if family is None: + return Route(Family.UNKNOWN, head or "root", None, libpod, version) + + if not rest: + # GET /volumes, GET /networks are lists; nothing else is a bare family call. + return Route(family, "list" if method == "GET" else "unknown", None, libpod, version) + if len(rest) == 1 and rest[0] in ("json", "create", "prune", "load", "get", "search"): + action = { + "json": "list", + "create": "pull" if family is Family.IMAGES else "create", + "get": "save", + }.get(rest[0], rest[0]) + return Route(family, action, None, libpod, version) + + verb: str | None = rest[-1] if rest[-1] in _VERBS else None + name = "/".join(rest[:-1] if verb else rest) + if verb is None: + action = "remove" if method == "DELETE" else "inspect" if method == "GET" else "unknown" + elif verb == "json": + action = "inspect" + elif verb == "get": + action = "save" + elif verb == "push": + return Route(Family.DENIED, "push", name, libpod, version) + else: + action = verb + return Route(family, action, name, libpod, version) + + +def name_span(method: str, path: str) -> tuple[int, int] | None: + """The segment range ``route()``'s ``name`` occupies in ``path``. + + Indices are into ``[s for s in path.split("/") if s]`` -- the same + filtered segment list ``route()`` itself classifies -- shifted past the + version and ``/libpod`` prefixes ``strip_version`` removes and past the + family segment. ``None`` when the route carries no name. + + The relay splices a resolved ID over exactly this range. Replacing the + first textual ``//`` instead would rewrite the wrong segment for a + resource legally named ``containers``, ``libpod`` or ``v1.45``: + ``/v1.45/containers/containers/start`` would become + ``/v1.45/aaa111/containers/start``, leaving the client's own name at the + position the daemon acts on. + """ + named = route(method, path) + if named.name is None: + return None + clean, version, libpod = strip_version(path) + head, rest = _split(clean) + if not rest: + return None + offset = (1 if version else 0) + (1 if libpod else 0) + 1 + if head == "exec": + # `route()` takes only rest[0] as the exec id, whatever follows it. + return offset, offset + 1 + end = len(rest) - 1 if rest[-1] in _VERBS else len(rest) + return (offset, offset + end) if end > 0 else None + + +ACT_BY_NAME: frozenset[tuple[Family, str]] = frozenset( + { + (Family.CONTAINERS, a) + for a in ( + "inspect", + "start", + "stop", + "kill", + "restart", + "pause", + "unpause", + "wait", + "remove", + "logs", + "top", + "stats", + "rename", + "update", + "archive", + "export", + "commit", + "attach", + "exec", + "resize", + "changes", + "healthcheck", + "mount", + "unmount", + "init", + "exists", + ) + } + | { + (Family.PODS, a) + for a in ( + "inspect", + "start", + "stop", + "kill", + "restart", + "pause", + "unpause", + "remove", + "top", + "stats", + "exists", + ) + } + | {(Family.EXEC, a) for a in ("exec_start", "exec_inspect", "exec_resize", "exec_remove")} + | {(Family.IMAGES, a) for a in ("remove", "tag")} + | {(Family.VOLUMES, a) for a in ("inspect", "remove", "exists")} + | {(Family.NETWORKS, a) for a in ("inspect", "remove", "connect", "disconnect", "exists")} +) + +LIST_LIKE: frozenset[tuple[Family, str]] = frozenset( + {(f, "list") for f in (Family.CONTAINERS, Family.PODS, Family.VOLUMES, Family.NETWORKS)} + | {(f, "prune") for f in (Family.CONTAINERS, Family.PODS, Family.VOLUMES, Family.NETWORKS, Family.IMAGES)} + | {(Family.SYSTEM, "events"), (Family.SYSTEM, "df")} +) diff --git a/tools/container-gateway/tests/__init__.py b/tools/container-gateway/tests/__init__.py new file mode 100644 index 000000000..13a83393a --- /dev/null +++ b/tools/container-gateway/tests/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tools/container-gateway/tests/fakebackend.py b/tools/container-gateway/tests/fakebackend.py new file mode 100644 index 000000000..1ec0c4bd6 --- /dev/null +++ b/tools/container-gateway/tests/fakebackend.py @@ -0,0 +1,244 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""A tiny Docker-shaped daemon for the relay tests, over in-process socket pairs. + +The fake never listens on a path: the sandbox this suite runs under denies +``bind()`` on a unix-domain socket anywhere (see ``test_http._socket_pair``), +so ``connect()`` builds a ``socket.socketpair()``, drives ``_handle`` on the +server end and hands the client end back. That is exactly the shape of the +connector ``Relay`` takes, so the relay talks to this fake through the same +code path it uses for a real daemon socket in production. +""" + +from __future__ import annotations + +import asyncio +import json +import socket +from dataclasses import dataclass, field +from typing import Any + +from container_gateway.http import Head, error_response, parse_request_line, read_body, read_head +from container_gateway.labels import LABEL_KEY + +_BODY_LIMIT = 10_000_000 + + +@dataclass +class FakeBackend: + """Daemon state plus a log of everything the relay actually forwarded.""" + + containers: dict[str, dict[str, Any]] = field(default_factory=dict) # id -> inspect payload + images: dict[str, dict[str, Any]] = field(default_factory=dict) # name -> inspect payload + volumes: dict[str, dict[str, Any]] = field(default_factory=dict) # name -> inspect payload + networks: dict[str, dict[str, Any]] = field(default_factory=dict) # name -> inspect payload + execs: dict[str, str] = field(default_factory=dict) # exec id -> container id + seen: list[tuple[str, str, Any]] = field(default_factory=list) # method, raw target, JSON body + heads: list[Head] = field(default_factory=list) # every request head as received + bodies: list[bytes] = field(default_factory=list) # every request body, unparsed + transports: list[asyncio.BaseTransport] = field(default_factory=list) + # Knobs for the failure paths the volume pre-create has to survive. + volume_create_status: int | None = None # force this status instead of creating + volume_create_conflict: str | None = None # 409, and the volume now belongs to this slug + volume_create_idempotent: str | None = None # 201, but the volume belongs to this slug + volume_create_bare: bool = False # 201 whose body carries no label map + interim_heads: int = 0 # how many 100 Continue heads to emit before the real one + + # ------------------------------------------------------------- fixtures + def add_container(self, cid: str, name: str, labels: dict[str, str]) -> None: + self.containers[cid] = {"Id": cid, "Name": f"/{name}", "Config": {"Labels": dict(labels)}} + + def add_image(self, name: str, image_id: str, labels: dict[str, str]) -> None: + self.images[name] = {"Id": image_id, "RepoTags": [name], "Labels": dict(labels)} + + def add_volume(self, name: str, labels: dict[str, str]) -> None: + self.volumes[name] = {"Name": name, "Labels": dict(labels)} + + def add_network(self, name: str, labels: dict[str, str]) -> None: + self.networks[name] = {"Name": name, "Id": f"net{name}", "Labels": dict(labels)} + + def _lookup(self, ref: str) -> dict[str, Any] | None: + if ref in self.containers: + return self.containers[ref] + return next((c for c in self.containers.values() if c["Name"] == f"/{ref}"), None) + + # ------------------------------------------------------------ transport + async def connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """One fresh connection to the fake: the connector ``Relay`` is built with.""" + loop = asyncio.get_running_loop() + server_sock, client_sock = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader, self._handle) + transport, _ = await loop.connect_accepted_socket(lambda: protocol, server_sock) + self.transports.append(transport) + return await asyncio.open_connection(sock=client_sock) + + async def start(self) -> None: + """No-op: nothing is bound, connections are made on demand.""" + + async def stop(self) -> None: + for transport in self.transports: + transport.close() + self.transports.clear() + + # -------------------------------------------------------------- serving + async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while (head := await read_head(reader)) is not None: + method, path, query = parse_request_line(head.start_line) + body = await read_body(reader, head, _BODY_LIMIT) + is_json = (head.get("content-type") or "").startswith("application/json") + parsed = _try_json(body) if body and is_json else None + self.seen.append((method, head.start_line.split(" ")[1], parsed)) + self.heads.append(head) + self.bodies.append(body) + await self._respond(method, path, query, parsed, reader, writer) + if head.upgrade: + return + finally: + writer.close() + + async def _respond( + self, + method: str, + path: str, + query: dict[str, list[str]], + body: Any, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + parts = [p for p in path.split("/") if p] + if parts and parts[0].startswith("v1."): + # Only a *leading* segment is the API version: a resource may + # legally be named "v1.45", and the daemon would not mistake it + # for one either. + parts = parts[1:] + if parts == ["_ping"]: + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK") + elif parts[:1] == ["containers"] and parts[-1] == "json" and len(parts) == 3: + container = self._lookup(parts[1]) + writer.write(_json(200, container) if container else error_response(404, "no such container")) + elif parts == ["containers", "json"]: + listing = [{"Id": i, "Labels": c["Config"]["Labels"]} for i, c in self.containers.items()] + writer.write(_json(200, listing)) + elif parts[:1] == ["exec"] and parts[-1] == "json": + cid = self.execs.get(parts[1]) + writer.write(_json(200, {"ID": parts[1], "ContainerID": cid}) if cid else _no_exec()) + elif parts[:1] == ["exec"] and parts[-1] == "start": + writer.write(_json(200, {"started": parts[1]})) + elif parts[:1] == ["exec"] and parts[-1] == "remove": + writer.write(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + elif parts[:1] == ["containers"] and parts[-1] == "logs": + writer.write( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + b"Content-Type: application/vnd.docker.raw-stream\r\n\r\n" + ) + for piece in (b"line1\n", b"line2\n"): + writer.write(f"{len(piece):x}\r\n".encode() + piece + b"\r\n") + await writer.drain() + writer.write(b"0\r\n\r\n") + elif parts[:1] == ["containers"] and parts[-1] == "attach": + writer.write(b"HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n") + await writer.drain() + data = await reader.read(64) + writer.write(b"echo:" + data) + elif parts[:1] == ["containers"] and parts[-1] == "archive": + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + elif parts == ["containers", "create"]: + for _ in range(self.interim_heads): + writer.write(b"HTTP/1.1 100 Continue\r\n\r\n") + await writer.drain() + writer.write(_json(201, {"Id": "newid", "Warnings": []})) + elif parts == ["volumes", "create"]: + name = str((body or {}).get("Name") or (body or {}).get("name") or "") + labels = (body or {}).get("Labels") or (body or {}).get("labels") or {} + if self.volume_create_conflict is not None: + self.add_volume(name, {LABEL_KEY: self.volume_create_conflict}) + writer.write(error_response(409, "volume already exists")) + elif self.volume_create_status is not None: + writer.write(error_response(self.volume_create_status, "volume create failed")) + else: + # docker-compat's create is idempotent: an existing volume + # comes back as a 201 carrying its own labels. + owner = self.volume_create_idempotent + self.add_volume(name, {LABEL_KEY: owner} if owner else labels) + payload = dict(self.volumes[name]) + if self.volume_create_bare: + payload.pop("Labels") + writer.write(_json(201, payload)) + elif parts[:1] == ["volumes"] and method == "GET" and len(parts) == 2: + volume = self.volumes.get(parts[1]) + writer.write(_json(200, volume) if volume else error_response(404, "no such volume")) + elif parts[:1] == ["images"] and parts[-1] == "json": + image = self.images.get("/".join(parts[1:-1])) + writer.write(_json(200, image) if image else error_response(404, "no such image")) + elif parts[:1] == ["images"] and method == "DELETE": + writer.write(_json(200, [{"Deleted": "/".join(parts[1:])}])) + elif parts[:1] == ["networks"] and method == "GET" and len(parts) == 2: + network = self.networks.get(parts[1]) + writer.write(_json(200, network) if network else error_response(404, "no such network")) + elif parts[:1] == ["containers"] and method == "POST": + writer.write(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + elif parts == ["build"]: + writer.write(_json(200, {"stream": "ok", "labels": query.get("labels")})) + else: + writer.write(error_response(404, f"fake: {method} {path}")) + await writer.drain() + + +async def socket_pair() -> tuple[ + tuple[asyncio.StreamReader, asyncio.StreamWriter], + tuple[asyncio.StreamReader, asyncio.StreamWriter], +]: + """Two already-connected endpoints, for driving ``Relay.handle`` directly. + + Same reason as ``connect()`` above: a pre-connected ``socket.socketpair()`` + handed to ``loop.connect_accepted_socket()`` never calls ``bind()``, which + the sandbox refuses. + """ + loop = asyncio.get_running_loop() + sock_a, sock_b = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + + async def wrap(sock: socket.socket) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + transport, _ = await loop.connect_accepted_socket(lambda: protocol, sock) + return reader, asyncio.StreamWriter(transport, protocol, reader, loop) + + return await wrap(sock_a), await wrap(sock_b) + + +def _try_json(body: bytes) -> Any: + try: + return json.loads(body) + except json.JSONDecodeError: + return None + + +def _no_exec() -> bytes: + return error_response(404, "no such exec") + + +def _json(status: int, payload: Any) -> bytes: + body = json.dumps(payload).encode() + reason = {200: "OK", 201: "Created"}[status] + head = Head( + f"HTTP/1.1 {status} {reason}", + [("Content-Type", "application/json"), ("Content-Length", str(len(body)))], + ) + return head.encode() + body diff --git a/tools/container-gateway/tests/test_backends.py b/tools/container-gateway/tests/test_backends.py new file mode 100644 index 000000000..64936963f --- /dev/null +++ b/tools/container-gateway/tests/test_backends.py @@ -0,0 +1,94 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Backend discovery is table-driven and testable without a daemon.""" + +from __future__ import annotations + +from pathlib import Path + +from container_gateway.backends import Backend, discover, egress_proxy_env, host_alias + +ALL = frozenset({"podman", "docker"}) + + +def test_podman_machine_on_darwin() -> None: + sock = Path("/var/folders/x/T/podman/podman-machine-default-api.sock") + + def run(argv: list[str]) -> str | None: + if argv[:3] == ["podman", "machine", "inspect"]: + return str(sock) + return None + + found = discover("Darwin", {}, run, lambda p: p == sock, ALL) + assert found == [Backend("podman", sock, "host.containers.internal")] + + +def test_machine_socket_missing_means_no_backend() -> None: + run = lambda argv: "/nope.sock" if argv[:2] == ["podman", "machine"] else None # noqa: E731 + assert discover("Darwin", {}, run, lambda p: False, ALL) == [] + + +def test_rootless_podman_on_linux() -> None: + sock = Path("/run/user/1000/podman/podman.sock") + found = discover("Linux", {"XDG_RUNTIME_DIR": "/run/user/1000"}, lambda a: None, lambda p: p == sock, ALL) + assert found == [Backend("podman", sock, "10.88.0.1")] + + +def test_docker_desktop_context_then_fallback() -> None: + ctx_sock = Path("/Users/a/.docker/run/docker.sock") + + def run(argv: list[str]) -> str | None: + if argv[:3] == ["docker", "context", "inspect"]: + return f"unix://{ctx_sock}" + return None + + found = discover("Darwin", {"HOME": "/Users/a"}, run, lambda p: p == ctx_sock, ALL) + assert found == [Backend("docker", ctx_sock, "host.docker.internal")] + fallback = discover("Darwin", {"HOME": "/Users/a"}, lambda a: None, lambda p: p == ctx_sock, ALL) + assert fallback == [Backend("docker", ctx_sock, "host.docker.internal")] + + +def test_dockerd_on_linux_and_wanted_filter() -> None: + sock = Path("/var/run/docker.sock") + assert discover("Linux", {}, lambda a: None, lambda p: p == sock, ALL) == [ + Backend("docker", sock, "172.17.0.1") + ] + assert discover("Linux", {}, lambda a: None, lambda p: p == sock, frozenset({"podman"})) == [] + + +def test_both_backends_podman_first() -> None: + p = Path("/run/user/1/podman/podman.sock") + d = Path("/var/run/docker.sock") + found = discover("Linux", {"XDG_RUNTIME_DIR": "/run/user/1"}, lambda a: None, lambda x: x in (p, d), ALL) + assert [b.kind for b in found] == ["podman", "docker"] + + +def test_egress_env() -> None: + b = Backend("podman", Path("/s"), "host.containers.internal") + assert egress_proxy_env(b, 8899) == { + "HTTP_PROXY": "http://host.containers.internal:8899", + "HTTPS_PROXY": "http://host.containers.internal:8899", + "NO_PROXY": "localhost,127.0.0.1,host.containers.internal", + } + + +def test_host_alias_table() -> None: + assert host_alias("podman", "Darwin") == "host.containers.internal" + assert host_alias("docker", "Darwin") == "host.docker.internal" + assert host_alias("docker", "Linux") == "172.17.0.1" + assert host_alias("podman", "Linux") == "10.88.0.1" diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py new file mode 100644 index 000000000..ed53cbe95 --- /dev/null +++ b/tools/container-gateway/tests/test_daemon.py @@ -0,0 +1,1352 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Daemon plumbing: paths, guards, idle exit, status and stop. + +Threat model exercised throughout this file: the daemon runs OUTSIDE the +sandbox with the operator's own privileges, while the sandboxed agent +can create, delete and symlink anything under the project tree, +including ``.apache-magpie-local/run/``. Every guard test below plants +exactly the kind of hostile filesystem state that threat model implies +and checks the guard refuses it -- no bind needed for any of them. + +No ``pytest-asyncio`` in the ``magpie-dev`` dependency group, so each +async scenario is a plain ``def`` test driving its coroutine through the +``run()`` helper, exactly as ``test_http.py`` and ``test_relay.py`` do. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +from collections.abc import AsyncIterator, Coroutine, Iterator +from pathlib import Path +from typing import Any, TypeVar + +import pytest + +from container_gateway import __main__ as cli +from container_gateway import daemon +from container_gateway.backends import Backend + +from .fakebackend import FakeBackend + +SRC = Path(__file__).resolve().parents[1] / "src" + +_T = TypeVar("_T") + +# Every scenario in this file is bounded. A daemon test that wedges -- +# waiting on a bind that never completes, a socket nobody answers, an +# idle timer that never fires -- must fail in seconds, naming itself, +# rather than hang the whole run until CI's job limit kills it with no +# output at all. Generous enough that no healthy scenario can reach it: +# the longest one here idles out after 1.5s. +SCENARIO_TIMEOUT = 15.0 +# The same bound for the subprocess-level CLI tests further down. +SUBPROCESS_TIMEOUT = 30 + + +def run(coro: Coroutine[Any, Any, _T], timeout: float = SCENARIO_TIMEOUT) -> _T: + """Drive a coroutine to completion without pytest-asyncio, under a hard bound.""" + + async def bounded() -> _T: + return await asyncio.wait_for(coro, timeout) + + return asyncio.run(bounded()) + + +async def _tcp_listener_or_skip() -> asyncio.AbstractServer: + """A loopback TCP listener, skipping the caller where the sandbox refuses ``bind()``. + + Returning the server rather than binding inside the caller's ``try`` + keeps the caller's ``finally`` from ever referencing a name that was + never assigned. + + The connection callback closes its side immediately. A callback that + leaves the accepted transport open makes ``Server.wait_closed()`` block + forever once anything has connected -- ``StreamReaderProtocol`` keeps the + transport alive after the peer's EOF, and since Python 3.12.1 + ``wait_closed()`` waits for every accepted connection, not just the + listening socket. That is what hung this file's teardown on CI. + """ + + def _close_immediately(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + writer.close() + + try: + return await asyncio.start_server(_close_immediately, host="127.0.0.1", port=0) + except PermissionError: + pytest.skip("sandbox denies TCP bind; runs in CI") + # Unreachable: pytest.skip() raises. The bare re-raise is what tells + # a static analyser the except branch never falls through -- without + # it, CodeQL reads the code below as running with nothing assigned. + raise + + +async def _close_server(server: asyncio.AbstractServer) -> None: + """Close a listener and wait for it, bounded. + + Teardown must never be the thing that hangs a test: an unbounded + ``wait_closed()`` turns one stuck connection into a whole-job timeout + with no failing test to point at. + """ + server.close() + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(server.wait_closed(), 5) + + +def _ns(project: Path, run_dir: Path, **extra: Any) -> argparse.Namespace: + """A minimal argparse.Namespace for calling cmd_serve/cmd_stop/cmd_status directly.""" + base = {"project": project, "run_dir": run_dir, "pid_file": None} + base.update(extra) + return argparse.Namespace(**base) + + +class _HeldPidLock: + """A pid lock the test body may hand back early, at most once. + + The ``stop``-path tests below release the lock mid-test, from inside + a fake ``os.kill``, to simulate the daemon exiting and dropping its + flock. ``released`` is how ``held_pid_lock`` knows not to close the + same descriptor a second time on the way out -- by then the number + may belong to something else entirely. + """ + + def __init__(self, fd: int) -> None: + self._fd = fd + self.released = False + + def close(self) -> None: + if not self.released: + self.released = True + os.close(self._fd) + + +@contextlib.contextmanager +def held_pid_lock(pid_file: Path) -> Iterator[_HeldPidLock]: + """Hold ``pid_file``'s lock for the body, releasing it however the body ends.""" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + lock = _HeldPidLock(fd) + try: + yield lock + finally: + if not lock.released: + lock.released = True + os.close(fd) + + +@pytest.fixture +def short_run_dir() -> Iterator[Path]: + """A run directory short enough to hold a unix-socket path. + + ``pytest``'s own ``tmp_path`` fixture nests under + ``pytest-of-/pytest-//`` inside ``$TMPDIR``, which + in this sandboxed dev environment is already long enough on its own + to blow the ~103-byte ``sun_path`` limit before ``run_dir`` even adds + ``podman.sock``. ``tempfile.mkdtemp()`` sits directly under + ``$TMPDIR`` with none of that nesting, so it stays short everywhere + ``tmp_path`` might not. It is also already 0700 and owned by us, so + it satisfies ``check_run_dir``'s "custom run-dir" branch as-is. + """ + d = Path(tempfile.mkdtemp(prefix="cg-")) + try: + yield d + finally: + shutil.rmtree(d, ignore_errors=True) + + +class _RealSocketBackend: + """A ``FakeBackend`` served over a real filesystem unix socket. + + ``tests/fakebackend.FakeBackend`` only ever hands out in-process + ``socket.socketpair()`` connections (the sandbox refuses ``bind()``, + so it never listens on a path) -- but ``daemon.run()`` needs a real + socket path to hand to ``unix_connector`` for a ``Backend``. This + thin wrapper does the one real bind these daemon tests need, so it + can hit the same ``PermissionError`` the sandbox raises and skip + exactly like every other bind-touching test in this suite. + """ + + def __init__(self, socket_path: Path) -> None: + self.socket = socket_path + self._fake = FakeBackend() + self._server: asyncio.AbstractServer | None = None + + async def start(self) -> None: + self._server = await asyncio.start_unix_server(self._fake._handle, path=str(self.socket)) + + async def stop(self) -> None: + """Stop serving, without waiting on a connection nobody will close. + + ``wait_closed`` waits for every accepted connection's handler + too (Python 3.12.1 and later), and the fake's handler sits + reading the next request on a keep-alive connection the gateway + has not closed yet. Teardown is bounded so a test that leaves + one open fails on its own assertion rather than wedging the run. + """ + if self._server is not None: + self._server.close() + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(self._server.wait_closed(), 5) + + +@contextlib.asynccontextmanager +async def _gateway( + project_root: Path, run_dir: Path, idle_timeout: float +) -> AsyncIterator[tuple[daemon.Config, asyncio.Task[int]]]: + """A running ``daemon.run()`` task with a real backend socket behind it. + + Skips the caller wherever the sandbox refuses the binds this needs, + exactly like every other bind-touching test in this suite: the + backend's own listener up front, the gateway's two sockets via the + task's early exception. + """ + backend = _RealSocketBackend(run_dir / "d.sock") + try: + await asyncio.wait_for(backend.start(), 5) + except PermissionError: + pytest.skip("sandbox denies unix bind; runs in CI") + # Unreachable: pytest.skip() raises. The bare re-raise is what tells + # a static analyser the except branch never falls through -- without + # it, CodeQL reads the code below as running with nothing assigned. + raise + try: + cfg = daemon.Config( + project_root, + run_dir, + ("podman", "docker"), + "off", + 8899, + None, + (), + idle_timeout, + "INFO", + run_dir / "pid", + ) + found = [Backend("podman", backend.socket, "host.containers.internal")] + task = asyncio.create_task(daemon.run(cfg, discover_fn=lambda *a, **k: found, platform="Darwin")) + await asyncio.sleep(0.3) + if task.done(): + exc = task.exception() + if isinstance(exc, PermissionError): + pytest.skip("sandbox denies unix bind; runs in CI") + if exc is not None: + raise exc + try: + yield cfg, task + finally: + # Only reached when the body did not get the daemon to exit + # on its own -- the failure is the body's to report, so the + # teardown just makes sure nothing is left running behind it. + if not task.done(): + task.cancel() + await asyncio.wait([task], timeout=5) + finally: + await asyncio.wait_for(backend.stop(), 5) + + +async def _ping(sock_path: Path) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """Ping the gateway over a fresh client connection, and leave it open. + + The response is read by its framing rather than to EOF: the gateway + keeps the connection alive after a ``/_ping``, so a read to EOF + would only return once the daemon tore the connection down, which + is the very thing the callers below are measuring. + """ + try: + r, w = await asyncio.wait_for(asyncio.open_unix_connection(str(sock_path)), 5) + except PermissionError: + pytest.skip("sandbox denies unix socket connections; runs in CI") + # Unreachable: pytest.skip() raises. The bare re-raise is what tells + # a static analyser the except branch never falls through -- without + # it, CodeQL reads the code below as running with nothing assigned. + raise + w.write(b"GET /_ping HTTP/1.1\r\nHost: x\r\n\r\n") + await asyncio.wait_for(w.drain(), 5) + # Every await here is bounded: a gateway that accepts the connection + # and then never answers must fail the test, not wedge the run. + assert b"200 OK" in await asyncio.wait_for(r.readuntil(b"\r\n\r\n"), 5) + assert await asyncio.wait_for(r.readexactly(2), 5) == b"OK" + return r, w + + +async def _close_client(w: asyncio.StreamWriter) -> None: + """Close a client connection and wait for the close to land. + + Guarded twice over: the peer may already be gone (``OSError``), and + a close that does not complete must not wedge a test that has + finished with the connection anyway. + """ + w.close() + with contextlib.suppress(OSError, TimeoutError): + await asyncio.wait_for(w.wait_closed(), 5) + + +# --------------------------------------------------------------- paths + + +def test_paths_and_socket_length(tmp_path: Path) -> None: + p = daemon.paths(tmp_path) + assert p["podman"].name == "podman.sock" and p["docker"].name == "docker.sock" + # A literal short path, not tmp_path: with TMPDIR unset, tmp_path's + # pytest-of-/pytest-// nesting is already long + # enough to exceed the ~103-byte sun_path limit on its own, and + # check_socket_path only measures byte length. + daemon.check_socket_path(Path("/tmp/ok.sock")) + with pytest.raises(SystemExit) as exc: + daemon.check_socket_path(Path("/" + "x" * 120 + "/podman.sock")) + assert exc.value.code == 2 + + +# ------------------------------------------------ C2: run dir / symlinks + + +def test_check_run_dir_default_layout_creates_both_dirs_0700(tmp_path: Path) -> None: + project_root = tmp_path / "proj" + project_root.mkdir() + run_dir = project_root / ".apache-magpie-local" / "run" + daemon.check_run_dir(run_dir, project_root) + assert (project_root / ".apache-magpie-local").stat().st_mode & 0o777 == 0o700 + assert run_dir.stat().st_mode & 0o777 == 0o700 + + +def test_check_run_dir_refuses_world_writable_existing_dir(tmp_path: Path) -> None: + project_root = tmp_path + run_dir = tmp_path / "custom-run" + daemon.check_run_dir(run_dir, project_root) + assert run_dir.stat().st_mode & 0o777 == 0o700 + run_dir.chmod(0o777) + with pytest.raises(SystemExit) as exc: + daemon.check_run_dir(run_dir, project_root) + assert exc.value.code == 2 + + +def test_check_run_dir_refuses_symlinked_run(tmp_path: Path) -> None: + project_root = tmp_path / "proj" + magpie_local = project_root / ".apache-magpie-local" + magpie_local.mkdir(parents=True, mode=0o700) + evil = tmp_path / "evil" + evil.mkdir() + (magpie_local / "run").symlink_to(evil) + with pytest.raises(SystemExit) as exc: + daemon.check_run_dir(magpie_local / "run", project_root) + assert exc.value.code == 2 + + +def test_check_run_dir_refuses_symlinked_apache_magpie_local(tmp_path: Path) -> None: + project_root = tmp_path / "proj" + project_root.mkdir() + evil = tmp_path / "evil" + evil.mkdir() + (project_root / ".apache-magpie-local").symlink_to(evil) + with pytest.raises(SystemExit) as exc: + daemon.check_run_dir(project_root / ".apache-magpie-local" / "run", project_root) + assert exc.value.code == 2 + + +def test_check_run_dir_refuses_symlinked_custom_run_dir(tmp_path: Path) -> None: + project_root = tmp_path / "proj" + project_root.mkdir() + evil = tmp_path / "evil" + evil.mkdir() + custom = tmp_path / "custom-run" + custom.symlink_to(evil) + with pytest.raises(SystemExit) as exc: + daemon.check_run_dir(custom, project_root) + assert exc.value.code == 2 + + +def test_check_run_dir_accepts_a_symlinked_ancestor_above_the_project_root(tmp_path: Path) -> None: + """The host's own layout is not the threat model. + + ``/tmp`` is a symlink to ``/private/tmp`` on macOS, ``/var`` likewise, + and a home directory can sit on a linked volume -- a project reached + through any of those must be served, not refused. The same shape is + built portably here, so it runs on Linux too: a symlinked directory + with a real project tree underneath it. + """ + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real) + project_root = link / "proj" + project_root.mkdir() + run_dir = project_root / ".apache-magpie-local" / "run" + daemon.check_run_dir(run_dir, project_root) + assert (real / "proj" / ".apache-magpie-local" / "run").is_dir() + assert daemon.validate_run_dir(run_dir, project_root) is True + + +def test_check_run_dir_accepts_a_custom_run_dir_under_a_symlinked_ancestor(tmp_path: Path) -> None: + """``--run-dir /tmp/whatever`` on macOS: the parent chain resolves, it is not refused.""" + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real) + project_root = tmp_path / "proj" + project_root.mkdir() + run_dir = link / "custom-run" + daemon.check_run_dir(run_dir, project_root) + assert (real / "custom-run").is_dir() + assert (real / "custom-run").stat().st_mode & 0o777 == 0o700 + assert daemon.validate_run_dir(run_dir, project_root) is True + + +def test_check_run_dir_refuses_a_symlinked_run_under_a_symlinked_ancestor(tmp_path: Path) -> None: + """Resolving the chain above the anchor does not excuse a symlink AT an owned component.""" + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real) + project_root = link / "proj" + magpie_local = project_root / ".apache-magpie-local" + magpie_local.mkdir(parents=True, mode=0o700) + evil = tmp_path / "evil" + evil.mkdir() + (magpie_local / "run").symlink_to(evil) + with pytest.raises(SystemExit) as exc: + daemon.check_run_dir(magpie_local / "run", project_root) + assert exc.value.code == 2 + + +def test_check_run_dir_refuses_a_foreign_owned_intermediate_component( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ownership is still checked on every component the gateway owns, not just the last one.""" + project_root = tmp_path / "proj" + (project_root / ".apache-magpie-local").mkdir(parents=True, mode=0o700) + foreign = os.geteuid() + 1 # captured before the patch, or the lambda recurses + monkeypatch.setattr(os, "geteuid", lambda: foreign) + with pytest.raises(SystemExit) as exc: + daemon.check_run_dir(project_root / ".apache-magpie-local" / "run", project_root) + assert exc.value.code == 2 + + +def test_check_run_dir_refuses_a_group_writable_intermediate_component(tmp_path: Path) -> None: + project_root = tmp_path / "proj" + magpie_local = project_root / ".apache-magpie-local" + magpie_local.mkdir(parents=True, mode=0o700) + magpie_local.chmod(0o770) + with pytest.raises(SystemExit) as exc: + daemon.check_run_dir(magpie_local / "run", project_root) + assert exc.value.code == 2 + + +def test_validate_run_dir_refuses_a_group_writable_intermediate_component(tmp_path: Path) -> None: + project_root = tmp_path / "proj" + magpie_local = project_root / ".apache-magpie-local" + (magpie_local / "run").mkdir(parents=True, mode=0o700) + magpie_local.chmod(0o770) + with pytest.raises(SystemExit) as exc: + daemon.validate_run_dir(magpie_local / "run", project_root) + assert exc.value.code == 2 + + +# ------------------------------------------- D5: missing ancestors refuse + + +def test_check_run_dir_refuses_missing_project_root(tmp_path: Path) -> None: + missing_root = tmp_path / "does-not-exist" + with pytest.raises(SystemExit) as exc: + daemon.check_run_dir(missing_root / ".apache-magpie-local" / "run", missing_root) + assert exc.value.code == 2 + + +def test_check_run_dir_refuses_missing_custom_run_dir_parent(tmp_path: Path) -> None: + project_root = tmp_path / "proj" + project_root.mkdir() + missing_parent_run_dir = tmp_path / "does-not-exist" / "run" + with pytest.raises(SystemExit) as exc: + daemon.check_run_dir(missing_parent_run_dir, project_root) + assert exc.value.code == 2 + + +def test_validate_run_dir_false_when_project_root_missing(tmp_path: Path) -> None: + missing_root = tmp_path / "does-not-exist" + assert daemon.validate_run_dir(missing_root / ".apache-magpie-local" / "run", missing_root) is False + + +def test_validate_run_dir_false_when_custom_parent_missing(tmp_path: Path) -> None: + project_root = tmp_path / "proj" + project_root.mkdir() + missing_parent_run_dir = tmp_path / "does-not-exist" / "run" + assert daemon.validate_run_dir(missing_parent_run_dir, project_root) is False + + +# --------------------------------------- D6: lstat-then-mkdir race safety + + +def test_ensure_owned_private_dir_handles_mkdir_race_with_planted_symlink( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = tmp_path / "custom-run" + evil = tmp_path / "evil" + evil.mkdir() + real_mkdir = Path.mkdir + + def racy_mkdir(self: Path, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None: + if self == target: + target.symlink_to(evil) # someone (or something) won the race and planted a symlink + raise FileExistsError(f"[Errno 17] File exists: '{target}'") + real_mkdir(self, mode, parents=parents, exist_ok=exist_ok) + + monkeypatch.setattr(Path, "mkdir", racy_mkdir) + with pytest.raises(SystemExit) as exc: + daemon.check_run_dir(target, tmp_path) + assert exc.value.code == 2 + + +# --------------------------------------------------- C2: socket guards + + +def test_check_socket_type_refuses_non_socket(tmp_path: Path) -> None: + p = tmp_path / "podman.sock" + p.write_text("not a socket") + with pytest.raises(SystemExit) as exc: + daemon.check_socket_type(p) + assert exc.value.code == 2 + + +def test_check_socket_type_allows_missing_path(tmp_path: Path) -> None: + daemon.check_socket_type(tmp_path / "missing.sock") # nothing there yet: fine + + +# ------------------------------------------------------ C2: pid / log fd + + +def test_acquire_pid_lock_refuses_symlink(tmp_path: Path) -> None: + target = tmp_path / "elsewhere.pid" + target.write_text("") + link = tmp_path / "container-gateway.pid" + link.symlink_to(target) + # `fd` stays None because the refusal fires before the open returns; + # the `finally` is what keeps this test from leaking a descriptor if + # `acquire_pid_lock` ever stops refusing. + fd: int | None = None + try: + with pytest.raises(SystemExit) as exc: + fd = daemon.acquire_pid_lock(link) + assert exc.value.code == 2 + finally: + if fd is not None: + os.close(fd) + + +def test_probe_pid_lock_refuses_symlink(tmp_path: Path) -> None: + target = tmp_path / "elsewhere.pid" + target.write_text("") + link = tmp_path / "container-gateway.pid" + link.symlink_to(target) + with pytest.raises(SystemExit) as exc: + daemon.probe_pid_lock(link) + assert exc.value.code == 2 + + +def test_probe_pid_lock_not_running_when_run_dir_absent(tmp_path: Path) -> None: + never_created = tmp_path / "never" / "container-gateway.pid" + assert daemon.probe_pid_lock(never_created) == (False, None) + + +def test_open_log_fd_refuses_symlink(tmp_path: Path) -> None: + target = tmp_path / "elsewhere.log" + target.write_text("") + link = tmp_path / "container-gateway.log" + link.symlink_to(target) + # Same shape as the pid-lock refusal above: nothing should be opened, + # and the `finally` proves it rather than assuming it. + fd: int | None = None + try: + with pytest.raises(SystemExit) as exc: + fd = cli._open_log_fd(link) + assert exc.value.code == 2 + finally: + if fd is not None: + os.close(fd) + + +# --------------------- D2: acquire_pid_lock never blanks a live pid file + + +def test_acquire_pid_lock_does_not_blank_a_live_daemons_pid_file(tmp_path: Path) -> None: + pid_file = tmp_path / "container-gateway.pid" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + second: int | None = None + try: + os.ftruncate(fd, 0) + os.lseek(fd, 0, 0) + os.write(fd, b"4242\n") + os.fsync(fd) + second = daemon.acquire_pid_lock(pid_file) + assert second is None + assert pid_file.read_text() == "4242\n" + finally: + if second is not None: + os.close(second) # only reachable if the contended probe ever won the lock + os.close(fd) + + +# ------------------------------------- D1: status/stop bypassed check_run_dir + + +def test_pid_file_is_trustworthy_none_when_missing(tmp_path: Path) -> None: + assert daemon.pid_file_is_trustworthy(tmp_path / "container-gateway.pid") is None + + +def test_pid_file_is_trustworthy_true_for_a_lock_created_file(tmp_path: Path) -> None: + pid_file = tmp_path / "container-gateway.pid" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + try: + assert daemon.pid_file_is_trustworthy(pid_file) is True + finally: + os.close(fd) + + +def test_pid_file_is_trustworthy_false_for_symlink(tmp_path: Path) -> None: + target = tmp_path / "elsewhere.pid" + target.write_text("4242\n") + target.chmod(0o600) + link = tmp_path / "container-gateway.pid" + link.symlink_to(target) + assert daemon.pid_file_is_trustworthy(link) is False + + +def test_pid_file_is_trustworthy_false_for_wrong_mode(tmp_path: Path) -> None: + pid_file = tmp_path / "container-gateway.pid" + pid_file.write_text("4242\n") + pid_file.chmod(0o644) + assert daemon.pid_file_is_trustworthy(pid_file) is False + + +def test_cli_status_refuses_symlinked_run_dir(tmp_path: Path) -> None: + project_root = tmp_path / "proj" + magpie_local = project_root / ".apache-magpie-local" + magpie_local.mkdir(parents=True, mode=0o700) + evil = tmp_path / "evil" + evil.mkdir() + (magpie_local / "run").symlink_to(evil) + with pytest.raises(SystemExit) as exc: + cli.cmd_status(_ns(project_root, magpie_local / "run")) + assert exc.value.code == 2 + + +def test_cli_stop_refuses_symlinked_run_dir_without_signalling( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project_root = tmp_path / "proj" + magpie_local = project_root / ".apache-magpie-local" + magpie_local.mkdir(parents=True, mode=0o700) + evil = tmp_path / "evil" + evil.mkdir() + (magpie_local / "run").symlink_to(evil) + calls: list[tuple[int, int]] = [] + monkeypatch.setattr(os, "kill", lambda pid, sig: calls.append((pid, sig))) + with pytest.raises(SystemExit) as exc: + cli.cmd_stop(_ns(project_root, magpie_local / "run")) + assert exc.value.code == 2 + assert calls == [] + + +def test_cli_stop_refuses_pid_file_with_wrong_mode( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pid_file = short_run_dir / "container-gateway.pid" + pid_file.write_text("4242\n") + pid_file.chmod(0o644) + calls: list[tuple[int, int]] = [] + monkeypatch.setattr(os, "kill", lambda pid, sig: calls.append((pid, sig))) + with pytest.raises(SystemExit) as exc: + cli.cmd_stop(_ns(tmp_path, short_run_dir)) + assert exc.value.code == 2 + assert calls == [] + + +def test_cli_status_treats_wrong_mode_pid_file_as_not_running( + tmp_path: Path, short_run_dir: Path, capsys: pytest.CaptureFixture[str] +) -> None: + pid_file = short_run_dir / "container-gateway.pid" + pid_file.write_text("4242\n") + pid_file.chmod(0o644) + rc = cli.cmd_status(_ns(tmp_path, short_run_dir)) + out = json.loads(capsys.readouterr().out) + assert rc == 3 + assert out["running"] is False + # Untrustworthy content is left alone, not deleted, by `status`. + assert pid_file.exists() + + +# -------------------------------- D4: stop verifies the signalled process + + +def test_cli_stop_refuses_when_process_does_not_look_like_gateway( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pid_file = short_run_dir / "container-gateway.pid" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + try: + monkeypatch.setattr("container_gateway.backends.default_runner", lambda argv: "bash -c sleep 100") + calls: list[tuple[int, int]] = [] + monkeypatch.setattr(os, "kill", lambda pid, sig: calls.append((pid, sig))) + rc = cli.cmd_stop(_ns(tmp_path, short_run_dir)) + assert calls == [] + assert rc == 1 + finally: + os.close(fd) + + +def test_cli_stop_refuses_a_process_that_merely_mentions_the_module_name( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An editor opened on a file named ``container_gateway.py`` is not the gateway. + + A substring check on the ``ps`` line would wrongly treat this as a + match; the argv-shape check must not. + """ + pid_file = short_run_dir / "container-gateway.pid" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + try: + monkeypatch.setattr( + "container_gateway.backends.default_runner", lambda argv: "vim container_gateway.py" + ) + calls: list[tuple[int, int]] = [] + monkeypatch.setattr(os, "kill", lambda pid, sig: calls.append((pid, sig))) + rc = cli.cmd_stop(_ns(tmp_path, short_run_dir)) + assert calls == [] + assert rc == 1 + finally: + os.close(fd) + + +def test_cli_stop_checks_only_the_first_line_of_ps_output( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A gateway-shaped second line must not rescue a non-gateway first line.""" + pid_file = short_run_dir / "container-gateway.pid" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + try: + monkeypatch.setattr( + "container_gateway.backends.default_runner", + lambda argv: "bash -c sleep 100\npython3 -m container_gateway serve --project /x", + ) + calls: list[tuple[int, int]] = [] + monkeypatch.setattr(os, "kill", lambda pid, sig: calls.append((pid, sig))) + rc = cli.cmd_stop(_ns(tmp_path, short_run_dir)) + assert calls == [] + assert rc == 1 + finally: + os.close(fd) + + +def test_cli_stop_signals_when_process_is_the_console_script( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The installed ``container-gateway`` console script is also recognised, not just ``python -m``.""" + pid_file = short_run_dir / "container-gateway.pid" + our_pid = os.getpid() + calls: list[tuple[int, int]] = [] + terminated = False + + with held_pid_lock(pid_file) as lock: + + def fake_kill(pid: int, sig: int) -> None: + nonlocal terminated + calls.append((pid, sig)) + if sig == signal.SIGTERM: + terminated = True + lock.close() # simulate the daemon exiting: release the flock + elif terminated: + raise ProcessLookupError + + monkeypatch.setattr(os, "kill", fake_kill) + monkeypatch.setattr( + "container_gateway.backends.default_runner", + lambda argv: "/usr/local/bin/container-gateway serve --project /x", + ) + rc = cli.cmd_stop(_ns(tmp_path, short_run_dir)) + assert rc == 0 + assert (our_pid, signal.SIGTERM) in calls + + +@pytest.mark.parametrize( + "command_line", + [ + "python3 -u -m container_gateway serve --project /x", + "uv run python -m container_gateway serve --project /x", + ], +) +def test_cli_stop_signals_a_python_dash_m_invocation_with_extra_argv( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch, command_line: str +) -> None: + """Interpreter flags (``-u``) and a runner prefix (``uv run``) do not defeat the argv-shape match.""" + pid_file = short_run_dir / "container-gateway.pid" + our_pid = os.getpid() + calls: list[tuple[int, int]] = [] + terminated = False + + with held_pid_lock(pid_file) as lock: + + def fake_kill(pid: int, sig: int) -> None: + nonlocal terminated + calls.append((pid, sig)) + if sig == signal.SIGTERM: + terminated = True + lock.close() # simulate the daemon exiting: release the flock + elif terminated: + raise ProcessLookupError + + monkeypatch.setattr(os, "kill", fake_kill) + monkeypatch.setattr("container_gateway.backends.default_runner", lambda argv: command_line) + rc = cli.cmd_stop(_ns(tmp_path, short_run_dir)) + assert rc == 0 + assert (our_pid, signal.SIGTERM) in calls + + +def test_cli_stop_refuses_when_ps_is_unavailable( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pid_file = short_run_dir / "container-gateway.pid" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + try: + monkeypatch.setattr("container_gateway.backends.default_runner", lambda argv: None) + calls: list[tuple[int, int]] = [] + monkeypatch.setattr(os, "kill", lambda pid, sig: calls.append((pid, sig))) + rc = cli.cmd_stop(_ns(tmp_path, short_run_dir)) + assert calls == [] + assert rc == 1 + finally: + os.close(fd) + + +# --------------------------------------------------------------- C1: pid + + +@pytest.mark.parametrize("content", ["-1", "0", "1", "abc", "12 34", "99999999999", ""]) +def test_read_pid_rejects_unsafe_content(tmp_path: Path, content: str) -> None: + pid_file = tmp_path / "container-gateway.pid" + pid_file.write_text(content) + assert daemon.read_pid(pid_file) is None + + +def test_read_pid_accepts_a_real_pid(tmp_path: Path) -> None: + pid_file = tmp_path / "container-gateway.pid" + pid_file.write_text("42\n") + assert daemon.read_pid(pid_file) == 42 + + +def test_pid_alive_false_for_pid_le_1() -> None: + assert daemon.pid_alive(0) is False + assert daemon.pid_alive(1) is False + assert daemon.pid_alive(-1) is False + + +@pytest.mark.parametrize("content", ["-1", "0", "1", "abc", "12 34"]) +def test_cli_stop_with_unsafe_pid_file_does_not_signal( + tmp_path: Path, short_run_dir: Path, content: str, monkeypatch: pytest.MonkeyPatch +) -> None: + pid_file = short_run_dir / "container-gateway.pid" + pid_file.write_text(content) + pid_file.chmod(0o600) # a trustworthy *file*; the *content* is what's unsafe here + calls: list[tuple[int, int]] = [] + monkeypatch.setattr(os, "kill", lambda pid, sig: calls.append((pid, sig))) + rc = cli.cmd_stop(_ns(tmp_path, short_run_dir)) + assert calls == [] + assert rc == 0 + + +def test_cli_stop_does_not_signal_when_lock_held_but_pid_content_poisoned( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A live, lock-holding instance whose pid-file *content* was overwritten afterwards. + + ``stop`` must refuse to guess: something is clearly running (the + lock says so), but nothing safe to signal can be read back out of + the file. + """ + pid_file = short_run_dir / "container-gateway.pid" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + try: + os.ftruncate(fd, 0) + os.lseek(fd, 0, 0) + os.write(fd, b"-1\n") + calls: list[tuple[int, int]] = [] + monkeypatch.setattr(os, "kill", lambda pid, sig: calls.append((pid, sig))) + rc = cli.cmd_stop(_ns(tmp_path, short_run_dir)) + assert calls == [] + assert rc == 1 + finally: + os.close(fd) + + +# --------------------------------------------- I3+I4: flock single instance + + +def test_cli_serve_short_circuits_when_lock_already_held(tmp_path: Path, short_run_dir: Path) -> None: + pid_file = short_run_dir / "container-gateway.pid" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + try: + ns = _ns( + tmp_path, + short_run_dir, + daemon=False, + backend=None, + egress="off", + egress_port=8899, + egress_host=None, + extra_bind_root=None, + idle_timeout=5.0, + backend_timeout=60.0, + log_level="INFO", + ) + assert cli.cmd_serve(ns) == 0 + assert not (short_run_dir / "podman.sock").exists() + assert not (short_run_dir / "docker.sock").exists() + finally: + os.close(fd) + + +def test_cli_status_reports_running_when_lock_held( + tmp_path: Path, short_run_dir: Path, capsys: pytest.CaptureFixture[str] +) -> None: + pid_file = short_run_dir / "container-gateway.pid" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + try: + rc = cli.cmd_status(_ns(tmp_path, short_run_dir)) + out = json.loads(capsys.readouterr().out) + finally: + os.close(fd) + assert rc == 0 + assert out["running"] is True + assert out["pid"] == os.getpid() + assert out["serving"] == [] # no sockets actually bound in this test + + +def test_cli_status_removes_stale_pid_file_when_no_lock_held( + tmp_path: Path, short_run_dir: Path, capsys: pytest.CaptureFixture[str] +) -> None: + pid_file = short_run_dir / "container-gateway.pid" + pid_file.write_text("424242\n") # a stale pid; nobody holds its lock + pid_file.chmod(0o600) # a trustworthy, well-formed pid file -- just stale + rc = cli.cmd_status(_ns(tmp_path, short_run_dir)) + out = json.loads(capsys.readouterr().out) + assert rc == 3 + assert out["running"] is False + assert out["pid"] is None + assert not pid_file.exists() + + +def test_cli_stop_signals_the_pid_and_reports_stopped( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pid_file = short_run_dir / "container-gateway.pid" + our_pid = os.getpid() + calls: list[tuple[int, int]] = [] + terminated = False + + with held_pid_lock(pid_file) as lock: + + def fake_kill(pid: int, sig: int) -> None: + nonlocal terminated + calls.append((pid, sig)) + if sig == signal.SIGTERM: + terminated = True + lock.close() # simulate the daemon exiting: release the flock + elif terminated: + raise ProcessLookupError + + monkeypatch.setattr(os, "kill", fake_kill) + monkeypatch.setattr( + "container_gateway.backends.default_runner", + lambda argv: "python3 -m container_gateway serve --project /x", + ) + rc = cli.cmd_stop(_ns(tmp_path, short_run_dir)) + assert rc == 0 + assert (our_pid, signal.SIGTERM) in calls + + +def test_cli_stop_when_lock_never_held_is_a_noop(tmp_path: Path, short_run_dir: Path) -> None: + assert cli.cmd_stop(_ns(tmp_path, short_run_dir)) == 0 + + +def test_cli_stop_prints_a_message_when_project_root_does_not_exist( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A --project value that names nothing gets its own message, not the run-directory one.""" + missing_root = tmp_path / "never-served" + run_dir = missing_root / ".apache-magpie-local" / "run" + rc = cli.cmd_stop(_ns(missing_root, run_dir)) + out = capsys.readouterr().out + assert rc == 0 + assert f"no project root at {missing_root}: nothing to stop" in out + + +def test_cli_stop_prints_a_message_when_no_run_directory_ever_existed( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A project that exists but was never served must not look identical to "stopped successfully".""" + project_root = tmp_path / "served-project" + project_root.mkdir() + run_dir = project_root / ".apache-magpie-local" / "run" + rc = cli.cmd_stop(_ns(project_root, run_dir)) + out = capsys.readouterr().out + assert rc == 0 + assert f"no run directory at {run_dir}: nothing to stop" in out + + +# ------------------------- D3: unlink the pid file before closing its fd + + +def test_run_unlinks_pid_file_before_closing_the_lock_fd( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + events: list[str] = [] + real_unlink = Path.unlink + real_close = os.close + pid_file = short_run_dir / "pid" + + def recording_unlink(self: Path, missing_ok: bool = False) -> None: + if self == pid_file: + events.append("unlink") + real_unlink(self, missing_ok=missing_ok) + + def recording_close(fd: int) -> None: + events.append("close") + real_close(fd) + + monkeypatch.setattr(Path, "unlink", recording_unlink) + monkeypatch.setattr(os, "close", recording_close) + + cfg = daemon.Config( + tmp_path, short_run_dir, ("podman", "docker"), "off", 8899, None, (), 5.0, "INFO", pid_file + ) + + async def scenario() -> None: + rc = await asyncio.wait_for(daemon.run(cfg, discover_fn=lambda *a, **k: [], platform="Darwin"), 5) + assert rc == 0 + + run(scenario()) + assert "unlink" in events + assert "close" in events + assert events.index("unlink") < events.index("close") + + +# ---------------------------------------------------------------- I5 + + +def test_partial_bind_failure_cleans_up_first_socket_and_server( + tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[Path] = [] + closed: list[bool] = [] + + class _FakeServer: + def close(self) -> None: + closed.append(True) + + async def wait_closed(self) -> None: + return None + + async def fake_serve_unix(path: Path, handler: Any) -> _FakeServer: + calls.append(path) + if len(calls) == 2: + raise OSError("simulated bind failure") + path.write_text("") # stand in for "a socket file now exists here" + return _FakeServer() + + monkeypatch.setattr(daemon, "serve_unix", fake_serve_unix) + cfg = daemon.Config( + tmp_path, + short_run_dir, + ("podman", "docker"), + "off", + 8899, + None, + (), + 5.0, + "INFO", + short_run_dir / "pid", + ) + found = [Backend("podman", short_run_dir / "upstream.sock", "host.containers.internal")] + + async def scenario() -> None: + with pytest.raises(OSError, match="simulated bind failure"): + await asyncio.wait_for(daemon.run(cfg, discover_fn=lambda *a, **k: found, platform="Darwin"), 5) + + run(scenario()) + assert closed == [True] + assert not (short_run_dir / "podman.sock").exists() + assert not cfg.pid_file.exists() + + +# ------------------------------------------------------- M9: probe_egress + + +def test_probe_egress_true_when_something_listens() -> None: + async def scenario() -> None: + server = await _tcp_listener_or_skip() + try: + port = server.sockets[0].getsockname()[1] + assert await daemon.probe_egress("host.containers.internal", port) is True + finally: + await _close_server(server) + + run(scenario()) + + +def test_probe_egress_false_when_nothing_listens() -> None: + async def scenario() -> None: + server = await _tcp_listener_or_skip() + port = server.sockets[0].getsockname()[1] + await _close_server(server) + assert await daemon.probe_egress("host.containers.internal", port) is False + + run(scenario()) + + +# ------------------------------------------------------------ full run() + + +def test_run_without_backends_exits_zero(tmp_path: Path, short_run_dir: Path) -> None: + async def scenario() -> None: + cfg = daemon.Config( + tmp_path, + short_run_dir, + ("podman", "docker"), + "off", + 8899, + None, + (), + 5.0, + "INFO", + short_run_dir / "pid", + ) + rc = await asyncio.wait_for(daemon.run(cfg, discover_fn=lambda *a, **k: [], platform="Darwin"), 5) + assert rc == 0 + assert not (short_run_dir / "podman.sock").exists() + + run(scenario()) + + +def test_run_serves_both_sockets_from_podman_only_and_idles_out(tmp_path: Path, short_run_dir: Path) -> None: + async def scenario() -> None: + async with _gateway(tmp_path, short_run_dir, 1.5) as (cfg, task): + clients = [await _ping(short_run_dir / name) for name in ("podman.sock", "docker.sock")] + assert daemon.read_pid(cfg.pid_file) == os.getpid() + # What is being measured below is the daemon's own exit, so + # the test's connections go first and the idle clock runs + # against nothing but the daemon. The test after this one is + # the one that leaves a connection open on purpose. + for _, w in clients: + await _close_client(w) + rc = await asyncio.wait_for(task, 10) # idle timeout fires 1.5s after the last close + assert rc == 0 + assert not cfg.pid_file.exists() + + run(scenario()) + + +def test_handlers_cancel_all_lets_wait_closed_return() -> None: + """The one mechanism the two idle-exit tests around this one rest on. + + Since Python 3.12.1 ``Server.wait_closed()`` returns only once every + accepted connection's handler has finished, so a handler parked on a + client that sends nothing more holds the server -- and the process + -- open indefinitely. The loopback listener here stands in for the + gateway's unix sockets: the same ``asyncio.Server``, over a bind + this sandbox allows where it refuses a unix one. + """ + + async def scenario() -> None: + handlers = daemon._Handlers() + started = asyncio.Event() + unwound = asyncio.Event() + + async def blocked(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + started.set() + try: + await reader.read() # the next keep-alive request, which never comes + finally: + unwound.set() + + try: + server = await asyncio.start_server(handlers.track(blocked), host="127.0.0.1", port=0) + except PermissionError: + pytest.skip("sandbox denies TCP bind; runs in CI") + # Unreachable: pytest.skip() raises. The bare re-raise is what tells + # a static analyser the except branch never falls through -- without + # it, CodeQL reads the code below as running with nothing assigned. + raise + try: + port = server.sockets[0].getsockname()[1] + _, w = await asyncio.wait_for(asyncio.open_connection("127.0.0.1", port), 5) + try: + await asyncio.wait_for(started.wait(), 5) + server.close() + await asyncio.wait_for(handlers.cancel_all(), 5) + assert unwound.is_set() + await asyncio.wait_for(server.wait_closed(), 5) + # Every handler deregistered as it unwound, so a second + # shutdown pass has nothing left to do. + await asyncio.wait_for(handlers.cancel_all(), 5) + finally: + await _close_client(w) + finally: + server.close() + + run(scenario()) + + +def test_run_idles_out_with_a_client_still_connected(tmp_path: Path, short_run_dir: Path) -> None: + """A connected client must not be able to hold the gateway open. + + ``Server.wait_closed()`` waits for every accepted connection's + handler as well (Python 3.12.1 and later), and the relay's handler + blocks reading the next request on a keep-alive connection, so a + daemon that closed its listening sockets and waited would never + reach its idle exit while anything was connected -- and would not + answer SIGTERM either. It ends its own handlers instead, so the + client's connection dies with the daemon rather than outliving it. + """ + + async def scenario() -> None: + async with _gateway(tmp_path, short_run_dir, 1.5) as (cfg, task): + reader, writer = await _ping(short_run_dir / "podman.sock") + try: + rc = await asyncio.wait_for(task, 10) # 1.5s idle plus the 1s poll, with margin + assert rc == 0 + assert not cfg.pid_file.exists() + assert not (short_run_dir / "podman.sock").exists() + # The connection was closed on the way out, not left + # dangling into a daemon that is no longer there. + assert await asyncio.wait_for(reader.read(), 5) == b"" + finally: + await _close_client(writer) + + run(scenario()) + + +# --------------------------------------------------------------- CLI e2e + + +def test_cli_status_when_not_running(tmp_path: Path) -> None: + done = subprocess.run( + [sys.executable, "-m", "container_gateway", "status", "--project", str(tmp_path)], + capture_output=True, + text=True, + env={**os.environ, "PYTHONPATH": str(SRC)}, + check=False, + timeout=SUBPROCESS_TIMEOUT, + ) + assert done.returncode == 3 + out = json.loads(done.stdout) + assert out["running"] is False + assert out["serving"] == [] + assert "backends" not in out + + +def test_cli_stop_when_never_served_reports_no_run_directory(tmp_path: Path) -> None: + """A project with no ``.apache-magpie-local/run`` at all gets a message, not silence. + + ``tmp_path`` itself has no run directory under it, so this exercises + ``validate_run_dir`` returning ``False`` end to end through the real + subprocess entry point, not just the unit-level ``cmd_stop`` call. + """ + done = subprocess.run( + [sys.executable, "-m", "container_gateway", "stop", "--project", str(tmp_path)], + capture_output=True, + text=True, + env={**os.environ, "PYTHONPATH": str(SRC)}, + check=False, + timeout=SUBPROCESS_TIMEOUT, + ) + assert done.returncode == 0 + assert "nothing to stop" in done.stdout + + +def test_cli_serve_help_lists_flags() -> None: + done = subprocess.run( + [sys.executable, "-m", "container_gateway", "serve", "--help"], + capture_output=True, + text=True, + env={**os.environ, "PYTHONPATH": str(SRC)}, + check=False, + timeout=SUBPROCESS_TIMEOUT, + ) + for flag in ( + "--project", + "--run-dir", + "--backend", + "--egress", + "--egress-port", + "--egress-host", + "--extra-bind-root", + "--idle-timeout", + "--backend-timeout", + "--log-level", + "--pid-file", + "--daemon", + ): + assert flag in done.stdout, flag + + +# ------------------------------------------- I7: TMPDIR is not a bind root + + +def test_bind_roots_are_the_project_and_the_extra_roots_only( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # $TMPDIR used to be a bind root. On macOS it is per-user, not + # per-project, so it let every project on the machine bind-mount every + # other project's scratch tree (and the agent's own). + project = tmp_path / "proj" + project.mkdir() + extra = tmp_path / "data" + extra.mkdir() + scratch = tmp_path / "scratch" + scratch.mkdir() + monkeypatch.setenv("TMPDIR", str(scratch)) + cfg = daemon.Config( + project_root=project, + run_dir=project / "run", + backends=(), + egress_mode="off", + egress_port=8899, + egress_host=None, + extra_bind_roots=(extra,), + idle_timeout=5.0, + log_level="INFO", + pid_file=project / "run" / "container-gateway.pid", + ) + ctx = daemon.build_context(cfg, Backend("podman", Path("/x.sock"), "host.containers.internal"), None) + assert ctx.bind_roots == (project.resolve(), extra.resolve()) + assert scratch.resolve() not in ctx.bind_roots diff --git a/tools/container-gateway/tests/test_http.py b/tools/container-gateway/tests/test_http.py new file mode 100644 index 000000000..011fbc681 --- /dev/null +++ b/tools/container-gateway/tests/test_http.py @@ -0,0 +1,476 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Framing: heads, bodies, chunked transfer, pumping and piping. + +No ``pytest-asyncio`` in the ``magpie-dev`` dependency group (stdlib + +mypy/pytest/ruff only), so each async scenario is a plain ``def`` test +that drives its coroutine through the ``run()`` helper below instead of +an ``@pytest.mark.asyncio``-marked ``async def``. +""" + +from __future__ import annotations + +import asyncio +import json +import socket +from collections.abc import Coroutine +from typing import Any, TypeVar + +import pytest + +from container_gateway.http import ( + Head, + HttpError, + error_response, + parse_request_line, + pipe, + pump, + read_body, + read_head, +) + +_T = TypeVar("_T") + + +def run(coro: Coroutine[Any, Any, _T]) -> _T: + """Drive a coroutine to completion without pytest-asyncio.""" + return asyncio.run(coro) + + +def reader_of(data: bytes) -> asyncio.StreamReader: + r = asyncio.StreamReader() + r.feed_data(data) + r.feed_eof() + return r + + +class _FakeWriter: + """A minimal duck-typed stand-in for the ``asyncio.StreamWriter`` calls this + module makes (``write``/``drain``/``close``/``can_write_eof``/``write_eof``). + + Used where a test is about ``pump``/``pipe``'s error handling rather than + about genuine byte-for-byte transport behaviour (I6/I8) -- a real socket + would work too but adds setup unrelated to what the test is checking. + """ + + def __init__(self, *, fail: bool = False) -> None: + self.written = bytearray() + self._fail = fail + + def write(self, data: bytes) -> None: + if self._fail: + raise OSError("broken pipe") + self.written += data + + async def drain(self) -> None: + return None + + def close(self) -> None: + return None + + def can_write_eof(self) -> bool: + return False + + def write_eof(self) -> None: + return None + + +async def _socket_pair() -> tuple[ + tuple[asyncio.StreamReader, asyncio.StreamWriter], + tuple[asyncio.StreamReader, asyncio.StreamWriter], +]: + """A real two-endpoint pair of already-connected unix sockets. + + Unlike an in-memory loopback (the original version of this helper), each + side is a genuine independent socket endpoint: writing on one side's + writer is observed -- with real half-close semantics -- only on the + *other* side's reader. That means two independent consumers (e.g. + ``pipe()``'s own opposite-direction copy task and a test reading + directly) can never race for the same shared buffer, which an in-memory + stand-in whose writer fed its own reader could. + + Built from ``socket.socketpair()`` (an anonymous, already-connected pair + with no filesystem path) rather than ``asyncio.start_unix_server`` bound + to a path under ``tmp_path``: the sandbox this suite runs under denies + ``bind()`` on a unix-domain socket path even inside the project/tmp tree, + but a pre-connected pair handed to ``loop.connect_accepted_socket()`` + never calls ``bind()`` or ``connect()`` at all. + """ + loop = asyncio.get_running_loop() + sock_a, sock_b = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + + async def wrap(sock: socket.socket) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + transport, _ = await loop.connect_accepted_socket(lambda: protocol, sock) + writer = asyncio.StreamWriter(transport, protocol, reader, loop) + return reader, writer + + return await wrap(sock_a), await wrap(sock_b) + + +def test_read_head_and_helpers() -> None: + async def scenario() -> None: + raw = ( + b"POST /v1.45/containers/create?name=x HTTP/1.1\r\n" + b"Host: docker\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}" + ) + r = reader_of(raw) + head = await read_head(r) + assert head is not None + assert head.start_line == "POST /v1.45/containers/create?name=x HTTP/1.1" + assert head.get("content-type") == "application/json" + assert head.content_length == 2 and not head.chunked and not head.upgrade + head.set("Content-Length", "4") + head.remove("host") + assert head.encode() == ( + b"POST /v1.45/containers/create?name=x HTTP/1.1\r\n" + b"Content-Type: application/json\r\nContent-Length: 4\r\n\r\n" + ) + assert await r.read() == b"{}" + + run(scenario()) + + +def test_read_head_eof_and_oversize() -> None: + async def scenario() -> None: + assert await read_head(reader_of(b"")) is None + + # M1: `read_head`'s own `limit=` is enforced separately from the + # `asyncio.StreamReader`'s internal buffer limit (default 64 KiB). Give + # the reader a generous internal limit so it is *this* function's + # `len(raw) > limit` check that fires, not `readuntil`'s own overrun. + big_reader = asyncio.StreamReader(limit=4096) + big_reader.feed_data(b"GET / HTTP/1.1\r\n" + b"X: " + b"a" * 300 + b"\r\n\r\n") + big_reader.feed_eof() + with pytest.raises(HttpError): + await read_head(big_reader, limit=100) + + # The StreamReader's own internal limit still surfaces as HttpError too. + with pytest.raises(HttpError): + await read_head(reader_of(b"GET / HTTP/1.1\r\n" + b"X: " + b"a" * 70000 + b"\r\n\r\n")) + + run(scenario()) + + +def test_parse_request_line() -> None: + m, p, q = parse_request_line("GET /v1.45/containers/json?all=1&filters=%7B%7D HTTP/1.1") + assert (m, p) == ("GET", "/v1.45/containers/json") + assert q == {"all": ["1"], "filters": ["{}"]} + with pytest.raises(HttpError): + parse_request_line("nonsense") + + +def test_read_body_content_length_and_chunked() -> None: + async def scenario() -> None: + head = await read_head(reader_of(b"POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\n")) + assert head is not None + assert await read_body(reader_of(b"hello"), head, 100) == b"hello" + chead = await read_head(reader_of(b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n")) + assert chead is not None + assert await read_body(reader_of(b"3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n"), chead, 100) == b"abcde" + with pytest.raises(HttpError): + await read_body(reader_of(b"hello"), head, 2) + + run(scenario()) + + +def test_pump_streams_chunked_verbatim() -> None: + async def scenario() -> None: + head = await read_head(reader_of(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")) + assert head is not None + src = reader_of(b"3\r\nabc\r\n0\r\n\r\n") + (verify_r, verify_w), (_sink_r, sink_w) = await _socket_pair() + await pump(src, sink_w, head) + sink_w.close() + assert await verify_r.read() == b"3\r\nabc\r\n0\r\n\r\n" + verify_w.close() + + run(scenario()) + + +def test_pipe_is_bidirectional() -> None: + async def scenario() -> None: + (peer_a_r, peer_a_w), (pipe_a_r, pipe_a_w) = await _socket_pair() + (peer_b_r, peer_b_w), (pipe_b_r, pipe_b_w) = await _socket_pair() + task = asyncio.create_task(pipe(pipe_a_r, pipe_b_w, pipe_b_r, pipe_a_w)) + + peer_a_w.write(b"ping") + await peer_a_w.drain() + assert await peer_b_r.readexactly(4) == b"ping" + + # I7: half-close the a->b direction; b->a must keep working independently. + peer_a_w.write_eof() + + peer_b_w.write(b"pong!") + await peer_b_w.drain() + assert await peer_a_r.readexactly(5) == b"pong!" + + peer_b_w.write_eof() + await asyncio.wait_for(task, 2) # both directions reached EOF -> normal completion + + peer_a_w.close() + peer_b_w.close() + + run(scenario()) + + +def test_i8_pipe_surfaces_a_copy_failure_as_http_error() -> None: + async def scenario() -> None: + a_r = reader_of(b"boom") + b_r = reader_of(b"") + with pytest.raises(HttpError): + await pipe(a_r, _FakeWriter(fail=True), b_r, _FakeWriter()) # type: ignore[arg-type] + + run(scenario()) + + +def test_error_response_shape() -> None: + raw = error_response(403, "container-gateway: nope") + head, _, body = raw.partition(b"\r\n\r\n") + assert head.startswith(b"HTTP/1.1 403 Forbidden") + assert b"Content-Type: application/json" in head + assert json.loads(body) == {"message": "container-gateway: nope"} + + +def test_c1_control_chars_in_headers_rejected() -> None: + async def scenario() -> None: + # `X: y\nContent-Length: 9` -- a bare LF smuggling a second header past a + # policy that only saw one, forwarded verbatim to the daemon otherwise. + with pytest.raises(HttpError): + await read_head(reader_of(b"GET / HTTP/1.1\r\nX: y\nContent-Length: 9\r\n\r\n")) + with pytest.raises(HttpError): + await read_head(reader_of(b"GET / HTTP/1.1\r\nX: a\n\nb\r\n\r\n")) + with pytest.raises(HttpError): + await read_head(reader_of(b"GET / HTTP/1.1\r\nX: a\x00b\r\n\r\n")) + with pytest.raises(HttpError): + await read_head(reader_of(b"GET / HTTP/1.1\r\nX Y: z\r\n\r\n")) + + run(scenario()) + + +def test_c1_head_set_and_encode_reject_control_chars() -> None: + head = Head("GET / HTTP/1.1", []) + with pytest.raises(HttpError): + head.set("X", "a\r\nb") + with pytest.raises(HttpError): + head.set("X Y", "z") + with pytest.raises(HttpError): + head.set("", "z") + # Bypass `set()`'s own validation to exercise `encode()`'s defence-in-depth check. + head.headers.append(("X", "a\nb")) + with pytest.raises(HttpError): + head.encode() + + +def test_m3_head_set_rejects_non_latin1_values() -> None: + head = Head("GET / HTTP/1.1", []) + with pytest.raises(HttpError): + head.set("X-Test", "中") # CJK codepoint, not representable in latin-1 + + +def test_m2_obs_fold_rejected() -> None: + async def scenario() -> None: + with pytest.raises(HttpError): + await read_head(reader_of(b"GET / HTTP/1.1\r\nX: a\r\n b\r\n\r\n")) + + run(scenario()) + + +def test_c2_read_head_rejects_bad_start_lines() -> None: + async def scenario() -> None: + with pytest.raises(HttpError): + await read_head(reader_of(b"GET /v1.45/containers/\njson HTTP/1.1\r\nHost: x\r\n\r\n")) + with pytest.raises(HttpError): + await read_head(reader_of(b"GET /v1.45/containers/json\tx HTTP/1.1\r\n\r\n")) + with pytest.raises(HttpError): + await read_head(reader_of(b"GET /v1.45/containers/json HTTP/1.1\r\n\r\n")) + with pytest.raises(HttpError): + await read_head(reader_of(b"GET /v1.45/containers/json HTTP/2\r\n\r\n")) + + run(scenario()) + + +def test_c2_parse_request_line_rejects_smuggling_shapes() -> None: + with pytest.raises(HttpError): + parse_request_line("GET /v1.45/containers/\njson HTTP/1.1") + with pytest.raises(HttpError): + parse_request_line("GET /v1.45/containers/json\tx HTTP/1.1") + with pytest.raises(HttpError): + parse_request_line("GET /v1.45/containers/json HTTP/1.1") + with pytest.raises(HttpError): + parse_request_line("GET /v1.45/containers/json HTTP/2") + with pytest.raises(HttpError): + parse_request_line(r"GET /v1.45\..\json HTTP/1.1") + + +def test_c3_duplicate_and_malformed_framing_headers_rejected() -> None: + async def scenario() -> None: + cases = [ + b"GET / HTTP/1.1\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\n", + b"GET / HTTP/1.1\r\nTransfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n\r\n", + b"GET / HTTP/1.1\r\nContent-Length: 5, 5\r\n\r\n", + b"GET / HTTP/1.1\r\nContent-Length: -5\r\n\r\n", + b"GET / HTTP/1.1\r\nContent-Length: +5\r\n\r\n", + b"GET / HTTP/1.1\r\nContent-Length: 0x10\r\n\r\n", + b"GET / HTTP/1.1\r\nContent-Length: \xb2\r\n\r\n", + b"GET / HTTP/1.1\r\nTransfer-Encoding: xchunkedy\r\n\r\n", + b"GET / HTTP/1.1\r\nTransfer-Encoding: chunked, gzip\r\n\r\n", + b"GET / HTTP/1.1\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n", + ] + for raw in cases: + with pytest.raises(HttpError): + await read_head(reader_of(raw)) + + run(scenario()) + + +def test_i3_bad_chunk_size_grammar_rejected() -> None: + async def scenario() -> None: + head = await read_head(reader_of(b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n")) + assert head is not None + bad_bodies = ( + b"0x10\r\naaaaaaaaaaaaaaaa\r\n0\r\n\r\n", + b"1_0\r\na\r\n0\r\n\r\n", + b"-1\r\n0\r\n\r\n", + b"\r\n0\r\n\r\n", + b" 3\r\nabc\r\n0\r\n\r\n", + ) + for bad in bad_bodies: + with pytest.raises(HttpError): + await read_body(reader_of(bad), head, 1000) + + run(scenario()) + + +def test_i4_chunk_size_checked_against_limit_before_reading() -> None: + async def scenario() -> None: + head = await read_head(reader_of(b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n")) + assert head is not None + # Declares far more than `limit` and never supplies that many bytes; if the + # limit were enforced only after `readexactly`, this would raise a truncation + # error instead of the limit error the check-before-read ordering produces. + with pytest.raises(HttpError, match="too large"): + await read_body(reader_of(b"FFFFFF\r\n"), head, 100) + + run(scenario()) + + +def test_i5_bad_chunk_terminator_rejected() -> None: + async def scenario() -> None: + head = await read_head(reader_of(b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n")) + assert head is not None + with pytest.raises(HttpError): + await read_body(reader_of(b"3\r\nabcXX0\r\n\r\n"), head, 100) + + run(scenario()) + + +def test_i6_pump_raises_on_early_eof() -> None: + async def scenario() -> None: + head = await read_head(reader_of(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\n")) + assert head is not None + with pytest.raises(HttpError, match="truncated"): + await pump(reader_of(b"short"), _FakeWriter(), head) # type: ignore[arg-type] + + chead = await read_head(reader_of(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")) + assert chead is not None + with pytest.raises(HttpError): + await pump(reader_of(b"3\r\nab"), _FakeWriter(), chead) # type: ignore[arg-type] + + run(scenario()) + + +def test_all_framing_failures_surface_as_http_error() -> None: + async def scenario() -> None: + head = await read_head(reader_of(b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n")) + assert head is not None + with pytest.raises(HttpError): + await read_body(reader_of(b"zz\r\n"), head, 100) # non-hex chunk size + with pytest.raises(HttpError): + await read_body(reader_of(b"5\r\nab"), head, 100) # early EOF mid-chunk + + run(scenario()) + + +def test_t1_status_lines_with_multiword_reason_parse() -> None: + async def scenario() -> None: + for raw_status in ( + "HTTP/1.1 404 Not Found", + "HTTP/1.1 204 No Content", + "HTTP/1.1 500 Internal Server Error", + "HTTP/1.1 304 Not Modified", + "HTTP/1.1 101 Switching Protocols", + ): + head = await read_head(reader_of(f"{raw_status}\r\n\r\n".encode())) + assert head is not None + assert head.start_line == raw_status + + # A status line with no reason phrase at all is also valid. + head = await read_head(reader_of(b"HTTP/1.1 200\r\n\r\n")) + assert head is not None + assert head.start_line == "HTTP/1.1 200" + + run(scenario()) + + +def test_t1_bad_status_codes_rejected() -> None: + async def scenario() -> None: + with pytest.raises(HttpError): + await read_head(reader_of(b"HTTP/1.1 20 OK\r\n\r\n")) # too few digits + with pytest.raises(HttpError): + await read_head(reader_of(b"HTTP/1.1 6000 X\r\n\r\n")) # not a valid status class + + run(scenario()) + + +def test_t1_request_line_with_space_in_target_still_rejected() -> None: + async def scenario() -> None: + with pytest.raises(HttpError): + await read_head(reader_of(b"GET /a b HTTP/1.1\r\n\r\n")) + + run(scenario()) + + +def test_t1_read_head_round_trips_its_own_error_response() -> None: + async def scenario() -> None: + raw = error_response(500, "x") + head = await read_head(reader_of(raw)) + assert head is not None + assert head.start_line == "HTTP/1.1 500 Internal Server Error" + + run(scenario()) + + +def test_t2_encode_rejects_control_char_in_start_line() -> None: + head = Head("GET /a\nb HTTP/1.1") + with pytest.raises(HttpError): + head.encode() + + +def test_t3_chunk_trailer_wraps_limit_overrun() -> None: + async def scenario() -> None: + head = await read_head(reader_of(b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n")) + assert head is not None + r = asyncio.StreamReader(limit=16) + r.feed_data(b"0\r\n" + b"X-Trailer: " + b"a" * 100 + b"\r\n\r\n") + r.feed_eof() + with pytest.raises(HttpError): + await read_body(r, head, 1000) + + run(scenario()) diff --git a/tools/container-gateway/tests/test_imports.py b/tools/container-gateway/tests/test_imports.py new file mode 100644 index 000000000..47226da5d --- /dev/null +++ b/tools/container-gateway/tests/test_imports.py @@ -0,0 +1,50 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Import-order regression guard: decisions.py -> policy.py -> policy_shape.py, one-way only. + +I6 (review findings round 1) replaced a bottom-of-file circular import +between ``policy.py`` and ``decisions.py`` with a strictly one-way +dependency chain. A fresh subprocess import of either module, on its own +(nothing else in the package imported first), is the only way to catch a +regression back to that circularity: within a single test process, whichever +module a prior test imported first is already cached in ``sys.modules``, so +the failure mode a circular import produces never surfaces there. +""" + +from __future__ import annotations + +import subprocess +import sys + + +def _import_in_fresh_subprocess(module: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", f"import {module}"], + capture_output=True, + text=True, + timeout=30, + ) + + +def test_decisions_module_imports_standalone() -> None: + result = _import_in_fresh_subprocess("container_gateway.decisions") + assert result.returncode == 0, result.stderr + + +def test_policy_module_imports_standalone() -> None: + result = _import_in_fresh_subprocess("container_gateway.policy") + assert result.returncode == 0, result.stderr diff --git a/tools/container-gateway/tests/test_integration.py b/tools/container-gateway/tests/test_integration.py new file mode 100644 index 000000000..0a407a19b --- /dev/null +++ b/tools/container-gateway/tests/test_integration.py @@ -0,0 +1,221 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Against a real backend, when one is present. Skipped otherwise.""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import subprocess +import sys +import tempfile +import time +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from container_gateway import backends, daemon + +pytestmark = pytest.mark.integration +SRC = Path(__file__).resolve().parents[1] / "src" + + +def _exists(p: Path) -> bool: + """``Path.exists`` without raising: a sandboxed session denies ``stat()``. + + on some candidate socket paths (e.g. ``~/.docker/run/docker.sock``) with + ``PermissionError`` rather than reporting "missing", so this test module + must not let that surface as a collection/test error -- it must fall + through to "no backend found" and skip cleanly instead. + """ + try: + return p.exists() + except OSError: + return False + + +def _backend() -> backends.Backend | None: + found = backends.discover( + platform.system(), + os.environ, + backends.default_runner, + _exists, + frozenset({"podman", "docker"}), + ) + return found[0] if found else None + + +def _short_project_dir() -> Path: + """A project root short enough that its gateway socket fits ``sun_path``. + + ``pytest``'s own ``tmp_path`` / ``tmp_path_factory.mktemp`` fixtures nest + under ``pytest-of-/pytest-//`` inside ``$TMPDIR``, + which on a normal macOS ``TMPDIR`` (``/var/folders/<2>/<~30>/T/``) is + already long enough that adding + ``.apache-magpie-local/run/.sock`` (36 bytes) blows the ~103-byte + ``sun_path`` limit. ``tempfile.mkdtemp()`` sits directly under + ``$TMPDIR`` with none of that nesting, exactly like + ``tests/test_daemon.py``'s ``short_run_dir`` fixture, so it stays short + everywhere ``tmp_path`` might not. + """ + return Path(tempfile.mkdtemp(prefix="cg-")) + + +def _check_socket_path_length(project: Path, kind: str) -> Path: + sock = project / ".apache-magpie-local" / "run" / f"{kind}.sock" + length = len(str(sock).encode()) + assert length <= daemon.MAX_SUN_PATH, ( + f"gateway socket path is {length} bytes (limit {daemon.MAX_SUN_PATH}): {sock} " + "-- the project root fixture is not short enough for this host's TMPDIR" + ) + return sock + + +def _stop(proc: subprocess.Popen[bytes]) -> None: + """Terminate a gateway subprocess, escalating to a hard kill if it ignores SIGTERM.""" + proc.terminate() + try: + proc.wait(10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(5) + + +def _wait_for_socket(sock: Path, attempts: int = 50, interval: float = 0.1) -> bool: + for _ in range(attempts): + if sock.exists(): + return True + time.sleep(interval) + return False + + +@pytest.fixture(scope="module") +def gateway() -> Iterator[tuple[Path, str]]: + b = _backend() + cli = shutil.which(b.kind) if b else None + if b is None or cli is None: + pytest.skip("no podman/docker backend on this host") + project = _short_project_dir() + try: + (project / "data").mkdir() + sock = _check_socket_path_length(project, b.kind) + env = {**os.environ, "PYTHONPATH": str(SRC)} + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "container_gateway", + "serve", + "--project", + str(project), + "--egress", + "off", + ], + env=env, + ) + try: + if not _wait_for_socket(sock): + pytest.fail("gateway did not come up") + except BaseException: + _stop(proc) + raise + yield project, cli + _stop(proc) + finally: + shutil.rmtree(project, ignore_errors=True) + + +def _run(cli: str, project: Path, *args: str) -> subprocess.CompletedProcess[str]: + kind = Path(cli).name + var = "CONTAINER_HOST" if kind == "podman" else "DOCKER_HOST" + sock = project / ".apache-magpie-local" / "run" / f"{kind}.sock" + return subprocess.run( + [cli, *args], capture_output=True, text=True, env={**os.environ, var: f"unix://{sock}"}, check=False + ) + + +def test_run_with_project_bind_mount(gateway: tuple[Path, str]) -> None: + project, cli = gateway + done = _run( + cli, + project, + "run", + "--rm", + "-v", + f"{project / 'data'}:/data", + "docker.io/library/alpine:3", + "sh", + "-c", + "echo hi > /data/out", + ) + assert done.returncode == 0, done.stderr + assert (project / "data" / "out").read_text() == "hi\n" + + +def test_home_bind_mount_is_refused(gateway: tuple[Path, str]) -> None: + project, cli = gateway + done = _run(cli, project, "run", "--rm", "-v", f"{Path.home()}:/h", "docker.io/library/alpine:3", "true") + assert done.returncode != 0 + assert "container-gateway: bind-mount" in done.stderr + + +def test_privileged_is_refused(gateway: tuple[Path, str]) -> None: + project, cli = gateway + done = _run(cli, project, "run", "--rm", "--privileged", "docker.io/library/alpine:3", "true") + assert "container-gateway: privileged" in done.stderr + + +def test_other_project_is_invisible(gateway: tuple[Path, str]) -> None: + project, cli = gateway + _run(cli, project, "run", "-d", "--name", "gw-it-sleeper", "docker.io/library/alpine:3", "sleep", "30") + try: + mine = json.loads(_run(cli, project, "ps", "-a", "--format", "json").stdout or "[]") + assert any("gw-it-sleeper" in json.dumps(c) for c in (mine if isinstance(mine, list) else [mine])) + other = _short_project_dir() + try: + other_kind = Path(cli).name + other_sock = _check_socket_path_length(other, other_kind) + env = {**os.environ, "PYTHONPATH": str(SRC)} + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "container_gateway", + "serve", + "--project", + str(other), + "--egress", + "off", + ], + env=env, + ) + try: + assert _wait_for_socket(other_sock), "second gateway did not come up" + theirs = _run(cli, other, "ps", "-a", "--format", "json").stdout + assert "gw-it-sleeper" not in theirs + denied = _run(cli, other, "rm", "-f", "gw-it-sleeper") + assert "label-check" in denied.stderr + finally: + _stop(proc) + finally: + shutil.rmtree(other, ignore_errors=True) + finally: + _run(cli, project, "rm", "-f", "gw-it-sleeper") diff --git a/tools/container-gateway/tests/test_labels.py b/tools/container-gateway/tests/test_labels.py new file mode 100644 index 000000000..3d663fe1c --- /dev/null +++ b/tools/container-gateway/tests/test_labels.py @@ -0,0 +1,87 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Project identity: the slug, the label, and filter merging.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from container_gateway.labels import ( + LABEL_KEY, + has_label, + label_filter_value, + merge_filters, + project_slug, + with_label, +) + + +def test_slug_is_resolved_path_with_dashes(tmp_path: Path) -> None: + root = tmp_path / "proj" + root.mkdir() + link = tmp_path / "link" + link.symlink_to(root) + assert project_slug(link) == str(root.resolve()).replace("/", "-") + assert project_slug(root).startswith("-") + + +def test_with_label_overrides_client_value() -> None: + out = with_label({"a": "b", LABEL_KEY: "spoofed"}, "-x") + assert out == {"a": "b", LABEL_KEY: "-x"} + assert with_label(None, "-x") == {LABEL_KEY: "-x"} + + +def test_has_label() -> None: + assert has_label({LABEL_KEY: "-x"}, "-x") + assert not has_label({LABEL_KEY: "-y"}, "-x") + assert not has_label(None, "-x") + assert not has_label({}, "-x") + + +def test_label_filter_value() -> None: + assert label_filter_value("-x") == "org.apache.magpie.project=-x" + + +@pytest.mark.parametrize( + ("raw", "expected_labels"), + [ + (None, ["org.apache.magpie.project=-x"]), + ("", ["org.apache.magpie.project=-x"]), + ('{"status":["running"]}', ["org.apache.magpie.project=-x"]), + ('{"label":["foo=bar"]}', ["foo=bar", "org.apache.magpie.project=-x"]), + # docker's older map-of-maps form + ('{"label":{"foo=bar":true}}', ["foo=bar", "org.apache.magpie.project=-x"]), + # a client trying to widen to another project is narrowed, not merged away + ( + '{"label":["org.apache.magpie.project=-other"]}', + ["org.apache.magpie.project=-other", "org.apache.magpie.project=-x"], + ), + ], +) +def test_merge_filters(raw: str | None, expected_labels: list[str]) -> None: + merged = json.loads(merge_filters(raw, "-x")) + assert sorted(merged["label"]) == sorted(expected_labels) + if raw and "status" in raw: + assert merged["status"] == ["running"] + + +def test_merge_filters_rejects_garbage() -> None: + with pytest.raises(ValueError): + merge_filters("{not json", "-x") diff --git a/tools/container-gateway/tests/test_package.py b/tools/container-gateway/tests/test_package.py new file mode 100644 index 000000000..7a34d29cb --- /dev/null +++ b/tools/container-gateway/tests/test_package.py @@ -0,0 +1,25 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The package imports and carries a version; nothing else yet.""" + +from __future__ import annotations + +import container_gateway + + +def test_version() -> None: + assert container_gateway.__version__ == "0.1.0" diff --git a/tools/container-gateway/tests/test_policy_build.py b/tools/container-gateway/tests/test_policy_build.py new file mode 100644 index 000000000..4603e450c --- /dev/null +++ b/tools/container-gateway/tests/test_policy_build.py @@ -0,0 +1,262 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""The build query is an allow-list, and a build gets the egress proxy (C2).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from container_gateway.decisions import Allow, Request, decide +from container_gateway.labels import LABEL_KEY +from container_gateway.policy import Deny, PolicyContext + + +@pytest.fixture +def ctx(tmp_path: Path) -> PolicyContext: + return PolicyContext("-p", tmp_path, (tmp_path,), None, "off") + + +@pytest.fixture +def proxied(tmp_path: Path) -> PolicyContext: + return PolicyContext( + "-p", + tmp_path, + (tmp_path,), + {"HTTP_PROXY": "http://host.containers.internal:8899"}, + "inject-if-available", + ) + + +def build(query: dict[str, list[str]], path: str = "/v1.45/build") -> Request: + return Request("POST", path, query, {}, None) + + +@pytest.mark.parametrize( + "query", + [ + {"networkmode": ["host"]}, + {"volume": ["/:/host"]}, + {"volumes": ["/Users:/host"]}, + {"remote": ["https://evil.example/ctx.tar"]}, + {"extrahosts": ['["h:1.2.3.4"]']}, + {"addhost": ['["h:1.2.3.4"]']}, + {"securityopt": ['["label=disable"]']}, + {"labelopts": ['["disable"]']}, + {"seccomp": ["/tmp/allow-all.json"]}, + {"apparmor": ["unconfined"]}, + {"unmask": ["ALL"]}, + {"addcaps": ['["SYS_ADMIN"]']}, + {"cgroupparent": ["/x"]}, + {"ulimits": ['["nofile=1024"]']}, + {"devices": ["/dev/kvm"]}, + {"device": ["/dev/kvm"]}, + {"unsetenv": ['["PATH"]']}, + {"secrets": ['["id=s,src=/etc/passwd"]']}, + {"ssh": ["default"]}, + {"session": ["abc"]}, + {"sessionid": ["abc"]}, + {"runtime": ["/tmp/evil"]}, + # The host namespace rides in libpod's nsoptions, not in networkmode. + {"nsoptions": ['[{"Name":"network","Host":true,"Path":""}]']}, + {"nsoptions": ['[{"Name":"pid","Host":true}]']}, + {"nsoptions": ['[{"Name":"user","Host":false,"Path":"/proc/1/ns/user"}]']}, + # An output that names a filesystem destination. + {"output": ["type=local,dest=/Users/me"]}, + {"outputs": ['[{"Type":"local","Attrs":{"dest=/Users/me":""}}]']}, + # Anything the gateway has not learned. + {"frobnicate": ["1"]}, + ], +) +def test_denied_build_parameters(ctx: PolicyContext, query: dict[str, list[str]]) -> None: + d = decide(build(query), ctx) + assert isinstance(d, Deny), query + assert d.reason.startswith(("denied-build-parameter", "network")), d.reason + + +def test_a_real_docker_build_query_passes(ctx: PolicyContext) -> None: + # What the docker CLI's classic builder sends: every parameter present, + # most of them empty, including ones the table above refuses when set. + query = { + "t": ["img:1"], + "buildargs": ["{}"], + "cachefrom": ["[]"], + "cgroupparent": [""], + "cpuperiod": ["0"], + "cpuquota": ["0"], + "cpusetcpus": [""], + "cpusetmems": [""], + "cpushares": ["0"], + "dockerfile": ["Dockerfile"], + "labels": ["{}"], + "memory": ["0"], + "memswap": ["0"], + "networkmode": [""], + "rm": ["1"], + "shmsize": ["0"], + "target": [""], + "ulimits": ["null"], + "version": ["1"], + } + a = decide(build(query), ctx) + assert isinstance(a, Allow) + assert json.loads(a.request.query["labels"][0]) == {LABEL_KEY: "-p"} + + +def test_a_real_podman_build_query_passes(ctx: PolicyContext) -> None: + # Captured from podman 6.1's remote client on `podman build -t x .`. + query = { + "buildargs": ['{"A":"1"}'], + "compressionFormat": ["gzip"], + "dockerfile": ['["Containerfile"]'], + "forceCompressionFormat": ["1"], + "forcerm": ["1"], + "httpproxy": ["1"], + "idmappingoptions": ['{"HostUIDMapping":true}'], + "inheritannotations": ["1"], + "isolation": ["0"], + "jobs": ["1"], + "layers": ["1"], + "networkmode": ["0"], + "nsoptions": ['[{"Name":"user","Host":true,"Path":""}]'], + "omithistory": ["0"], + "output": ["x"], + "outputformat": ["application/vnd.oci.image.manifest.v1+json"], + "pullpolicy": ["missing"], + "retry": ["3"], + "retry-delay": ["2s"], + "rewritetimestamp": ["0"], + "rm": ["1"], + "shmsize": ["67108864"], + "t": ["x"], + } + a = decide(build(dict(query), "/v5.2.0/libpod/build"), ctx) + assert isinstance(a, Allow) + # libpod takes `labels` as a JSON array of k=v strings, not an object. + assert json.loads(a.request.query["labels"][0]) == [f"{LABEL_KEY}=-p"] + + +def test_build_networkmode_keywords_and_buildah_integers_pass(ctx: PolicyContext) -> None: + for value in ("", "default", "bridge", "none", "0", "1", "2"): + a = decide(build({"networkmode": [value]}), ctx) + assert isinstance(a, Allow), value + + +def test_build_proxy_goes_into_buildargs(proxied: PolicyContext) -> None: + a = decide(build({"buildargs": ['{"A":"1","HTTP_PROXY":"http://evil:1"}'], "httpproxy": ["1"]}), proxied) + assert isinstance(a, Allow) + args = json.loads(a.request.query["buildargs"][0]) + assert args == {"A": "1", "HTTP_PROXY": "http://host.containers.internal:8899"} + # podman's own "let the daemon add its proxy variables" is turned off. + assert a.request.query["httpproxy"] == ["0"] + + +def test_build_proxy_is_added_when_the_client_sent_no_buildargs(proxied: PolicyContext) -> None: + a = decide(build({"t": ["x"]}), proxied) + assert isinstance(a, Allow) + assert json.loads(a.request.query["buildargs"][0]) == { + "HTTP_PROXY": "http://host.containers.internal:8899" + } + + +def test_build_proxy_is_not_added_when_egress_is_off(ctx: PolicyContext) -> None: + a = decide(build({"t": ["x"]}), ctx) + assert isinstance(a, Allow) + assert "buildargs" not in a.request.query + + +@pytest.mark.parametrize( + "query", + [ + {"buildargs": ["notjson"]}, + {"buildargs": ["[1,2]"]}, + {"nsoptions": ["notjson"]}, + {"nsoptions": ["{}"]}, + ], +) +def test_malformed_build_values_deny_instead_of_raising( + proxied: PolicyContext, query: dict[str, list[str]] +) -> None: + d = decide(build(query), proxied) + assert isinstance(d, Deny) and d.reason.startswith("malformed"), d + + +# --- Polish round: the output check covers every spelling (P3), buildid (P4) --- + + +@pytest.mark.parametrize( + "query", + [ + # P3: buildkit's JSON array names the destination in Attrs, which the + # old literal `dest=` substring check never saw. + {"outputs": ['[{"Type":"local","Attrs":{"dest":"/Users/me"}}]']}, + {"output": ['[{"Type":"tar","Attrs":{"dest":"/Users/me/out.tar"}}]']}, + # P3: a bare path -- podman's `-o `. + {"output": ["/Users/me/out"]}, + {"output": ["./out"]}, + {"output": ["~/out"]}, + {"output": ["-"]}, + # The comma form, with and without an explicit destination. + {"output": ["type=local,dest=/Users/me"]}, + {"output": ["type=tar"]}, + {"output": ["type=image,dest=/Users/me"]}, + # Anything that does not parse as one of the two safe shapes. + {"outputs": ["[{"]}, + {"outputs": ['["local"]']}, + # A filesystem type is refused regardless of other attributes, and a + # `dest`/`output` attribute is refused regardless of type, including + # with no `type` given at all. + {"output": ["type=local,dest=/x"]}, + {"output": ["type=tar,dest=/x"]}, + {"output": ["dest=/x"]}, + {"outputs": ['[{"Type":"local","Attrs":{"dest":"/x"}}]']}, + {"outputs": ['[{"Type":"tar","Attrs":{"dest":"/x"}}]']}, + {"outputs": ['[{"Attrs":{"dest":"/x"}}]']}, + ], +) +def test_denied_build_outputs(ctx: PolicyContext, query: dict[str, list[str]]) -> None: + d = decide(build(query), ctx) + assert isinstance(d, Deny), query + assert d.reason.startswith("denied-build-parameter"), d.reason + + +@pytest.mark.parametrize( + "query", + [ + # podman puts the image name in `output` on every `podman build -t`. + {"output": ["x"]}, + {"output": ["quay.io/me/img:1"]}, + {"output": [""]}, + {"output": ["type=image"]}, + {"outputs": ['[{"Type":"registry"}]']}, + # P4: the classic builder's inert build id. + {"buildid": ["abc123"]}, + # A safe type stays allowed alongside any non-destination attribute + # -- a normal `buildx --output type=image,name=x` invocation. + {"output": ["type=image,name=x"]}, + {"output": ["type=image,push=true"]}, + {"output": ["type=registry,name=x"]}, + {"outputs": ['[{"Type":"image","Attrs":{"name":"x"}}]']}, + {"outputs": ['[{"Type":"image","Attrs":{"push":"true"}}]']}, + {"outputs": ['[{"Type":"registry","Attrs":{"name":"x"}}]']}, + ], +) +def test_allowed_build_outputs_and_buildid(ctx: PolicyContext, query: dict[str, list[str]]) -> None: + a = decide(build(query), ctx) + assert isinstance(a, Allow), query diff --git a/tools/container-gateway/tests/test_policy_create.py b/tools/container-gateway/tests/test_policy_create.py new file mode 100644 index 000000000..7adfc0060 --- /dev/null +++ b/tools/container-gateway/tests/test_policy_create.py @@ -0,0 +1,917 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Every row of the spec's create-time table, in compat and libpod shape.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from container_gateway.labels import LABEL_KEY +from container_gateway.policy import ( + Deny, + PolicyContext, + apply_create_rewrites, + check_create, + named_networks, + named_volumes, +) + + +@pytest.fixture +def ctx(tmp_path: Path) -> PolicyContext: + root = tmp_path / "proj" + root.mkdir() + scratch = tmp_path / "scratch" + scratch.mkdir() + return PolicyContext( + slug="-proj", + project_root=root, + bind_roots=(root, scratch), + proxy_env={"HTTP_PROXY": "http://host.containers.internal:8899"}, + egress_mode="inject-if-available", + ) + + +def compat(**host: Any) -> dict[str, Any]: + return {"Image": "alpine", "HostConfig": host} + + +def libpod(**top: Any) -> dict[str, Any]: + return {"image": "alpine", **top} + + +def compat_with_endpoints(endpoints: dict[str, Any]) -> dict[str, Any]: + body = compat() + body["NetworkingConfig"] = {"EndpointsConfig": endpoints} + return body + + +@pytest.mark.parametrize( + ("body", "rule"), + [ + (compat(Privileged=True), "privileged"), + (libpod(privileged=True), "privileged"), + (compat(CapAdd=["SYS_ADMIN"]), "cap-add"), + (libpod(cap_add=["NET_RAW"]), "cap-add"), + (compat(Devices=[{"PathOnHost": "/dev/kvm"}]), "devices"), + (compat(DeviceRequests=[{"Driver": "nvidia"}]), "devices"), + (compat(DeviceCgroupRules=["c 1:3 rwm"]), "devices"), + (libpod(devices=["/dev/kvm"]), "devices"), + (compat(PidMode="host"), "namespace"), + (compat(IpcMode="host"), "namespace"), + (compat(UTSMode="host"), "namespace"), + (compat(UsernsMode="host"), "namespace"), + (compat(CgroupnsMode="host"), "namespace"), + (compat(PidMode="container:deadbeef"), "namespace"), + (libpod(pidns={"nsmode": "host"}), "namespace"), + (libpod(userns={"nsmode": "host"}), "namespace"), + (compat(NetworkMode="host"), "network"), + (compat(NetworkMode="container:deadbeef"), "network"), + (libpod(netns={"nsmode": "host"}), "network"), + (compat(SecurityOpt=["seccomp=unconfined"]), "security-opt"), + (compat(SecurityOpt=["apparmor=unconfined"]), "security-opt"), + (compat(SecurityOpt=["label=disable"]), "security-opt"), + (compat(SecurityOpt=["no-new-privileges=false"]), "security-opt"), + (compat(SecurityOpt=["systempaths=unconfined"]), "security-opt"), + (compat(Sysctls={"net.ipv4.ip_forward": "1"}), "sysctls"), + (compat(CgroupParent="/x"), "cgroup-parent"), + (compat(Runtime="nvidia"), "runtime"), + (compat(Isolation="hyperv"), "isolation"), + (compat(MaskedPaths=[]), "masked-paths"), + (compat(ReadonlyPaths=[]), "readonly-paths"), + (compat(Binds=["/etc:/etc:ro"]), "bind-mount"), + (compat(Binds=["/Users/alice/.ssh:/root/.ssh"]), "bind-mount"), + (compat(Mounts=[{"Type": "bind", "Source": "/", "Target": "/host"}]), "bind-mount"), + (libpod(mounts=[{"type": "bind", "source": "/etc", "destination": "/etc"}]), "bind-mount"), + (compat(VolumesFrom=["other"]), "volumes-from"), + # --- Fix round 1 additions below --- + # I3: namespace/network allow-list, not two literals + (compat(PidMode="ns:/proc/1/ns/pid"), "namespace"), + (libpod(pidns={"nsmode": "path"}), "namespace"), + (compat(NetworkMode="ns:/proc/1/ns/net"), "network"), + # I4: libpod spellings of the SecurityOpt / devices / masked-paths rules + (libpod(selinux_opts=["disable"]), "security-opt"), + (libpod(apparmor_profile="unconfined"), "security-opt"), + (libpod(seccomp_policy="unsafe"), "security-opt"), + (libpod(seccomp_profile_path="/tmp/x.json"), "security-opt"), + (libpod(unmask=["ALL"]), "security-opt"), + (libpod(device_cgroup_rule=["c 1:3 rwm"]), "devices"), + (libpod(mask=[]), "masked-paths"), + # I5: SecurityOpt parsing - both separators, per-key allow-list + (compat(SecurityOpt=["seccomp:unconfined"]), "security-opt"), + (compat(SecurityOpt=["seccomp=/tmp/allow-all.json"]), "security-opt"), + (compat(SecurityOpt=["label:disable"]), "security-opt"), + (compat(SecurityOpt=["unmask=all"]), "security-opt"), + # I6: PublishAllPorts sidesteps the loopback rewrite + (compat(PublishAllPorts=True), "publish-all"), + (libpod(publish_image_ports=True), "publish-all"), + # M1: type guards never raise, they deny + ({"Image": "alpine", "HostConfig": "x"}, "malformed"), + (compat(Mounts=["x"]), "malformed"), + (compat(Binds="not-a-list"), "malformed"), + (compat(SecurityOpt="not-a-list"), "malformed"), + (compat(CapAdd="not-a-list"), "malformed"), + (libpod(cap_add="not-a-list"), "malformed"), + # M2: a Binds source with a path separator is a path, checked and denied + (compat(Binds=["a/../../../../etc:/etc"]), "bind-mount"), + # M3: MaskedPaths / ReadonlyPaths - any explicit value is denied, not just empty + (compat(MaskedPaths=["/proc/foo"]), "masked-paths"), + (compat(ReadonlyPaths=["/proc/foo"]), "readonly-paths"), + # C2: mount entries with absent or non-canonical Type skip the bind check + (compat(Mounts=[{"Source": "/", "Target": "/h"}]), "mount-type"), + (libpod(mounts=[{"source": "/etc", "destination": "/etc"}]), "mount-type"), + (compat(Mounts=[{"Type": "devpts", "Source": "/dev/pts", "Target": "/dev/pts"}]), "mount-type"), + # --- Fix round 2 additions below --- + # R2: libpod bare-word netns modes escape the prefix list; exact allow-list now + (libpod(netns={"nsmode": "container", "value": "deadbeef"}), "network"), + (libpod(netns={"nsmode": "ns", "value": "/proc/1/ns/net"}), "network"), + (libpod(netns={"nsmode": "from-container", "value": "deadbeef"}), "network"), + (libpod(netns={"nsmode": "from-pod"}), "network"), + # --- Fix round 3 additions below --- + # S1: the `_nsmode` malformed sentinel was not fail-closed for the network rule + (libpod(netns={"nsmode": "host", "extra": 1}), "network"), + (libpod(netns={"nsmode": "container", "value": "x", "extra": 1}), "network"), + (libpod(netns={"nsmode": "host", "Extra": 1}), "network"), + (compat(NetworkMode=""), "network"), + # S2: `host`/`none` attached through the endpoint maps + (compat_with_endpoints({"host": {}}), "network"), + (compat_with_endpoints({"HOST": {}}), "network"), + (compat_with_endpoints({"none": {}}), "network"), + (libpod(networks={"host": {}}), "network"), + (libpod(networks={"HOST": {}}), "network"), + (libpod(networks={"none": {}}), "network"), + ], +) +def test_denied_shapes(ctx: PolicyContext, body: dict[str, Any], rule: str) -> None: + d = check_create(body, ctx, libpod="HostConfig" not in body) + assert isinstance(d, Deny), body + assert d.reason.startswith(rule), d.reason + assert d.message.startswith("container-gateway: ") + assert "sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error" in d.message + + +def test_bind_under_project_root_is_allowed(ctx: PolicyContext) -> None: + src = ctx.project_root / "data" + src.mkdir() + assert check_create(compat(Binds=[f"{src}:/data"]), ctx, libpod=False) is None + assert ( + check_create(compat(Mounts=[{"Type": "bind", "Source": str(src), "Target": "/d"}]), ctx, libpod=False) + is None + ) + assert ( + check_create( + libpod(mounts=[{"type": "bind", "source": str(src), "destination": "/d"}]), ctx, libpod=True + ) + is None + ) + + +def test_bind_symlink_escaping_root_is_denied(ctx: PolicyContext, tmp_path: Path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + link = ctx.project_root / "escape" + link.symlink_to(outside) + d = check_create(compat(Binds=[f"{link}:/x"]), ctx, libpod=False) + assert isinstance(d, Deny) and d.reason.startswith("bind-mount") + + +def test_tmpfs_and_capdrop_and_named_volume_pass(ctx: PolicyContext) -> None: + body = compat(CapDrop=["ALL"], Tmpfs={"/run": "rw"}, Mounts=[{"Type": "tmpfs", "Target": "/t"}]) + assert check_create(body, ctx, libpod=False) is None + # Named volumes are label-checked by the relay (needs the backend), not here. + assert check_create(compat(Binds=["myvol:/data"]), ctx, libpod=False) is None + + +def test_rewrites_label_hostip_and_proxy(ctx: PolicyContext) -> None: + body = compat(PortBindings={"80/tcp": [{"HostPort": "8080"}]}) + body["Env"] = ["FOO=1", "HTTP_PROXY=http://evil:1"] + out = apply_create_rewrites(body, ctx, libpod=False) + assert out["Labels"][LABEL_KEY] == "-proj" + assert out["HostConfig"]["PortBindings"]["80/tcp"][0]["HostIp"] == "127.0.0.1" + assert "HTTP_PROXY=http://host.containers.internal:8899" in out["Env"] + assert "HTTP_PROXY=http://evil:1" not in out["Env"] + assert "FOO=1" in out["Env"] + assert body.get("Labels") is None, "input must not be mutated" + + +def test_rewrites_libpod_shape(ctx: PolicyContext) -> None: + body = libpod(portmappings=[{"container_port": 80, "host_port": 8080}], env={"HTTP_PROXY": "x"}) + out = apply_create_rewrites(body, ctx, libpod=True) + assert out["labels"][LABEL_KEY] == "-proj" + assert out["portmappings"][0]["host_ip"] == "127.0.0.1" + assert out["env"]["HTTP_PROXY"] == "http://host.containers.internal:8899" + + +def test_explicit_hostip_is_kept(ctx: PolicyContext) -> None: + body = compat(PortBindings={"80/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8080"}]}) + out = apply_create_rewrites(body, ctx, libpod=False) + assert out["HostConfig"]["PortBindings"]["80/tcp"][0]["HostIp"] == "0.0.0.0" + + +def test_egress_require_without_proxy_denies(ctx: PolicyContext) -> None: + strict = PolicyContext(ctx.slug, ctx.project_root, ctx.bind_roots, None, "require") + d = check_create(compat(), strict, libpod=False) + assert isinstance(d, Deny) and d.reason.startswith("egress-required") + + +def test_egress_off_injects_nothing(ctx: PolicyContext) -> None: + off = PolicyContext(ctx.slug, ctx.project_root, ctx.bind_roots, None, "off") + out = apply_create_rewrites(compat(), off, libpod=False) + assert "Env" not in out or not any(e.startswith("HTTP_PROXY=") for e in out["Env"]) + + +# --- Fix round 1 additions below --- + + +@pytest.mark.parametrize( + ("body", "libpod"), + [ + # C1: a case-variant top-level key hides the real HostConfig from _host() + ({"Image": "alpine", "hostconfig": {"Privileged": True}}, False), + # C1: a case-variant HostConfig key hides Privileged from the check + (compat(PRIVILEGED=True), False), + # C1: Labels/labels both present - the daemon could take either + ({"Image": "alpine", "HostConfig": {}, "Labels": {"a": "b"}, "labels": {"a": "b"}}, False), + # C1: Env/env both present (Env must be a list, env must be a dict - each + # individually well-typed, so only the spelling collision denies this) + ({"Image": "alpine", "HostConfig": {}, "Env": ["A=1"], "env": {"A": "1"}}, False), + # C1: libpod duplicate labels + ({"image": "alpine", "labels": {"a": "b"}, "Labels": {"a": "b"}}, True), + # C1: libpod duplicate env (Env list / env dict, each individually well-typed) + ({"image": "alpine", "env": {"A": "1"}, "Env": ["A=1"]}, True), + # C1: libpod case-variant top-level key + ({"image": "alpine", "Privileged": True}, True), + # C1/C2: type and Type collide inside one Mounts entry + ( + { + "Image": "alpine", + "HostConfig": {"Mounts": [{"type": None, "Type": "bind", "Source": "/", "Target": "/h"}]}, + }, + False, + ), + # R1: the six libpod namespace objects were outside the spelling check + (libpod(pidns={"NSMode": "host"}), True), + (libpod(userns={"NSMode": "host"}), True), + (libpod(netns={"NSMode": "host"}), True), + (libpod(pidns={"nsmode": "private", "Value": "x"}), True), + ], +) +def test_canonical_spelling_violation_is_denied( + ctx: PolicyContext, body: dict[str, Any], libpod: bool +) -> None: + d = check_create(body, ctx, libpod=libpod) + assert isinstance(d, Deny), body + assert d.reason.startswith("ambiguous-field"), d.reason + + +def test_security_opt_allowed_values_pass(ctx: PolicyContext) -> None: + assert check_create(compat(SecurityOpt=["apparmor=docker-default"]), ctx, libpod=False) is None + assert check_create(compat(SecurityOpt=["no-new-privileges"]), ctx, libpod=False) is None + + +def test_named_network_is_allowed_and_deferred_to_relay(ctx: PolicyContext) -> None: + # A named network is not one of the fixed keywords and does not start with + # host / container / ns / path / from-, so check_create allows it; the + # relay label-checks the network by name (Task 9). + assert check_create(compat(NetworkMode="mynet"), ctx, libpod=False) is None + assert named_networks(compat(NetworkMode="mynet"), False) == ["mynet"] + assert named_networks(compat(NetworkMode="host"), False) == [] + assert named_networks(compat(NetworkMode="container:deadbeef"), False) == [] + assert check_create(libpod(netns={"nsmode": "bridge"}), ctx, libpod=True) is None + assert named_networks(libpod(netns={"nsmode": "bridge"}), True) == [] + + +def test_named_volumes_helper(ctx: PolicyContext) -> None: + assert named_volumes(compat(Binds=["myvol:/data"]), False) == ["myvol"] + assert named_volumes(compat(Mounts=[{"Type": "volume", "Source": "myvol2", "Target": "/d"}]), False) == [ + "myvol2" + ] + assert named_volumes( + libpod(mounts=[{"type": "volume", "source": "myvol3", "destination": "/d"}]), True + ) == ["myvol3"] + # An anonymous volume has no name for the relay to label-check. + assert named_volumes(compat(Mounts=[{"Type": "volume", "Target": "/d"}]), False) == [] + assert named_volumes(libpod(mounts=[{"type": "volume", "destination": "/d"}]), True) == [] + + +def test_proxy_env_filtered_case_insensitively(ctx: PolicyContext) -> None: + body = compat() + body["Env"] = ["Http_Proxy=http://evil:1", "FOO=1"] + out = apply_create_rewrites(body, ctx, libpod=False) + assert "Http_Proxy=http://evil:1" not in out["Env"] + assert "HTTP_PROXY=http://host.containers.internal:8899" in out["Env"] + assert "FOO=1" in out["Env"] + + +# --- Fix round 2 additions below --- + + +def test_masked_and_readonly_paths_null_passes(ctx: PolicyContext) -> None: + # moby's HostConfig serialises MaskedPaths/ReadonlyPaths: null on every + # create (no omitempty); the gateway must not refuse an ordinary create. + body = compat(MaskedPaths=None, ReadonlyPaths=None) + assert check_create(body, ctx, libpod=False) is None + + +def test_mask_and_unmask_null_passes(ctx: PolicyContext) -> None: + assert check_create(libpod(mask=None, unmask=None), ctx, libpod=True) is None + + +def test_named_volumes_includes_libpod_top_level_volumes(ctx: PolicyContext) -> None: + body = libpod(volumes=[{"Name": "myvol4", "Dest": "/d"}]) + assert check_create(body, ctx, libpod=True) is None + assert named_volumes(body, True) == ["myvol4"] + + +def test_named_networks_from_endpoints_config_and_libpod_networks(ctx: PolicyContext) -> None: + compat_body = compat() + compat_body["NetworkingConfig"] = {"EndpointsConfig": {"other-net": {}}} + assert check_create(compat_body, ctx, libpod=False) is None + assert named_networks(compat_body, False) == ["other-net"] + + libpod_body = libpod(networks={"n1": {}}) + assert check_create(libpod_body, ctx, libpod=True) is None + assert named_networks(libpod_body, True) == ["n1"] + + +def test_named_networks_excludes_builtin_names(ctx: PolicyContext) -> None: + compat_body = compat() + compat_body["NetworkingConfig"] = {"EndpointsConfig": {"bridge": {}, "podman": {}}} + assert named_networks(compat_body, False) == [] + assert named_networks(compat(NetworkMode="host"), False) == [] + assert named_networks(compat(NetworkMode="none"), False) == [] + + +@pytest.mark.parametrize( + ("body", "libpod"), + [ + # R3: TypeError in policy_shape.py before _malformed_shape ran + (compat(Mounts=5), False), + (libpod(mounts=5), True), + (libpod(portmappings=5), True), + (compat(PortBindings={"80/tcp": 5}), False), + # R3: type-guarded in one shape only - the "wrong" shape's spelling slipped through + (libpod(SecurityOpt=5), True), + (compat(selinux_opts=5), False), + # R3: a non-dict body (AttributeError) + ("not-a-dict", False), + # R3: apply_create_rewrites crashes downstream if check_create does not catch these first + (libpod(env=["A=1"]), True), + ({"Image": "alpine", "Labels": ["a"], "HostConfig": {}}, False), + ], +) +def test_malformed_bodies_never_raise(ctx: PolicyContext, body: Any, libpod: bool) -> None: + result = check_create(body, ctx, libpod=libpod) + assert isinstance(result, Deny), body + + +@pytest.mark.parametrize( + ("body", "libpod"), + [ + (compat(), False), + (compat(CapDrop=["ALL"], Tmpfs={"/run": "rw"}, Mounts=[{"Type": "tmpfs", "Target": "/t"}]), False), + (compat(Binds=["myvol:/data"]), False), + (compat(SecurityOpt=["apparmor=docker-default"]), False), + (compat(NetworkMode="mynet"), False), + (libpod(netns={"nsmode": "bridge"}), True), + (libpod(portmappings=[{"container_port": 80, "host_port": 8080}], env={"HTTP_PROXY": "x"}), True), + # S3: `"HostConfig": null` is a request check_create allows (see + # _malformed_shape) but the old `out.setdefault("HostConfig", {})` + # left it as None and crashed on `.get()`. + ({"Image": "alpine", "HostConfig": None}, False), + ], +) +def test_apply_create_rewrites_succeeds_on_every_allowed_body( + ctx: PolicyContext, body: dict[str, Any], libpod: bool +) -> None: + assert check_create(body, ctx, libpod=libpod) is None + apply_create_rewrites(body, ctx, libpod=libpod) # must not raise + + +# --- Fix round 3 additions below --- + + +def test_ipcns_shareable_is_allowed(ctx: PolicyContext) -> None: + # podman's containers.conf default for the IPC namespace: private with + # opt-in sharing, not host - must not be denied. + assert check_create(libpod(ipcns={"nsmode": "shareable"}), ctx, libpod=True) is None + d = check_create(libpod(ipcns={"nsmode": "host"}), ctx, libpod=True) + assert isinstance(d, Deny) and d.reason.startswith("namespace") + + +def test_endpoints_config_and_networks_still_allow_real_names(ctx: PolicyContext) -> None: + # S2 must not regress the R6 "a real named network is allowed and + # returned" behaviour while closing the host/none escape. + assert check_create(compat_with_endpoints({"mynet": {}}), ctx, libpod=False) is None + assert named_networks(compat_with_endpoints({"mynet": {}}), False) == ["mynet"] + assert check_create(libpod(networks={"mynet": {}}), ctx, libpod=True) is None + assert named_networks(libpod(networks={"mynet": {}}), True) == ["mynet"] + + +# --- Fix round 4 additions below (Task 6 addendum carry-overs) --- + + +def test_network_name_grammar_rejects_trailing_newline(ctx: PolicyContext) -> None: + # `$` in a non-MULTILINE regex matches just before a trailing newline, so + # `.match()` let "mynet\n" through; the grammar check must fullmatch. + d = check_create(compat(NetworkMode="mynet\n"), ctx, libpod=False) + assert isinstance(d, Deny) and d.reason.startswith("network") + d2 = check_create(compat_with_endpoints({"mynet\n": {}}), ctx, libpod=False) + assert isinstance(d2, Deny) and d2.reason.startswith("network") + + +def test_default_endpoint_key_is_not_a_named_network(ctx: PolicyContext) -> None: + # C5: `default` is the docker CLI's sentinel for the default bridge -- + # every plain `docker run` sends EndpointsConfig: {"default": {}}. It is + # not a network the relay can inspect, so treating it as a named one + # refused every create. The body below is the shape a real `docker run` + # sends; a genuinely named network must still be label-checked. + body = compat_with_endpoints({"default": {}}) + assert check_create(body, ctx, libpod=False) is None + assert named_networks(body, False) == [] + + named = compat_with_endpoints({"mynet": {}}) + assert check_create(named, ctx, libpod=False) is None + assert named_networks(named, False) == ["mynet"] + + +def test_endpoint_map_checked_regardless_of_libpod_flag(ctx: PolicyContext) -> None: + # The endpoint-map classifier must read both NetworkingConfig.EndpointsConfig + # and libpod networks unconditionally, like every other rule in this module. + libpod_body_with_compat_field = libpod(NetworkingConfig={"EndpointsConfig": {"host": {}}}) + d = check_create(libpod_body_with_compat_field, ctx, libpod=True) + assert isinstance(d, Deny) and d.reason.startswith("network") + + compat_body_with_libpod_field = compat() + compat_body_with_libpod_field["networks"] = {"host": {}} + d2 = check_create(compat_body_with_libpod_field, ctx, libpod=False) + assert isinstance(d2, Deny) and d2.reason.startswith("network") + + +def test_network_deny_wording_per_position(ctx: PolicyContext) -> None: + network_mode = check_create(compat(NetworkMode="host"), ctx, libpod=False) + assert isinstance(network_mode, Deny) + assert network_mode.reason == "network: NetworkMode=host is refused" + + endpoint_key = check_create(compat_with_endpoints({"host": {}}), ctx, libpod=False) + assert isinstance(endpoint_key, Deny) + assert endpoint_key.reason == "network: network name host is refused" + + netns_object = check_create(libpod(netns={"nsmode": "host", "extra": 1}), ctx, libpod=True) + assert isinstance(netns_object, Deny) + assert netns_object.reason == "network: netns netns object has unexpected keys" + + +# --- Fix round 1 additions below (review findings round 1) --- + + +def test_named_networks_reads_both_endpoint_shapes_regardless_of_libpod_flag(ctx: PolicyContext) -> None: + # I7: named_networks must read both NetworkingConfig.EndpointsConfig and + # libpod networks unconditionally, like check_create's own classifier + # (fix round 4, I3/S2) already does. + compat_body_with_libpod_field = compat() + compat_body_with_libpod_field["networks"] = {"mynet": {}} + assert named_networks(compat_body_with_libpod_field, False) == ["mynet"] + + libpod_body_with_compat_field = libpod(NetworkingConfig={"EndpointsConfig": {"othernet": {}}}) + assert named_networks(libpod_body_with_compat_field, True) == ["othernet"] + + +# --- Final fix wave: the create body is an allow-list (C1, C5, C6) --- + + +@pytest.mark.parametrize( + ("body", "libpod_shape", "rule"), + [ + # C1: the host-access fields a deny-list had never heard of. Each is + # refused with a reason of its own rather than a bare unknown-field. + (libpod(rootfs="/Users"), True, "rootfs"), + (libpod(rootfs_overlay=True, rootfs="/Users"), True, "rootfs"), + (libpod(overlay_volumes=[{"source": "/Users/me", "destination": "/d"}]), True, "overlay-volumes"), + (libpod(env_host=True), True, "env-host"), + ( + libpod(log_configuration={"driver": "k8s-file", "path": "/Users/me/x.log"}), + True, + "log-configuration", + ), + (libpod(secret_env={"A": "s"}), True, "secrets"), + (libpod(secrets=[{"source": "s"}]), True, "secrets"), + (libpod(cni_networks=["host"]), True, "network"), + (libpod(chroot_directories=["/Users"]), True, "chroot-directories"), + (libpod(init_path="/Users/me/init"), True, "init-path"), + (libpod(conmon_pid_file="/Users/me/pid"), True, "pid-file"), + (compat(ContainerIDFile="/Users/me/cid"), False, "container-id-file"), + (compat(Links=["other:db"]), False, "links"), + (compat(Cgroup="container:deadbeef"), False, "cgroup-parent"), + (compat(VolumeDriver="evil-plugin"), False, "volume-driver"), + # C1: anything the gateway has never heard of, in either position. + ({"Image": "alpine", "HostConfig": {}, "Frobnicate": 1}, False, "unknown-field"), + (compat(Frobnicate=1), False, "unknown-field"), + (libpod(frobnicate=1), True, "unknown-field"), + # ... including a case variant of a denied field, which the daemon's + # case-insensitive decoder would bind to the real one. + (libpod(ROOTFS="/Users"), True, "rootfs"), + # Compat's LogConfig is libpod's log_configuration by another name. + ( + compat(LogConfig={"Type": "k8s-file", "Config": {"path": "/Users/me/x"}}), + False, + "log-configuration", + ), + ( + compat(LogConfig={"Type": "json-file", "Config": {"path": "/Users/me/x"}}), + False, + "log-configuration", + ), + # podman's own annotation namespace records the flags this policy refuses. + (libpod(annotations={"io.podman.annotations.privileged": "TRUE"}), True, "annotations"), + # A pod create body spells SecurityOpt `security_opt`. + (libpod(security_opt=["seccomp=unconfined"]), True, "security-opt"), + # C6: a host-like network through podman's own `Networks` spelling. + (libpod(Networks={"host": {}}), True, "network"), + ], +) +def test_allow_list_refuses_host_access_fields( + ctx: PolicyContext, body: dict[str, Any], libpod_shape: bool, rule: str +) -> None: + d = check_create(body, ctx, libpod=libpod_shape) + assert isinstance(d, Deny), body + assert d.reason.startswith(rule), d.reason + + +@pytest.mark.parametrize( + ("body", "libpod_shape"), + [ + # Both CLIs serialise the zero value of every field on every create, + # so a denied field at its zero value must still pass. + (libpod(env_host=False, log_configuration={}, httpproxy=True), True), + (compat(ContainerIDFile="", Links=None, Cgroup="", VolumeDriver=""), False), + (compat(LogConfig={"Type": "", "Config": {}}), False), + ], +) +def test_denied_fields_at_their_zero_value_pass( + ctx: PolicyContext, body: dict[str, Any], libpod_shape: bool +) -> None: + assert check_create(body, ctx, libpod=libpod_shape) is None + + +def _docker_run_body() -> dict[str, Any]: + """What `docker run -v /data:/x -p 8080:80 alpine echo hi` sends. + + The docker CLI marshals the whole Config / HostConfig struct, zero + values included, so this is the body the allow-list has to admit. + """ + return { + "Hostname": "", + "Domainname": "", + "User": "", + "AttachStdin": False, + "AttachStdout": True, + "AttachStderr": True, + "ExposedPorts": {"80/tcp": {}}, + "Tty": False, + "OpenStdin": False, + "StdinOnce": False, + "Env": [], + "Cmd": ["echo", "hi"], + "Image": "alpine", + "Volumes": {}, + "WorkingDir": "", + "Entrypoint": None, + "OnBuild": None, + "Labels": {}, + "ArgsEscaped": False, + "NetworkDisabled": False, + "MacAddress": "", + "StopSignal": "", + "StopTimeout": None, + "Shell": None, + "Healthcheck": None, + "HostConfig": { + "Binds": [], + "ContainerIDFile": "", + "LogConfig": {"Type": "", "Config": {}}, + "NetworkMode": "default", + "PortBindings": {"80/tcp": [{"HostIp": "", "HostPort": "8080"}]}, + "RestartPolicy": {"Name": "no", "MaximumRetryCount": 0}, + "AutoRemove": True, + "VolumeDriver": "", + "VolumesFrom": None, + "ConsoleSize": [40, 158], + "CapAdd": None, + "CapDrop": None, + "CgroupnsMode": "private", + "Dns": [], + "DnsOptions": [], + "DnsSearch": [], + "ExtraHosts": None, + "GroupAdd": None, + "IpcMode": "private", + "Cgroup": "", + "Links": None, + "OomScoreAdj": 0, + "PidMode": "", + "Privileged": False, + "PublishAllPorts": False, + "ReadonlyRootfs": False, + "SecurityOpt": None, + "StorageOpt": None, + "Tmpfs": None, + "UTSMode": "", + "UsernsMode": "", + "ShmSize": 0, + "Sysctls": None, + "Runtime": "", + "Isolation": "", + "CpuShares": 0, + "Memory": 0, + "NanoCpus": 0, + "CgroupParent": "", + "BlkioWeight": 0, + "BlkioWeightDevice": [], + "BlkioDeviceReadBps": [], + "BlkioDeviceWriteBps": [], + "BlkioDeviceReadIOps": [], + "BlkioDeviceWriteIOps": [], + "CpuPeriod": 0, + "CpuQuota": 0, + "CpuRealtimePeriod": 0, + "CpuRealtimeRuntime": 0, + "CpusetCpus": "", + "CpusetMems": "", + "Devices": [], + "DeviceCgroupRules": None, + "DeviceRequests": None, + "MemoryReservation": 0, + "MemorySwap": 0, + "MemorySwappiness": None, + "OomKillDisable": None, + "PidsLimit": None, + "Ulimits": [], + "CpuCount": 0, + "CpuPercent": 0, + "IOMaximumIOps": 0, + "IOMaximumBandwidth": 0, + "MaskedPaths": None, + "ReadonlyPaths": None, + "Mounts": None, + "Init": None, + "Annotations": None, + }, + "NetworkingConfig": {"EndpointsConfig": {"default": {}}}, + } + + +def _podman_run_body() -> dict[str, Any]: + """What podman 6.1's `podman run --rm -v ... -p 8080:80 alpine echo hi` sends. + + Captured from the remote client, trimmed only of the container's own + command line. Note `Networks` (capital N): that is podman's spelling. + """ + return { + "name": "foo", + "command": ["echo", "hi"], + "env_host": False, + "httpproxy": True, + "env": {"FOO": "1"}, + "terminal": False, + "stdin": False, + "stop_timeout": 10, + "log_configuration": {}, + "systemd": "true", + "sdnotifyMode": "container", + "pidns": {}, + "utsns": {}, + "remove": True, + "containerCreateCommand": ["podman", "run"], + "init_container_type": "", + "unsetenvall": False, + "manage_password": True, + "image": "docker.io/library/alpine", + "raw_image_name": "docker.io/library/alpine", + "image_volume_mode": "anonymous", + "init": False, + "mounts": [{"destination": "/x", "type": "tmpfs", "source": "tmpfs"}], + "ipcns": {}, + "volatile": True, + "privileged": False, + "seccomp_policy": "default", + "userns": {}, + "idmappings": {"HostUIDMapping": True, "HostGIDMapping": True, "UIDMap": None, "GIDMap": None}, + "read_only_filesystem": False, + "read_write_tmpfs": False, + "umask": "0022", + "cgroupns": {}, + "netns": {}, + "portmappings": [{"host_ip": "", "container_port": 80, "host_port": 8080}], + "publish_image_ports": False, + "Networks": None, + "use_image_resolve_conf": False, + "use_image_hostname": False, + "use_image_hosts": False, + "healthconfig": {}, + "healthLogDestination": "local", + "healthMaxLogCount": 5, + "healthMaxLogSize": 500, + } + + +def test_a_real_docker_run_body_still_passes(ctx: PolicyContext) -> None: + body = _docker_run_body() + body["HostConfig"]["Binds"] = [f"{ctx.project_root}:/x"] + assert check_create(body, ctx, libpod=False) is None + assert named_networks(body, False) == [] + apply_create_rewrites(body, ctx, libpod=False) # must not raise + + +def test_a_real_podman_run_body_still_passes(ctx: PolicyContext) -> None: + body = _podman_run_body() + assert check_create(body, ctx, libpod=True) is None + assert named_networks(body, True) == [] + + +def test_podman_networks_spelling_is_read_and_label_checked(ctx: PolicyContext) -> None: + # C6: podman's SpecGenerator spells the per-network map `Networks`. The + # old canonical-spelling table only knew `networks`, so every podman + # create was refused as ambiguous-field -- and the named network in it + # was never label-checked. + body = _podman_run_body() + body["Networks"] = {"mynet": {"interface_name": ""}} + body["networkOrder"] = ["mynet"] + assert check_create(body, ctx, libpod=True) is None + assert named_networks(body, True) == ["mynet"] + + +def test_both_network_spellings_at_once_is_ambiguous(ctx: PolicyContext) -> None: + body = libpod(networks={"a": {}}, Networks={"b": {}}) + d = check_create(body, ctx, libpod=True) + assert isinstance(d, Deny) and d.reason.startswith("ambiguous-field") + + +def test_httpproxy_is_turned_off_when_the_gateway_injects_its_own(ctx: PolicyContext) -> None: + out = apply_create_rewrites(libpod(httpproxy=True), ctx, libpod=True) + assert out["httpproxy"] is False + assert out["env"]["HTTP_PROXY"] == "http://host.containers.internal:8899" + # With egress off the daemon's own setting is left exactly as sent. + off = PolicyContext(ctx.slug, ctx.project_root, ctx.bind_roots, None, "off") + assert apply_create_rewrites(libpod(httpproxy=True), off, libpod=True)["httpproxy"] is True + + +# --- Polish round: P1 volume drivers, P2 case bypasses, P4 over-denials --- + + +@pytest.mark.parametrize( + ("body", "libpod_shape"), + [ + # P1: the `local` driver with `type=none,device=/,o=bind` is a + # host-root bind mount, and an anonymous volume carrying it has no + # name for the relay to label-check. + ( + compat( + Mounts=[ + { + "Type": "volume", + "Target": "/h", + "VolumeOptions": { + "DriverConfig": { + "Name": "local", + "Options": {"type": "none", "device": "/", "o": "bind"}, + } + }, + } + ] + ), + False, + ), + # The inner keys of VolumeOptions are not covered by the canonical- + # spelling check, so the DriverConfig lookup is casefolded too. + ( + compat( + Mounts=[ + { + "Type": "volume", + "Target": "/h", + "VolumeOptions": {"driverconfig": {"Name": "sshfs"}}, + } + ] + ), + False, + ), + # The libpod mount shape spells the same thing as options. + ( + libpod( + mounts=[ + { + "type": "volume", + "destination": "/h", + "options": ["volume-opt=type=none", "volume-opt=device=/", "volume-opt=o=bind"], + } + ] + ), + True, + ), + # ... and so does libpod's top-level named-volume list. + (libpod(volumes=[{"Dest": "/h", "Options": ["volume-opt=device=/"]}]), True), + ], +) +def test_a_volume_driver_configuration_is_refused( + ctx: PolicyContext, body: dict[str, Any], libpod_shape: bool +) -> None: + d = check_create(body, ctx, libpod=libpod_shape) + assert isinstance(d, Deny), body + assert d.reason.startswith("volume-driver"), d.reason + + +@pytest.mark.parametrize( + ("body", "libpod_shape"), + [ + (compat(Mounts=[{"Type": "volume", "Source": "data", "Target": "/h"}]), False), + (libpod(mounts=[{"type": "volume", "source": "data", "destination": "/h"}]), True), + (libpod(volumes=[{"Name": "data", "Dest": "/h", "Options": ["rw", "z"]}]), True), + ], +) +def test_a_plain_named_volume_is_still_allowed( + ctx: PolicyContext, body: dict[str, Any], libpod_shape: bool +) -> None: + assert check_create(body, ctx, libpod=libpod_shape) is None + + +@pytest.mark.parametrize( + ("body", "rule"), + [ + # P2: Go's decoder binds these to the same struct field, so the + # denied-field table has to be looked up casefolded. + (compat(CONTAINERIDFILE="/Users/me/cid"), "container-id-file"), + (compat(LINKS=["other:db"]), "links"), + (compat(CGROUP="container:deadbeef"), "cgroup-parent"), + (compat(volumedriver="evil-plugin"), "volume-driver"), + ], +) +def test_denied_create_fields_are_refused_in_any_case( + ctx: PolicyContext, body: dict[str, Any], rule: str +) -> None: + d = check_create(body, ctx, libpod=False) + assert isinstance(d, Deny), body + assert d.reason.startswith(rule), d.reason + + +@pytest.mark.parametrize( + ("body", "libpod_shape"), + [ + # P4: `podman run --ulimit`, and every run at all once + # containers.conf sets `default_ulimits`. + (libpod(r_limits=[{"type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024}]), True), + # P4: the camelCase device-limit members of podman's resource spec. + (libpod(weightDevice=[{"Path": "/dev/sda", "Weight": 100}]), True), + (libpod(throttleReadBpsDevice={"/dev/sda": {"Rate": 1}}), True), + (libpod(throttleWriteBpsDevice={"/dev/sda": {"Rate": 1}}), True), + (libpod(throttleReadIOPSDevice={"/dev/sda": {"Rate": 1}}), True), + (libpod(throttleWriteIOPSDevice={"/dev/sda": {"Rate": 1}}), True), + (libpod(personality={"domain": "LINUX"}), True), + # P4: `docker run --log-opt max-size=10m`, and compose's + # `logging.options` block. + (compat(LogConfig={"Type": "json-file", "Config": {"max-size": "10m", "max-file": "3"}}), False), + (compat(LogConfig={"Type": "local", "Config": {"max-size": "10m", "compress": "true"}}), False), + (compat(LogConfig={"Type": "", "Config": {"max-size": "10m"}}), False), + ], +) +def test_common_invocations_are_no_longer_refused( + ctx: PolicyContext, body: dict[str, Any], libpod_shape: bool +) -> None: + assert check_create(body, ctx, libpod=libpod_shape) is None + + +@pytest.mark.parametrize( + "body", + [ + # A driver that is not one of the safe two still refuses its options. + compat(LogConfig={"Type": "none", "Config": {"max-size": "10m"}}), + # ... and the option that names a host path is refused on the safe + # drivers too: podman's compat endpoint maps it onto libpod's + # `log_configuration.path`. + compat(LogConfig={"Type": "json-file", "Config": {"path": "/Users/me/x"}}), + compat(LogConfig={"Type": "local", "Config": {"path": "/Users/me/x"}}), + ], +) +def test_log_driver_options_that_can_name_a_host_path_stay_refused( + ctx: PolicyContext, body: dict[str, Any] +) -> None: + d = check_create(body, ctx, libpod=False) + assert isinstance(d, Deny), body + assert d.reason.startswith("log-configuration"), d.reason diff --git a/tools/container-gateway/tests/test_policy_images.py b/tools/container-gateway/tests/test_policy_images.py new file mode 100644 index 000000000..ad5148cdc --- /dev/null +++ b/tools/container-gateway/tests/test_policy_images.py @@ -0,0 +1,73 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Images are shared: read freely, remove or tag only what the project built.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from container_gateway.decisions import Allow, Request, decide +from container_gateway.labels import LABEL_KEY +from container_gateway.policy import Deny, PolicyContext + + +@pytest.fixture +def ctx(tmp_path: Path) -> PolicyContext: + return PolicyContext("-p", tmp_path, (tmp_path,), None, "off") + + +def req(method: str, path: str, query: dict[str, list[str]] | None = None) -> Request: + return Request(method, path, query or {}, {}, None) + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("GET", "/v1.45/images/json"), + ("POST", "/v1.45/images/create"), + ("GET", "/v1.45/images/alpine:3/json"), + ("GET", "/v1.45/images/alpine:3/history"), + ("GET", "/v1.45/images/alpine:3/get"), + ("GET", "/v1.45/images/search"), + ("POST", "/v1.45/images/load"), + ], +) +def test_reads_and_pull_need_no_label(ctx: PolicyContext, method: str, path: str) -> None: + a = decide(req(method, path), ctx) + assert isinstance(a, Allow) and a.label_check is None + + +def test_remove_and_tag_need_label(ctx: PolicyContext) -> None: + rm = decide(req("DELETE", "/v1.45/images/alpine:3"), ctx) + tag = decide(req("POST", "/v1.45/images/alpine:3/tag", {"repo": ["x"], "tag": ["y"]}), ctx) + assert isinstance(rm, Allow) and rm.label_check == "alpine:3" + assert isinstance(tag, Allow) and tag.label_check == "alpine:3" + + +def test_push_is_denied(ctx: PolicyContext) -> None: + assert isinstance(decide(req("POST", "/v1.45/images/alpine:3/push"), ctx), Deny) + + +def test_image_prune_is_dangling_and_labelled(ctx: PolicyContext) -> None: + a = decide(req("POST", "/v1.45/images/prune"), ctx) + assert isinstance(a, Allow) + f = json.loads(a.request.query["filters"][0]) + assert f["dangling"] == ["true"] + assert f["label"] == [f"{LABEL_KEY}=-p"] diff --git a/tools/container-gateway/tests/test_policy_labels.py b/tools/container-gateway/tests/test_policy_labels.py new file mode 100644 index 000000000..402c749f1 --- /dev/null +++ b/tools/container-gateway/tests/test_policy_labels.py @@ -0,0 +1,329 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""decide(): deny lists, filter merging, label injection, act-by-name checks.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from container_gateway.decisions import Allow, Request, decide +from container_gateway.labels import LABEL_KEY +from container_gateway.policy import Deny, PolicyContext +from container_gateway.routes import _VERBS, Family + + +@pytest.fixture +def ctx(tmp_path: Path) -> PolicyContext: + return PolicyContext("-p", tmp_path, (tmp_path,), None, "off") + + +def req(method: str, path: str, query: dict[str, list[str]] | None = None, body: object = None) -> Request: + return Request(method, path, query or {}, {}, body) + + +@pytest.mark.parametrize( + "path", ["/v1.45/auth", "/v1.45/swarm/init", "/v1.45/secrets", "/v1.45/images/x/push", "/v1.45/plugins"] +) +def test_denied_families(ctx: PolicyContext, path: str) -> None: + d = decide(req("POST", path), ctx) + assert isinstance(d, Deny) and d.reason.startswith("denied-endpoint") + + +def test_unknown_path_is_denied_not_forwarded(ctx: PolicyContext) -> None: + d = decide(req("GET", "/v1.45/frobnicate"), ctx) + assert isinstance(d, Deny) and d.reason.startswith("unknown-endpoint") + + +def test_list_gets_label_filter(ctx: PolicyContext) -> None: + a = decide(req("GET", "/v1.45/containers/json", {"all": ["1"]}), ctx) + assert isinstance(a, Allow) + f = json.loads(a.request.query["filters"][0]) + assert f["label"] == [f"{LABEL_KEY}=-p"] + assert a.request.query["all"] == ["1"] + assert a.label_check is None + + +def test_events_and_prune_get_label_filter(ctx: PolicyContext) -> None: + for path in ("/v1.45/events", "/v1.45/volumes/prune", "/v5.0.0/libpod/pods/prune"): + a = decide(req("GET" if "events" in path else "POST", path), ctx) + assert isinstance(a, Allow), path + assert f"{LABEL_KEY}=-p" in json.loads(a.request.query["filters"][0])["label"] + + +def test_container_create_is_labelled(ctx: PolicyContext) -> None: + a = decide(req("POST", "/v1.45/containers/create", body={"Image": "alpine"}), ctx) + assert isinstance(a, Allow) + assert a.request.body["Labels"][LABEL_KEY] == "-p" + + +def test_container_create_privileged_is_denied(ctx: PolicyContext) -> None: + d = decide( + req("POST", "/v1.45/containers/create", body={"Image": "a", "HostConfig": {"Privileged": True}}), ctx + ) + assert isinstance(d, Deny) and d.reason.startswith("privileged") + + +def test_volume_and_network_create_are_labelled(ctx: PolicyContext) -> None: + v = decide(req("POST", "/v1.45/volumes/create", body={"Name": "v"}), ctx) + n = decide(req("POST", "/v5.0.0/libpod/networks/create", body={"name": "n"}), ctx) + assert isinstance(v, Allow) and v.request.body["Labels"][LABEL_KEY] == "-p" + assert isinstance(n, Allow) and n.request.body["labels"][LABEL_KEY] == "-p" + + +def test_build_labels_query(ctx: PolicyContext) -> None: + a = decide(req("POST", "/v1.45/build", {"t": ["img:1"], "labels": ['{"a":"b"}']}), ctx) + assert isinstance(a, Allow) + assert json.loads(a.request.query["labels"][0]) == {"a": "b", LABEL_KEY: "-p"} + + +def test_act_by_name_carries_label_check(ctx: PolicyContext) -> None: + a = decide(req("POST", "/v1.45/containers/web1/start"), ctx) + assert isinstance(a, Allow) and a.label_check == "web1" + e = decide(req("POST", "/v1.45/exec/abc123/start", body={}), ctx) + assert isinstance(e, Allow) and e.label_check == "abc123" + + +def test_ping_and_version_pass_through(ctx: PolicyContext) -> None: + for path in ("/_ping", "/v1.45/version", "/v1.45/info"): + a = decide(req("GET", path), ctx) + assert isinstance(a, Allow) and a.label_check is None + + +def test_raw_target_round_trips_query() -> None: + r = Request("GET", "/v1.45/containers/json", {"all": ["1"], "filters": ['{"label":["a=b"]}']}, {}, None) + assert r.raw_target() == "/v1.45/containers/json?all=1&filters=%7B%22label%22%3A%5B%22a%3Db%22%5D%7D" + + +def test_raw_target_with_empty_query_value_list_is_bare_path() -> None: + # A query dict like {"a": []} is a non-empty dict with nothing to + # render; raw_target() must not append a bare "?". + r = Request("GET", "/v1.45/info", {"a": []}, {}, None) + assert r.raw_target() == "/v1.45/info" + + +# --- Fix round 1 additions below (review findings) --- + + +@pytest.mark.parametrize( + ("path", "body"), + [ + # C1: second spelling of the label field lets a foreign slug win + ("/v1.45/volumes/create", {"Name": "v", "labels": {}, "Labels": {LABEL_KEY: "-other"}}), + ("/v1.45/volumes/create", {"Labels": {"a": "b"}, "LABELS": {LABEL_KEY: "-other"}}), + ("/v5.0.0/libpod/networks/create", {"name": "n", "Labels": {}, "labels": {LABEL_KEY: "-other"}}), + ("/v5.0.0/libpod/networks/create", {"name": "n", "labels": {"a": "b"}, "LABELS": {"a": "c"}}), + ], +) +def test_resource_create_label_spelling_collision_is_denied( + ctx: PolicyContext, path: str, body: dict[str, object] +) -> None: + d = decide(req("POST", path, body=body), ctx) + assert isinstance(d, Deny) and d.reason.startswith("ambiguous-field") + + +def test_resource_create_non_dict_body_is_denied(ctx: PolicyContext) -> None: + v = decide(req("POST", "/v1.45/volumes/create", body=["x"]), ctx) + n = decide(req("POST", "/v5.0.0/libpod/networks/create", body="not-a-dict"), ctx) + assert isinstance(v, Deny) and v.reason.startswith("malformed") + assert isinstance(n, Deny) and n.reason.startswith("malformed") + + +@pytest.mark.parametrize( + ("path", "query"), + [ + ("/v1.45/containers/json", {"filters": ["notjson"]}), + ("/v1.45/containers/json", {"filters": ["[1,2]"]}), + ("/v1.45/build", {"labels": ["notjson"]}), + ("/v1.45/build", {"labels": ["5"]}), + ], +) +def test_malformed_query_values_deny_instead_of_raising( + ctx: PolicyContext, path: str, query: dict[str, list[str]] +) -> None: + d = decide(req("POST" if "build" in path else "GET", path, query), ctx) + assert isinstance(d, Deny) and d.reason.startswith("malformed") + + +@pytest.mark.parametrize( + "path", + [ + "/v1.45/images/../swarm/init", + "//v1.45/info", + "/v1.45/images/alpine%2f..%2fswarm/init", + "/v1.45/images/alpine/..\\swarm", + "/v1.45/images/\x00json", + ], +) +def test_unnormalised_paths_are_denied_before_routing(ctx: PolicyContext, path: str) -> None: + d = decide(req("POST", path), ctx) + assert isinstance(d, Deny) and d.reason.startswith("malformed") + + +def test_container_create_non_dict_body_is_denied(ctx: PolicyContext) -> None: + d = decide(req("POST", "/v1.45/containers/create", body=["x"]), ctx) + assert isinstance(d, Deny) and d.reason.startswith("malformed") + + +# Verbs `decide()` refuses outright (C4) or that carry a body of their own +# (C3), so "every remaining named verb is label-checked" stays exact. +_DENIED_VERBS = frozenset({"checkpoint", "restore", "generate", "play"}) +_BODY_VERBS: dict[str, object] = {"exec": {"Cmd": ["sh"]}, "update": {"Memory": 1}} + + +@pytest.mark.parametrize("verb", sorted(_VERBS - {"push"} - _DENIED_VERBS)) +@pytest.mark.parametrize( + ("path_prefix", "name"), + [("/v1.45/containers/web1", "web1"), ("/v5.2.0/libpod/pods/p1", "p1")], +) +def test_every_named_verb_route_carries_a_label_check( + ctx: PolicyContext, path_prefix: str, name: str, verb: str +) -> None: + a = decide(req("POST", f"{path_prefix}/{verb}", body=_BODY_VERBS.get(verb)), ctx) + assert isinstance(a, Allow), (path_prefix, verb) + assert a.label_check == name + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("POST", "/v5.2.0/libpod/pods/p1/init"), + ("GET", "/v1.45/containers/web1/get"), + ("POST", "/v1.45/networks/n1/exists"), + ("GET", "/v1.45/volumes/myvol"), + ("POST", "/v1.45/exec/abc123/resize"), + ("GET", "/v1.45/images/alpine:3/json"), + ("GET", "/v1.45/images/alpine:3/history"), + ], +) +def test_no_named_route_is_fail_open_except_image_reads(ctx: PolicyContext, method: str, path: str) -> None: + a = decide(req(method, path), ctx) + assert isinstance(a, Allow), (method, path) + if a.route.family is Family.IMAGES and a.route.action in ("inspect", "history"): + return # image reads are explicitly exempt from the label check + assert a.route.name is not None + assert a.label_check == a.route.name + + +# --- Final fix wave: exec / update bodies, denied actions, compat commit --- + + +@pytest.mark.parametrize( + ("body", "rule"), + [ + ({"Cmd": ["sh"], "Privileged": True}, "privileged"), + ({"Cmd": ["sh"], "privileged": True}, "ambiguous-field"), + ({"Cmd": ["sh"], "CapAdd": ["SYS_ADMIN"]}, "cap-add"), + ({"Cmd": ["sh"], "Devices": [{"PathOnHost": "/dev/kvm"}]}, "devices"), + ({"Cmd": ["sh"], "PidMode": "host"}, "namespace"), + ({"Cmd": ["sh"], "NetworkMode": "host"}, "network"), + ({"Cmd": ["sh"], "SecurityOpt": ["seccomp=unconfined"]}, "security-opt"), + ({"Cmd": ["sh"], "Frobnicate": 1}, "unknown-field"), + ("not-a-dict", "malformed"), + ], +) +def test_exec_create_body_is_checked(ctx: PolicyContext, body: object, rule: str) -> None: + d = decide(req("POST", "/v1.45/containers/web1/exec", body=body), ctx) + assert isinstance(d, Deny), body + assert d.reason.startswith(rule), d.reason + + +def test_a_real_exec_create_body_passes(ctx: PolicyContext) -> None: + # Captured from podman 6.1's `podman exec -i sh -c true`. + body = { + "User": "", + "Privileged": False, + "Tty": False, + "AttachStdin": True, + "AttachStderr": True, + "AttachStdout": True, + "DetachKeys": "ctrl-p,ctrl-q", + "Env": [], + "WorkingDir": "", + "Cmd": ["sh", "-c", "true"], + } + a = decide(req("POST", "/v5.2.0/libpod/containers/web1/exec", body=body), ctx) + assert isinstance(a, Allow) and a.label_check == "web1" + + +@pytest.mark.parametrize( + ("body", "rule"), + [ + ({"Memory": 1024, "CpuShares": 2}, None), + ({"RestartPolicy": {"Name": "no"}}, None), + ({"Privileged": True}, "unknown-field"), + ({"Devices": [{"PathOnHost": "/dev/kvm"}]}, "devices"), + ({"Binds": ["/:/host"]}, "unknown-field"), + ], +) +def test_container_update_body_is_checked(ctx: PolicyContext, body: object, rule: str | None) -> None: + verdict = decide(req("POST", "/v1.45/containers/web1/update", body=body), ctx) + if rule is None: + assert isinstance(verdict, Allow) and verdict.label_check == "web1" + else: + assert isinstance(verdict, Deny) and verdict.reason.startswith(rule) + + +@pytest.mark.parametrize( + "path", + [ + "/v5.2.0/libpod/containers/c1/checkpoint", + "/v5.2.0/libpod/containers/c1/restore", + "/v5.2.0/libpod/pods/p1/checkpoint", + "/v5.2.0/libpod/pods/p1/restore", + "/v1.45/containers/c1/checkpoint", + ], +) +def test_checkpoint_and_restore_are_denied_endpoints(ctx: PolicyContext, path: str) -> None: + d = decide(req("POST", path, {"export": ["/Users/me/x.tar"]}), ctx) + assert isinstance(d, Deny) and d.reason.startswith("denied-") + + +@pytest.mark.parametrize("param", ["export", "import", "EXPORT"]) +def test_export_and_import_query_parameters_are_denied(ctx: PolicyContext, param: str) -> None: + d = decide(req("POST", "/v1.45/containers/c1/start", {param: ["/Users/me/x.tar"]}), ctx) + assert isinstance(d, Deny) and d.reason.startswith("denied-query") + + +def test_compat_commit_takes_the_container_from_the_query(ctx: PolicyContext) -> None: + a = decide(req("POST", "/v1.45/commit", {"container": ["web1"], "repo": ["img"]}), ctx) + assert isinstance(a, Allow) + assert a.label_check == "web1" + assert a.name_in_query == "container" + assert (a.route.family, a.route.action) == (Family.CONTAINERS, "commit") + + +def test_path_spelling_of_commit_still_label_checks_the_path(ctx: PolicyContext) -> None: + a = decide(req("POST", "/v1.45/containers/web1/commit"), ctx) + assert isinstance(a, Allow) and a.label_check == "web1" and a.name_in_query is None + + +def test_compat_commit_without_a_container_is_denied(ctx: PolicyContext) -> None: + d = decide(req("POST", "/v1.45/commit", {"repo": ["img"]}), ctx) + assert isinstance(d, Deny) and d.reason.startswith("malformed") + + +# --- Polish round: podman's exec cleanup call (P5) --- + + +def test_exec_remove_carries_a_label_check(ctx: PolicyContext) -> None: + a = decide(req("POST", "/v5.2.0/libpod/exec/abc123/remove"), ctx) + assert isinstance(a, Allow) + assert (a.route.family, a.route.action) == (Family.EXEC, "exec_remove") + assert a.label_check == "abc123" diff --git a/tools/container-gateway/tests/test_relay.py b/tools/container-gateway/tests/test_relay.py new file mode 100644 index 000000000..2c88c2482 --- /dev/null +++ b/tools/container-gateway/tests/test_relay.py @@ -0,0 +1,743 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""The relay end to end against the fake backend. + +No ``pytest-asyncio`` in the ``magpie-dev`` dependency group, so each +scenario is a plain ``def`` test driving its coroutine through ``run()``, +exactly as ``test_http.py`` does. The relay is driven directly over a +``socket.socketpair()`` rather than through a bound gateway socket: the +sandbox refuses ``bind()`` on a unix-domain path, and ``serve_unix`` (the +only piece that binds) gets its own smoke test below. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections.abc import Awaitable, Callable, Coroutine +from pathlib import Path +from typing import Any, TypeVar + +import pytest + +from container_gateway import relay as relay_module +from container_gateway.labels import LABEL_KEY +from container_gateway.policy import PolicyContext +from container_gateway.relay import Relay, _rewrite_name, serve_unix + +from .fakebackend import FakeBackend, socket_pair + +_T = TypeVar("_T") + + +def run(coro: Coroutine[Any, Any, _T]) -> _T: + """Drive a coroutine to completion without pytest-asyncio.""" + return asyncio.run(coro) + + +async def _serve_unix_or_skip( + sock: Path, handler: Callable[[asyncio.StreamReader, asyncio.StreamWriter], Awaitable[None]] +) -> asyncio.AbstractServer: + """Bind ``sock``, skipping the caller where the sandbox refuses ``bind()``.""" + try: + return await serve_unix(sock, handler) + except PermissionError: + pytest.skip("sandbox denies unix bind; runs in CI") + # Unreachable: pytest.skip() raises. The bare re-raise is what tells + # a static analyser the except branch never falls through -- without + # it, CodeQL reads the code below as running with nothing assigned. + raise + + +def stack(root: Path) -> tuple[FakeBackend, Relay]: + """A fake daemon holding one owned and one foreign resource of each kind.""" + backend = FakeBackend() + backend.add_container("aaa111", "mine", {LABEL_KEY: "-p"}) + backend.add_container("bbb222", "theirs", {LABEL_KEY: "-q"}) + backend.execs["ex1"] = "aaa111" + backend.execs["ex2"] = "bbb222" + backend.add_volume("myvol", {LABEL_KEY: "-p"}) + backend.add_volume("theirvol", {LABEL_KEY: "-q"}) + backend.add_network("mynet", {LABEL_KEY: "-p"}) + backend.add_network("theirnet", {LABEL_KEY: "-q"}) + ctx = PolicyContext("-p", root, (root,), {"HTTP_PROXY": "http://h:1"}, "inject-if-available") + return backend, Relay(backend.connect, ctx, backend_label=str(root / "d.sock")) + + +async def call(relay: Relay, raw: bytes) -> tuple[int, bytes, bytes]: + """Send one request over a fresh client connection; return status, head, body.""" + (reader, writer), server = await socket_pair() + served = asyncio.create_task(relay.handle(*server)) + writer.write(raw) + await writer.drain() + writer.write_eof() + data = await asyncio.wait_for(reader.read(), 5) + await asyncio.wait_for(served, 5) + writer.close() + head, _, body = data.partition(b"\r\n\r\n") + return int(head.split(b" ")[1]), head, body + + +async def call_raw(relay: Relay, raw: bytes) -> bytes: + """Like ``call``, but returns every byte the relay wrote, interim heads included.""" + (reader, writer), server = await socket_pair() + served = asyncio.create_task(relay.handle(*server)) + writer.write(raw) + await writer.drain() + writer.write_eof() + data = await asyncio.wait_for(reader.read(), 5) + await asyncio.wait_for(served, 5) + writer.close() + return data + + +def dechunk(body: bytes) -> bytes: + out, rest = b"", body + while rest: + size, _, rest = rest.partition(b"\r\n") + n = int(size, 16) + if n == 0: + break + out, rest = out + rest[:n], rest[n + 2 :] + return out + + +def create_request(payload: dict[str, Any], extra: bytes = b"") -> bytes: + body = json.dumps(payload).encode() + head = ( + b"POST /v1.45/containers/create HTTP/1.1\r\nHost: x\r\n" + + extra + + b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + ) + return head + body + + +def test_ping_passes_through(tmp_path: Path) -> None: + async def scenario() -> None: + _, relay = stack(tmp_path) + status, _, body = await call(relay, b"GET /_ping HTTP/1.1\r\nHost: x\r\n\r\n") + assert (status, body) == (200, b"OK") + + run(scenario()) + + +def test_serve_unix_binds_owner_only(tmp_path: Path) -> None: + async def scenario() -> None: + async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + writer.close() + + sock = tmp_path / "gw.sock" + # Bound outside the try that owns the close, so the `finally` + # below can never reference a name the bind failed to assign. + server = await _serve_unix_or_skip(sock, handler) + try: + assert sock.stat().st_mode & 0o777 == 0o600 + finally: + server.close() + await server.wait_closed() + + run(scenario()) + + +def test_serve_unix_does_not_unlink_a_pre_existing_file(tmp_path: Path) -> None: + """``serve_unix`` never removes what was there before it -- the daemon does, and only + + after ``check_socket_type`` has confirmed it is safe to. This drives + ``serve_unix`` straight at a pre-existing regular file with no such + check in front of it and asserts the file survives regardless of + whether the bind itself got far enough to fail on "already in use" + or was refused outright by the sandbox. + """ + + async def scenario() -> None: + async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + writer.close() + + sock = tmp_path / "gw.sock" + sock.write_text("not a socket") + with contextlib.suppress(OSError): + server = await serve_unix(sock, handler) + server.close() + await server.wait_closed() + assert sock.exists() + assert sock.read_text() == "not a socket" + + run(scenario()) + + +def test_denied_is_403_json_and_never_reaches_backend(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + raw = b"POST /v1.45/auth HTTP/1.1\r\nHost: x\r\nContent-Length: 2\r\n\r\n{}" + status, head, body = await call(relay, raw) + assert status == 403 and b"application/json" in head + assert json.loads(body)["message"].startswith("container-gateway: denied-endpoint") + assert backend.seen == [] + + run(scenario()) + + +def test_list_is_filtered_by_label(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + status, _, _ = await call(relay, b"GET /v1.45/containers/json HTTP/1.1\r\nHost: x\r\n\r\n") + assert status == 200 + _, target, _ = backend.seen[-1] + assert "filters=" in target and "org.apache.magpie.project%3D-p" in target + + run(scenario()) + + +def test_create_body_is_rewritten_and_length_fixed(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + status, _, _ = await call(relay, create_request({"Image": "alpine", "Env": ["A=1"]})) + assert status == 201 + _, _, sent = backend.seen[-1] + assert sent is not None and sent["Labels"][LABEL_KEY] == "-p" + assert "HTTP_PROXY=http://h:1" in sent["Env"] + + run(scenario()) + + +def test_act_by_name_resolves_to_id_when_labelled(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + raw = b"POST /v1.45/containers/mine/start HTTP/1.1\r\nHost: x\r\n\r\n" + status, _, _ = await call(relay, raw) + assert status == 204 + assert backend.seen[-1][:2] == ("POST", "/v1.45/containers/aaa111/start") + + run(scenario()) + + +def test_act_by_name_on_foreign_container_is_403_not_404(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + raw = b"POST /v1.45/containers/theirs/start HTTP/1.1\r\nHost: x\r\n\r\n" + status, _, body = await call(relay, raw) + assert status == 403 and b"label-check" in body + assert all(t != "/v1.45/containers/bbb222/start" for _, t, _ in backend.seen) + ghost = b"POST /v1.45/containers/ghost/start HTTP/1.1\r\nHost: x\r\n\r\n" + status, _, _ = await call(relay, ghost) + assert status == 403 + + run(scenario()) + + +def test_exec_start_checks_owning_container(tmp_path: Path) -> None: + async def scenario() -> None: + _, relay = stack(tmp_path) + prefix = b"HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}" + ok, _, _ = await call(relay, b"POST /v1.45/exec/ex1/start " + prefix) + bad, _, _ = await call(relay, b"POST /v1.45/exec/ex2/start " + prefix) + assert ok != 403 and bad == 403 + + run(scenario()) + + +def test_chunked_logs_stream_through(tmp_path: Path) -> None: + async def scenario() -> None: + _, relay = stack(tmp_path) + raw = b"GET /v1.45/containers/mine/logs?stdout=1 HTTP/1.1\r\nHost: x\r\n\r\n" + status, head, body = await call(relay, raw) + assert status == 200 and b"chunked" in head.lower() + assert dechunk(body) == b"line1\nline2\n" + + run(scenario()) + + +def test_attach_hijack_pipes_both_ways(tmp_path: Path) -> None: + async def scenario() -> None: + _, relay = stack(tmp_path) + (reader, writer), server = await socket_pair() + served = asyncio.create_task(relay.handle(*server)) + writer.write( + b"POST /v1.45/containers/mine/attach?stream=1&stdin=1 HTTP/1.1\r\n" + b"Host: x\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n" + ) + await writer.drain() + head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5) + assert head.startswith(b"HTTP/1.1 101") + writer.write(b"hello") + await writer.drain() + assert await asyncio.wait_for(reader.readexactly(10), 5) == b"echo:hello" + writer.close() + await asyncio.wait_for(served, 5) + + run(scenario()) + + +def test_backend_down_is_502(tmp_path: Path) -> None: + async def scenario() -> None: + async def refused() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + raise OSError("connection refused") + + ctx = PolicyContext("-p", tmp_path, (tmp_path,), None, "off") + relay = Relay(refused, ctx, backend_label=str(tmp_path / "missing.sock")) + status, _, body = await call(relay, b"GET /_ping HTTP/1.1\r\nHost: x\r\n\r\n") + assert status == 502 and b"backend" in body + + run(scenario()) + + +def test_named_volume_owned_by_the_project_passes(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + payload = {"Image": "alpine", "HostConfig": {"Binds": ["myvol:/data"]}} + status, _, _ = await call(relay, create_request(payload)) + assert status == 201 + assert ("GET", "/volumes/myvol", None) in backend.seen + assert backend.seen[-1][1] == "/v1.45/containers/create" + + run(scenario()) + + +def test_foreign_named_volume_is_403(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + payload = {"Image": "alpine", "HostConfig": {"Binds": ["theirvol:/data"]}} + status, _, body = await call(relay, create_request(payload)) + assert status == 403 + assert "label-check: volume theirvol" in json.loads(body)["message"] + assert all(t != "/v1.45/containers/create" for _, t, _ in backend.seen) + + run(scenario()) + + +def test_missing_named_volume_is_precreated_with_the_label(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + payload = {"Image": "alpine", "HostConfig": {"Binds": ["newvol:/data"]}} + status, _, _ = await call(relay, create_request(payload)) + assert status == 201 + created = [b for method, t, b in backend.seen if (method, t) == ("POST", "/volumes/create")] + assert created == [{"Name": "newvol", "Labels": {LABEL_KEY: "-p"}}] + assert backend.volumes["newvol"]["Labels"][LABEL_KEY] == "-p" + assert backend.seen[-1][1] == "/v1.45/containers/create" + + run(scenario()) + + +def test_foreign_named_network_is_403(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + payload = {"Image": "alpine", "HostConfig": {"NetworkMode": "theirnet"}} + status, _, body = await call(relay, create_request(payload)) + assert status == 403 + assert "label-check: network theirnet does not belong" in json.loads(body)["message"] + assert all(t != "/v1.45/containers/create" for _, t, _ in backend.seen) + + run(scenario()) + + +def test_missing_named_network_is_403(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + payload = {"Image": "alpine", "HostConfig": {"NetworkMode": "ghostnet"}} + status, _, body = await call(relay, create_request(payload)) + assert status == 403 + assert "label-check: network ghostnet does not exist" in json.loads(body)["message"] + assert all(t != "/v1.45/containers/create" for _, t, _ in backend.seen) + + run(scenario()) + + +def test_owned_named_network_passes(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + payload = {"Image": "alpine", "HostConfig": {"NetworkMode": "mynet"}} + status, _, _ = await call(relay, create_request(payload)) + assert status == 201 + assert ("GET", "/networks/mynet", None) in backend.seen + + run(scenario()) + + +# --- Fix round 1 additions below --- + + +def test_id_rewrite_is_segment_exact(tmp_path: Path) -> None: + """A container legally named after a path element must not shift the rewrite.""" + + async def scenario() -> None: + for name, cid in (("containers", "ccc333"), ("v1.45", "ddd444"), ("libpod", "eee555")): + backend, relay = stack(tmp_path) + backend.add_container(cid, name, {LABEL_KEY: "-p"}) + raw = f"POST /v1.45/containers/{name}/start HTTP/1.1\r\nHost: x\r\n\r\n".encode() + status, _, _ = await call(relay, raw) + assert status == 204 + assert backend.seen[-1][:2] == ("POST", f"/v1.45/containers/{cid}/start") + + run(scenario()) + + +def test_id_rewrite_spans_a_multi_segment_image_name(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.add_image("quay.io/podman/hello:latest", "sha256:dead", {LABEL_KEY: "-p"}) + raw = b"DELETE /v1.45/images/quay.io/podman/hello:latest HTTP/1.1\r\nHost: x\r\n\r\n" + status, _, _ = await call(relay, raw) + assert status == 200 + assert backend.seen[-1][:2] == ("DELETE", "/v1.45/images/sha256:dead") + + run(scenario()) + + +def test_foreign_multi_segment_image_is_403(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.add_image("quay.io/podman/hello:latest", "sha256:dead", {LABEL_KEY: "-q"}) + raw = b"DELETE /v1.45/images/quay.io/podman/hello:latest HTTP/1.1\r\nHost: x\r\n\r\n" + status, _, body = await call(relay, raw) + assert status == 403 and b"label-check" in body + assert all(method != "DELETE" for method, _, _ in backend.seen) + + run(scenario()) + + +def test_volume_precreate_conflict_rechecks_the_label(tmp_path: Path) -> None: + """Another project winning the create race must not hand us its volume.""" + + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.volume_create_conflict = "-q" + payload = {"Image": "alpine", "HostConfig": {"Binds": ["racevol:/data"]}} + status, _, body = await call(relay, create_request(payload)) + assert status == 403 + assert "label-check: volume racevol" in json.loads(body)["message"] + assert all(t != "/v1.45/containers/create" for _, t, _ in backend.seen) + + run(scenario()) + + +def test_volume_precreate_conflict_won_by_this_project_passes(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.volume_create_conflict = "-p" + payload = {"Image": "alpine", "HostConfig": {"Binds": ["racevol:/data"]}} + status, _, _ = await call(relay, create_request(payload)) + assert status == 201 + assert backend.seen[-1][1] == "/v1.45/containers/create" + + run(scenario()) + + +def test_volume_precreate_failure_is_403(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.volume_create_status = 500 + payload = {"Image": "alpine", "HostConfig": {"Binds": ["newvol:/data"]}} + status, _, body = await call(relay, create_request(payload)) + assert status == 403 + assert "label-check: volume newvol could not be prepared" in json.loads(body)["message"] + assert all(t != "/v1.45/containers/create" for _, t, _ in backend.seen) + + run(scenario()) + + +def test_anonymous_volume_mount_needs_no_check(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + payload = {"Image": "alpine", "HostConfig": {"Mounts": [{"Type": "volume", "Target": "/d"}]}} + status, _, _ = await call(relay, create_request(payload)) + assert status == 201 + assert [t for _, t, _ in backend.seen] == ["/v1.45/containers/create"] + + run(scenario()) + + +def test_interim_head_from_the_backend_is_consumed(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.interim_heads = 1 + data = await call_raw(relay, create_request({"Image": "alpine"})) + assert data.startswith(b"HTTP/1.1 201 Created") + assert b"100 Continue" not in data + assert json.loads(data.partition(b"\r\n\r\n")[2])["Id"] == "newid" + + run(scenario()) + + +def test_expect_continue_is_answered_by_the_relay_and_stripped(tmp_path: Path) -> None: + """The client's HTTP peer is the relay, so the relay completes the handshake.""" + + async def scenario() -> None: + backend, relay = stack(tmp_path) + raw = create_request({"Image": "alpine"}, extra=b"Expect: 100-continue\r\n") + data = await call_raw(relay, raw) + assert data.startswith(b"HTTP/1.1 100 Continue\r\n\r\n") + assert b"HTTP/1.1 201 Created" in data + assert backend.heads[-1].get("expect") is None + + run(scenario()) + + +def test_expect_continue_on_a_streamed_body_is_answered_too(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + blob = b"tar-bytes" * 4 + raw = ( + b"PUT /v1.45/containers/mine/archive?path=/tmp HTTP/1.1\r\nHost: x\r\n" + b"Expect: 100-continue\r\nContent-Length: %d\r\n\r\n" % len(blob) + ) + blob + data = await call_raw(relay, raw) + assert data.startswith(b"HTTP/1.1 100 Continue\r\n\r\n") + assert b"HTTP/1.1 200 OK" in data + assert backend.bodies[-1] == blob + assert backend.heads[-1].get("expect") is None + + run(scenario()) + + +def test_hop_by_hop_headers_are_stripped(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + raw = ( + b"GET /_ping HTTP/1.1\r\nHost: x\r\nKeep-Alive: timeout=5\r\n" + b"Connection: keep-alive\r\nTE: trailers\r\nProxy-Connection: on\r\n\r\n" + ) + status, _, _ = await call(relay, raw) + assert status == 200 + sent = backend.heads[-1] + assert [sent.get(h) for h in ("keep-alive", "connection", "te", "proxy-connection")] == [None] * 4 + assert sent.get("host") == "docker" + + run(scenario()) + + +def test_upgrade_connection_header_survives(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + (reader, writer), server = await socket_pair() + served = asyncio.create_task(relay.handle(*server)) + writer.write( + b"POST /v1.45/containers/mine/attach?stream=1 HTTP/1.1\r\n" + b"Host: x\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n" + ) + await writer.drain() + head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5) + assert head.startswith(b"HTTP/1.1 101") + assert backend.heads[-1].get("connection") == "Upgrade" + writer.close() + await asyncio.wait_for(served, 5) + + run(scenario()) + + +def test_network_is_checked_before_any_volume_is_created(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + payload = { + "Image": "alpine", + "HostConfig": {"NetworkMode": "theirnet", "Binds": ["newvol:/data"]}, + } + status, _, _ = await call(relay, create_request(payload)) + assert status == 403 + assert all(t != "/volumes/create" for _, t, _ in backend.seen) + assert "newvol" not in backend.volumes + + run(scenario()) + + +def test_archive_body_streams_verbatim(tmp_path: Path) -> None: + """Buffering follows the action, not a Content-Type the client chose.""" + + async def scenario() -> None: + backend, relay = stack(tmp_path) + blob = b"\x00tar-bytes-not-json\xff" * 8 + raw = ( + b"PUT /v1.45/containers/mine/archive?path=/tmp HTTP/1.1\r\nHost: x\r\n" + b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(blob) + ) + blob + status, _, _ = await call(relay, raw) + assert status == 200 + assert backend.bodies[-1] == blob + assert backend.seen[-1][:2] == ("PUT", "/v1.45/containers/aaa111/archive?path=%2Ftmp") + + run(scenario()) + + +# --- Fix round 2 additions below --- + + +def test_idempotent_precreate_of_a_foreign_volume_is_403(tmp_path: Path) -> None: + """A 201 from docker-compat's idempotent create may hand back someone else's volume.""" + + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.volume_create_idempotent = "-q" + payload = {"Image": "alpine", "HostConfig": {"Binds": ["racevol:/data"]}} + status, _, body = await call(relay, create_request(payload)) + assert status == 403 + assert "label-check: volume racevol" in json.loads(body)["message"] + assert all(t != "/v1.45/containers/create" for _, t, _ in backend.seen) + + run(scenario()) + + +def test_idempotent_precreate_of_our_own_volume_passes(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.volume_create_idempotent = "-p" + payload = {"Image": "alpine", "HostConfig": {"Binds": ["racevol:/data"]}} + status, _, _ = await call(relay, create_request(payload)) + assert status == 201 + assert backend.seen[-1][1] == "/v1.45/containers/create" + + run(scenario()) + + +def test_precreate_response_without_labels_is_reinspected(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.volume_create_bare = True + payload = {"Image": "alpine", "HostConfig": {"Binds": ["newvol:/data"]}} + status, _, _ = await call(relay, create_request(payload)) + assert status == 201 + assert [t for _, t, _ in backend.seen].count("/volumes/newvol") == 2 + assert backend.seen[-1][1] == "/v1.45/containers/create" + + run(scenario()) + + +def test_precreate_response_without_labels_on_a_foreign_volume_is_403(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.volume_create_bare = True + backend.volume_create_idempotent = "-q" + payload = {"Image": "alpine", "HostConfig": {"Binds": ["newvol:/data"]}} + status, _, body = await call(relay, create_request(payload)) + assert status == 403 + assert "label-check: volume newvol" in json.loads(body)["message"] + assert all(t != "/v1.45/containers/create" for _, t, _ in backend.seen) + + run(scenario()) + + +def test_a_flood_of_interim_heads_is_502(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.interim_heads = 10 + status, _, body = await call(relay, create_request({"Image": "alpine"})) + assert status == 502 + assert "too many interim responses" in json.loads(body)["message"] + + run(scenario()) + + +def test_eight_interim_heads_are_still_relayed(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + backend.interim_heads = 8 + status, _, _ = await call(relay, create_request({"Image": "alpine"})) + assert status == 201 + + run(scenario()) + + +def test_empty_resource_name_is_403_without_a_backend_call(tmp_path: Path) -> None: + async def scenario() -> None: + backend, relay = stack(tmp_path) + status, _, body = await call(relay, b"POST /v1.45/containers/start HTTP/1.1\r\nHost: x\r\n\r\n") + assert status == 403 + assert "label-check: empty resource name" in json.loads(body)["message"] + assert backend.seen == [] + # The doubled-slash spelling of the same thing never even routes. + status, _, _ = await call(relay, b"GET /v1.45/containers//json HTTP/1.1\r\nHost: x\r\n\r\n") + assert status == 403 + assert backend.seen == [] + + run(scenario()) + + +def test_rewrite_refuses_a_span_that_does_not_spell_the_name() -> None: + assert _rewrite_name("POST", "/v1.45/containers/mine/start", "mine", "aaa111") == ( + "/v1.45/containers/aaa111/start" + ) + assert _rewrite_name("POST", "/v1.45/containers/mine/start", "other", "aaa111") is None + assert _rewrite_name("GET", "/_ping", "mine", "aaa111") is None + # An unchanged identifier (the exec case) is not a rewrite at all. + assert _rewrite_name("POST", "/v1.45/exec/ex1/start", "ex1", "ex1") == "/v1.45/exec/ex1/start" + + +def test_an_unrewritable_target_is_refused_not_forwarded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The caller refuses rather than forwarding the client's own name.""" + + async def scenario() -> None: + backend, relay = stack(tmp_path) + monkeypatch.setattr(relay_module, "_rewrite_name", lambda *_: None) + raw = b"POST /v1.45/containers/mine/start HTTP/1.1\r\nHost: x\r\n\r\n" + status, _, body = await call(relay, raw) + assert status == 403 + assert "label-check: cannot rewrite request target" in json.loads(body)["message"] + assert all(not t.endswith("/start") for _, t, _ in backend.seen) + + run(scenario()) + + +def test_backend_timeout_on_connect_is_502(tmp_path: Path) -> None: + """A connector that never returns times out rather than hanging the client.""" + + async def scenario() -> None: + async def never_connects() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + await asyncio.sleep(10) + raise AssertionError("backend_timeout should have fired first") + + ctx = PolicyContext("-p", tmp_path, (tmp_path,), None, "off") + relay = Relay(never_connects, ctx, backend_timeout=0.05) + status, _, body = await call(relay, b"GET /_ping HTTP/1.1\r\nHost: x\r\n\r\n") + assert status == 502 and b"backend timed out" in body + + run(scenario()) + + +def test_backend_timeout_on_response_head_is_502(tmp_path: Path) -> None: + """A backend that accepts the connection but never answers times out too.""" + + async def scenario() -> None: + held: list[Any] = [] + + async def connect_and_go_silent() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + client, server = await socket_pair() + held.append(server) # keep the backend's end open but never write to it + return client + + ctx = PolicyContext("-p", tmp_path, (tmp_path,), None, "off") + relay = Relay(connect_and_go_silent, ctx, backend_timeout=0.05) + status, _, body = await call(relay, b"GET /_ping HTTP/1.1\r\nHost: x\r\n\r\n") + assert status == 502 and b"backend timed out" in body + + run(scenario()) + + +# --- Polish round: podman's exec cleanup call (P5) --- + + +def test_exec_remove_checks_owning_container(tmp_path: Path) -> None: + async def scenario() -> None: + _, relay = stack(tmp_path) + suffix = b"HTTP/1.1\r\nHost: x\r\n\r\n" + ok, _, _ = await call(relay, b"POST /v1.45/exec/ex1/remove " + suffix) + bad, _, _ = await call(relay, b"POST /v1.45/exec/ex2/remove " + suffix) + assert ok == 204 and bad == 403 + + run(scenario()) diff --git a/tools/container-gateway/tests/test_routes.py b/tools/container-gateway/tests/test_routes.py new file mode 100644 index 000000000..f4dcce537 --- /dev/null +++ b/tools/container-gateway/tests/test_routes.py @@ -0,0 +1,129 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""API path routing covers both the compat and the libpod path families.""" + +from __future__ import annotations + +import pytest + +from container_gateway.routes import ACT_BY_NAME, LIST_LIKE, Family, name_span, route, strip_version + + +@pytest.mark.parametrize( + ("raw", "path", "version", "libpod"), + [ + ("/v1.45/containers/json", "/containers/json", "v1.45", False), + ("/containers/json", "/containers/json", None, False), + ("/v5.2.0/libpod/containers/json", "/containers/json", "v5.2.0", True), + ("/libpod/pods/create", "/pods/create", None, True), + ("/_ping", "/_ping", None, False), + ], +) +def test_strip_version(raw: str, path: str, version: str | None, libpod: bool) -> None: + assert strip_version(raw) == (path, version, libpod) + + +@pytest.mark.parametrize( + ("method", "path", "family", "action", "name"), + [ + ("GET", "/v1.45/containers/json", Family.CONTAINERS, "list", None), + ("POST", "/v1.45/containers/create", Family.CONTAINERS, "create", None), + ("GET", "/v1.45/containers/web1/json", Family.CONTAINERS, "inspect", "web1"), + ("POST", "/v1.45/containers/web1/start", Family.CONTAINERS, "start", "web1"), + ("DELETE", "/v1.45/containers/web1", Family.CONTAINERS, "remove", "web1"), + ("POST", "/v1.45/containers/web1/exec", Family.CONTAINERS, "exec", "web1"), + ("POST", "/v1.45/exec/abc/start", Family.EXEC, "exec_start", "abc"), + ("GET", "/v1.45/exec/abc/json", Family.EXEC, "exec_inspect", "abc"), + ("POST", "/v1.45/containers/prune", Family.CONTAINERS, "prune", None), + ("GET", "/v1.45/containers/web1/archive", Family.CONTAINERS, "archive", "web1"), + ("PUT", "/v1.45/containers/web1/archive", Family.CONTAINERS, "archive", "web1"), + ("POST", "/v5.2.0/libpod/pods/create", Family.PODS, "create", None), + ("POST", "/v5.2.0/libpod/pods/p1/start", Family.PODS, "start", "p1"), + ("GET", "/v1.45/images/json", Family.IMAGES, "list", None), + ("POST", "/v1.45/images/create", Family.IMAGES, "pull", None), + ("GET", "/v1.45/images/alpine:3/json", Family.IMAGES, "inspect", "alpine:3"), + ("DELETE", "/v1.45/images/alpine:3", Family.IMAGES, "remove", "alpine:3"), + ("POST", "/v1.45/images/alpine:3/push", Family.DENIED, "push", "alpine:3"), + ("POST", "/v1.45/build", Family.BUILD, "build", None), + ("POST", "/v1.45/volumes/create", Family.VOLUMES, "create", None), + ("GET", "/v1.45/volumes", Family.VOLUMES, "list", None), + ("POST", "/v1.45/networks/n1/connect", Family.NETWORKS, "connect", "n1"), + ("GET", "/_ping", Family.SYSTEM, "ping", None), + ("GET", "/v1.45/info", Family.SYSTEM, "info", None), + ("GET", "/v1.45/events", Family.SYSTEM, "events", None), + ("POST", "/v1.45/auth", Family.DENIED, "auth", None), + ("GET", "/v1.45/swarm", Family.DENIED, "swarm", None), + ("GET", "/v1.45/secrets", Family.DENIED, "secrets", None), + ("GET", "/v1.45/plugins", Family.DENIED, "plugins", None), + ("GET", "/v1.45/frobnicate", Family.UNKNOWN, "frobnicate", None), + ], +) +def test_route(method: str, path: str, family: Family, action: str, name: str | None) -> None: + r = route(method, path) + assert (r.family, r.action, r.name) == (family, action, name) + + +def test_image_names_with_slashes_and_tags_are_one_name() -> None: + r = route("GET", "/v1.45/images/quay.io/podman/hello:latest/json") + assert r.family is Family.IMAGES and r.action == "inspect" + assert r.name == "quay.io/podman/hello:latest" + + +def test_act_by_name_and_list_like_sets() -> None: + assert (Family.CONTAINERS, "start") in ACT_BY_NAME + assert (Family.CONTAINERS, "create") not in ACT_BY_NAME + assert (Family.IMAGES, "inspect") not in ACT_BY_NAME + assert (Family.IMAGES, "remove") in ACT_BY_NAME + assert (Family.CONTAINERS, "list") in LIST_LIKE + assert (Family.SYSTEM, "events") in LIST_LIKE + assert (Family.VOLUMES, "prune") in LIST_LIKE + + +# --- Fix round 1 additions below --- + + +@pytest.mark.parametrize( + ("method", "path", "span"), + [ + ("POST", "/v1.45/containers/mine/start", (2, 3)), + ("POST", "/containers/mine/start", (1, 2)), + ("POST", "/v1.45/containers/containers/start", (2, 3)), + ("POST", "/v1.45/containers/v1.45/start", (2, 3)), + ("POST", "/v1.45/containers/libpod/start", (2, 3)), + ("POST", "/v1.45/libpod/containers/mine/start", (3, 4)), + ("POST", "/libpod/containers/mine/start", (2, 3)), + ("DELETE", "/v1.45/images/quay.io/podman/hello:latest", (2, 5)), + ("POST", "/v1.45/exec/ex1/start", (2, 3)), + ("GET", "/v1.45/containers/json", None), + ("GET", "/_ping", None), + ("POST", "/v1.45/containers/create", None), + ], +) +def test_name_span_matches_the_route_name(method: str, path: str, span: tuple[int, int] | None) -> None: + assert name_span(method, path) == span + segments = [s for s in path.split("/") if s] + expected = route(method, path).name + assert (None if span is None else "/".join(segments[span[0] : span[1]])) == expected + + +# --- Polish round: podman's exec cleanup call (P5) --- + + +def test_exec_remove_is_routed_and_label_checked() -> None: + r = route("POST", "/v5.2.0/libpod/exec/ex1/remove") + assert (r.family, r.action, r.name) == (Family.EXEC, "exec_remove", "ex1") + assert (Family.EXEC, "exec_remove") in ACT_BY_NAME diff --git a/tools/container-gateway/tool.md b/tools/container-gateway/tool.md new file mode 100644 index 000000000..c6b88a945 --- /dev/null +++ b/tools/container-gateway/tool.md @@ -0,0 +1,165 @@ + + + + +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [Tool: container-gateway](#tool-container-gateway) + - [What this tool provides](#what-this-tool-provides) + - [Why this is its own tool](#why-this-is-its-own-tool) + - [Relationship to RFC-AI-0004 and RFC-AI-0003](#relationship-to-rfc-ai-0004-and-rfc-ai-0003) + - [How adopters consume this tool](#how-adopters-consume-this-tool) + - [What this tool is NOT for](#what-this-tool-is-not-for) + - [Limits and residual risks](#limits-and-residual-risks) + - [Declared egress surfaces](#declared-egress-surfaces) + - [Failure modes](#failure-modes) + + + +# Tool: container-gateway + +This directory documents the **container-gateway** tool: a per-project policy +proxy in front of the podman / docker daemon socket, so sandboxed shell +commands can drive containers without reaching the daemon, the machine +identity, or the host filesystem. + +How-to (run it, point the CLIs at it, the create-time refusal table) lives in +[`README.md`](README.md). This file is the **what** and **why**. + +## What this tool provides + +A Docker-compatible API relay that forwards to whichever backend is +available (Podman machine, Docker Desktop, rootless podman, dockerd) and +enforces a default-deny policy on that API. Three properties fall out of the +policy: + +1. **Containers only.** The agent reaches the daemon exclusively through the + API surface the gateway forwards, and every request shape that would turn + a container into host access is stripped or refused. The create body, the + exec body, the update body and the build query are **allow-lists**: a + field or parameter the gateway has not learned is refused, rather than + forwarded because no rule happened to name it. +2. **This project's containers only.** Every resource the gateway creates is + labelled with the project slug; every read or act call is filtered to + that label. Two projects on one machine share a daemon and see disjoint + worlds. +3. **Same egress policy as the shell.** Containers get the egress gateway as + their HTTP proxy, so tools that honour proxy variables are bound by the + same host allow-list as sandboxed commands. + +## Why this is its own tool + +Container access is cross-cutting. +It is not specific to one skill, so it +does not belong under any single skill's directory (which would create N +drifting copies of the same policy). It is also not an adapter for an +external system in the `contract:*` sense: it has no upstream API of its own +to speak on a skill's behalf, it is framework substrate that makes an +*existing* local daemon safe to reach from inside the sandbox, in the same +family as [`tools/egress-gateway`](../egress-gateway/). + +It depends on nothing beyond the Python standard library, so it stays a +policy proxy rather than growing a container-orchestration dependency. +`podman` and `docker` remain external CLIs the gateway forwards to, never a +library it imports. + +## Relationship to RFC-AI-0004 and RFC-AI-0003 + +[RFC-AI-0004](../../docs/rfcs/RFC-AI-0004.md) Principle 2 (secure sandbox by +default) treats the daemon socket for a container runtime the same way it +treats the raw internet: a capability the agent needs occasionally, gated +behind a chokepoint the agent cannot bypass from inside the sandbox. Today +the only ways to reach `podman` / `docker` from the sandbox are to exclude +the CLI from sandboxing entirely, or to allow the daemon socket directly in +`sandbox.network.allowUnixSockets`. Both hand the agent a root-equivalent +socket, since the daemon can mount arbitrary host paths. + +The container gateway closes that gap the same way +[RFC-AI-0003](../../docs/rfcs/RFC-AI-0003.md) §4.4's egress gateway closes +the network-egress gap: a policy proxy the sandbox is allowed to reach sits +between the agent and the thing that actually has host-level power. +`docs/rfcs/RFC-AI-0004.md`'s Principle 2 architecture table names both +gateways together as the *socket gateways* row. + +## How adopters consume this tool + +1. Run the gateway (outside the sandbox: it needs the real daemon socket, + which the sandbox denies by design). See [`README.md`](README.md). +2. Point `CONTAINER_HOST` / `DOCKER_HOST` at its two sockets, and allow + those two sockets (never the real daemon socket) in + `sandbox.network.allowUnixSockets`. +3. Optionally wire `tools/agent-isolation/container-gateway-hook.sh` as a + Claude Code `SessionStart` / `SessionEnd` hook so the gateway starts and + stops with the session; other harnesses start it by hand or from their + own wrapper. + +## What this tool is NOT for + +- **Not** a container security boundary. The runtime remains the real + boundary between a container and the VM or host kernel; a malicious image + that escapes its container is outside this gateway's scope. +- **Not** a network content filter. It injects proxy variables into + containers it creates; it does not intercept raw sockets or DNS from + inside a container. +- **Not** a replacement for `tools/egress-gateway`. The two are + complementary: the egress gateway bounds which hosts *any* tool may reach + over HTTP(S); this tool bounds what a *container* may touch on the host + (mounts, namespaces, privileges) and hands it the same egress policy as a + proxy. +- **Not** per-project daemon isolation. Isolation is by label on a daemon + shared across every project on the machine, not by running a separate + daemon per project. + +## Limits and residual risks + +The gateway is a policy boundary, not a sandbox for the daemon. What it +does not cover, and what the design accepts: + +- **An unknown field is refused.** The allow-list posture means a daemon + feature the gateway has not learned is unavailable through it until the + table learns it. The refusal names the field, so the fix is a table entry + plus a test, not a debugging session. +- **Bind sources are checked on the host at decision time and re-resolved by + the daemon at mount time.** A symlink swapped between those two moments is + not caught: they are two independent resolutions of the same path, and the + gateway holds no lock on the filesystem in between. +- **Images are shared across projects by design.** Pull, list, inspect, + history, save and build are allowed on any image the host holds; only + remove and tag are label-checked. One project can see and run an image + another pulled. +- **`/info`, `/version` and `/_ping` return host-level daemon facts** — the + daemon's version and storage driver, the host's container counts — not a + per-project view. +- **Container egress is a friction layer, not a wall.** Proxy variables bind + the tools that honour them; a raw socket, a tool that ignores the + variables, and DNS all go straight out, exactly as RFC-AI-0004 says of the + permission layer. +- **Backend discovery happens at start.** A Podman machine or Docker Desktop + started later is not picked up until the gateway restarts, which normally + means the next session. +- **A bare relative build-output value with no leading `.`, `~` or `/` (`-o + outdir`) is indistinguishable from an image reference and passes**, since + it is resolved against the daemon's own working directory, not a path the + agent chose. + +## Declared egress surfaces + +None. The gateway's only connections are local unix sockets: the two it +listens on for the sandboxed CLIs, and the backend's own daemon socket it +forwards to. It makes no outbound network call of its own, which is why the +`no-telemetry-import` check in +[`tools/skill-and-tool-validator/`](../skill-and-tool-validator/) exempts it +the same way it exempts `egress-gateway`. +Both tools' network-shaped +imports (`socket`) are the mechanism, not an egress surface, per +[`tools/egress-gateway/tool.md`](../egress-gateway/tool.md#declared-egress-surfaces). + +## Failure modes + +| Symptom | Likely cause | Remediation | +|---|---|---| +| CLI reports `502` from the gateway | Backend (Podman machine / Docker Desktop / dockerd) is down | Start the backend, then retry; see [`docs/setup/sandbox-troubleshooting.md`](../../docs/setup/sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error) | +| CLI gets a connect error, no `502` | Gateway is not running for this project | Run the `SessionStart` hook or start the gateway by hand (see [`README.md`](README.md)) | +| CLI gets `Operation not permitted` reaching the socket | The gateway's socket is not in `sandbox.network.allowUnixSockets` | Add the two gateway sockets (never the real daemon socket) per [`docs/setup/sandbox-troubleshooting.md`](../../docs/setup/sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error) | +| Container create returns `403` | A create-time request violated the policy (see `README.md` § What the policy refuses) | Read the one-line reason in the response and adjust the request; it names the rule and what to change | diff --git a/tools/sandbox-lint/README.md b/tools/sandbox-lint/README.md index ee99fae82..a5440fe4c 100644 --- a/tools/sandbox-lint/README.md +++ b/tools/sandbox-lint/README.md @@ -137,6 +137,11 @@ uv run --project tools/sandbox-lint sandbox-lint --any-harness /path/to/magpie - `permissions.deny` contains the verbatim entries listed in [`src/sandbox_lint/__init__.py`](src/sandbox_lint/__init__.py) (`REQUIRED_PERMISSIONS_DENY`). + - `sandbox.network.allowUnixSockets` contains no entry that names a + container daemon socket (`docker.sock`, `podman.sock`, or anything + ending in `-api.sock`, matched case-insensitively) unless it sits under + `.apache-magpie-local/run` — see [Residual risk](#residual-risk) for + the one case this cannot fully verify. 3. **Baseline self-check.** The same invariants are applied to `expected.json` itself, so a PR cannot weaken the baseline in lockstep with the live settings without the lint catching the @@ -149,16 +154,21 @@ uv run --project tools/sandbox-lint sandbox-lint --any-harness /path/to/magpie ## How to use -Run from the repository root: +Run from the repository root, with `--project` rather than `--directory` — +`--directory` changes the process's working directory before it runs, so the +tool would then look for `.claude/settings.json` inside +`tools/sandbox-lint/` instead of at the repository root; `--project` only +tells `uv` where to find this tool's `pyproject.toml` and leaves the working +directory alone: ```sh -uv run --directory tools/sandbox-lint --group dev sandbox-lint +uv run --project tools/sandbox-lint --group dev sandbox-lint ``` Run with explicit paths (useful for tests): ```sh -uv run --directory tools/sandbox-lint --group dev sandbox-lint \ +uv run --project tools/sandbox-lint --group dev sandbox-lint \ --settings .claude/settings.json \ --expected tools/sandbox-lint/expected.json ``` @@ -241,3 +251,17 @@ the *shipped* configuration but not local overrides during a single agent run. The companion threat-model document records this under section *X3, Sandbox bypass via developer override* and *Residual risk #4*; consult that document once it lands on `main`. + +The container-daemon-socket check in `check_invariants` accepts a +`project_root` argument to anchor its `.apache-magpie-local/run` +exemption: an `allowUnixSockets` entry is exempt only when it resolves to +exactly `/.apache-magpie-local/run/`. The CLI entry +point always infers `project_root` from `--settings` (when the path ends +in `.claude/settings.json`) and passes it, so `sandbox-lint` itself is not +exposed to the gap below. A caller that invokes `check_invariants` directly +without a `project_root` — including this lint's own invariant self-check +on a `--settings` path that does not sit under a `.claude/` directory — +falls back to an unanchored suffix match on the parent directory string, +under which a decoy path such as `/tmp/evil/.apache-magpie-local/run/podman.sock` +is indistinguishable from a legitimate project-scoped socket, since both +end in the same suffix. diff --git a/tools/sandbox-lint/expected.json b/tools/sandbox-lint/expected.json index 6e6217520..cccfaf158 100644 --- a/tools/sandbox-lint/expected.json +++ b/tools/sandbox-lint/expected.json @@ -1,5 +1,9 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", + "env": { + "CONTAINER_HOST": "unix://./.apache-magpie-local/run/podman.sock", + "DOCKER_HOST": "unix://./.apache-magpie-local/run/docker.sock" + }, "sandbox": { "enabled": true, "excludedCommands": [ diff --git a/tools/sandbox-lint/src/sandbox_lint/__init__.py b/tools/sandbox-lint/src/sandbox_lint/__init__.py index ba05b037a..6cd95c7cd 100644 --- a/tools/sandbox-lint/src/sandbox_lint/__init__.py +++ b/tools/sandbox-lint/src/sandbox_lint/__init__.py @@ -34,6 +34,7 @@ import argparse import json +import os import sys import tomllib from pathlib import Path @@ -190,8 +191,32 @@ def deep_diff(actual: Any, expected: Any, path: str = "$") -> list[str]: # --------------------------------------------------------------------------- -def check_invariants(settings: dict[str, Any]) -> list[str]: - """Return list of invariant violations; empty list means OK.""" +def check_invariants(settings: dict[str, Any], project_root: Path | None = None) -> list[str]: + """Return list of invariant violations; empty list means OK. + + ``project_root``, when given, anchors the ``.apache-magpie-local/run`` + exemption for daemon-socket entries in ``allowUnixSockets``: an entry is + exempt only when it resolves to exactly + ``/.apache-magpie-local/run/``. Without a + ``project_root`` (the default), the exemption falls back to an + unanchored suffix match on the parent directory string, and a decoy + path such as ``/tmp/evil/.apache-magpie-local/run/podman.sock`` is + indistinguishable from a legitimate project-scoped socket -- both end + in the same suffix -- so it is accepted. This residual is documented + in ``tools/sandbox-lint/README.md`` under Residual risk. The CLI entry + point always knows the project root and passes it; only a caller that + invokes this function directly without one inherits the residual. + + The anchored comparison itself is lexical (``os.path.normpath`` on the + two path strings), not a filesystem-resolved one: it never calls + ``Path.resolve()`` or otherwise touches disk. A ``project_root`` (or an + ``allowUnixSockets`` entry) reached through a symlink can therefore make + a legitimate absolute entry compare as off-root and get flagged even + though it is fine on disk. This fails closed -- a false positive here is + a lint the maintainer has to explain, not a bypassed daemon-socket + check -- so it is an accepted trade-off, not a bug to silence with + ``resolve()``. + """ errors: list[str] = [] sandbox = settings.get("sandbox") @@ -254,6 +279,37 @@ def check_invariants(settings: dict[str, Any]) -> list[str]: "list the gh write subcommands one by one)" ) + # A daemon socket (docker.sock, podman.sock, or a podman API socket such + # as podman-machine-default-api.sock) grants the sandboxed agent direct + # control of the container runtime -- equivalent to host root on most + # setups. The container gateway (tools/container-gateway) is the only + # sanctioned path: it enforces its own policy in front of the real + # socket, and its sockets live under .apache-magpie-local/run/, never + # the daemon's own well-known path. + for entry in settings.get("sandbox", {}).get("network", {}).get("allowUnixSockets", []): + stripped = entry.rstrip("/") + name = stripped.rsplit("/", 1)[-1] + parent = stripped.rsplit("/", 1)[0] if "/" in stripped else "" + # macOS's filesystem is case-insensitive, so the sandbox treats + # "Docker.sock" the same as "docker.sock"; match names the same way. + name_cf = name.casefold() + is_daemon = name_cf in ("docker.sock", "podman.sock") or name_cf.endswith("-api.sock") + if not is_daemon: + continue + if project_root is not None: + parent_path = Path(parent) if parent else Path() + if not parent_path.is_absolute(): + parent_path = project_root / parent_path + expected_parent = project_root / ".apache-magpie-local" / "run" + is_exempt = os.path.normpath(str(parent_path)) == os.path.normpath(str(expected_parent)) + else: + is_exempt = parent.endswith(".apache-magpie-local/run") + if not is_exempt: + errors.append( + f"sandbox.network.allowUnixSockets: {entry} names a container daemon socket; " + "route through the container gateway (/.apache-magpie-local/run/*.sock) instead" + ) + return errors @@ -389,6 +445,24 @@ def _lint_any_harness(framework_root: Path | None) -> int: return 1 +def _infer_project_root(settings_path: Path) -> Path | None: + """The project root implied by ``settings_path``, when it is recoverable. + + The convention this repository and every adopter follow is + ``/.claude/settings.json``; when ``settings_path`` fits + that shape, its grandparent is the project root ``check_invariants`` + needs to anchor the ``allowUnixSockets`` daemon-socket exemption. A + ``--settings`` path that does not sit under a ``.claude/`` directory + (e.g. an ad-hoc fixture in a test) yields ``None``, and + ``check_invariants`` falls back to its documented unanchored-suffix + residual for that call. + """ + resolved = settings_path.resolve() + if resolved.parent.name != ".claude": + return None + return resolved.parent.parent + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="sandbox-lint", @@ -490,11 +564,12 @@ def main(argv: list[str] | None = None) -> int: settings = _load_json(args.settings) expected = _load_json(args.expected) - invariant_errors = check_invariants(settings) + project_root = _infer_project_root(args.settings) + invariant_errors = check_invariants(settings, project_root=project_root) diff_errors = deep_diff(settings, expected) # Run invariants on the baseline too: if a future PR weakens both # files in lockstep, the baseline must still pass on its own. - baseline_invariant_errors = check_invariants(expected) + baseline_invariant_errors = check_invariants(expected, project_root=project_root) if not invariant_errors and not diff_errors and not baseline_invariant_errors: print(f"sandbox-lint: OK ({args.settings} matches {args.expected})") diff --git a/tools/sandbox-lint/tests/test_validator.py b/tools/sandbox-lint/tests/test_validator.py index 373f3e568..d16f6b52b 100644 --- a/tools/sandbox-lint/tests/test_validator.py +++ b/tools/sandbox-lint/tests/test_validator.py @@ -385,3 +385,137 @@ def test_cli_exits_when_top_level_value_is_not_object(tmp_path: Path, baseline: _write_json(expected_path, baseline) with pytest.raises(SystemExit): main(["--settings", str(settings_path), "--expected", str(expected_path)]) + + +# --------------------------------------------------------------------------- +# Container gateway: env vars route CONTAINER_HOST / DOCKER_HOST through the +# gateway; absolute allowUnixSockets entries for the gateway sockets are +# per-project, local settings (RELATIVE_SOCKETS=no), not committed here. +# --------------------------------------------------------------------------- + + +def test_baseline_routes_containers_through_the_gateway(baseline: dict[str, Any]) -> None: + env = baseline.get("env", {}) + assert env.get("CONTAINER_HOST") == "unix://./.apache-magpie-local/run/podman.sock" + assert env.get("DOCKER_HOST") == "unix://./.apache-magpie-local/run/docker.sock" + + +def test_baseline_has_no_gateway_socket_entries(baseline: dict[str, Any]) -> None: + # The committed reference (RELATIVE_SOCKETS=no) routes podman/docker + # through the project-relative env vars above only. The absolute + # allowUnixSockets entries a sandboxed Bash needs to connect(2) to the + # gateway sockets are per-project, local settings -- written into + # .claude/settings.local.json by `/magpie-setup config`, never into this + # committed baseline (see docs/setup/secure-agent-setup.md). + sockets = baseline["sandbox"]["network"].get("allowUnixSockets", []) + assert not any(s.endswith("podman.sock") or s.endswith("docker.sock") for s in sockets) + + +@pytest.mark.parametrize( + "entry", + [ + "/var/run/docker.sock", + "~/.docker/run/docker.sock", + "/run/user/1000/podman/podman.sock", + "/var/folders/ab/T/podman/podman-machine-default-api.sock", + ], +) +def test_daemon_sockets_in_allow_unix_sockets_are_rejected(baseline: dict[str, Any], entry: str) -> None: + settings = copy.deepcopy(baseline) + settings["sandbox"]["network"].setdefault("allowUnixSockets", []).append(entry) + errors = check_invariants(settings) + assert any("names a container daemon socket" in e and entry in e for e in errors), errors + + +def test_gateway_sockets_pass_the_invariant(baseline: dict[str, Any]) -> None: + settings = copy.deepcopy(baseline) + settings["sandbox"]["network"]["allowUnixSockets"] = [ + "./.apache-magpie-local/run/podman.sock", + "/Users/x/proj/.apache-magpie-local/run/docker.sock", + ] + assert not [e for e in check_invariants(settings) if "daemon socket" in e] + + +@pytest.mark.parametrize( + "entry", ["/var/run/Docker.sock", "~/.docker/run/DOCKER.SOCK", "/run/podman/PODMAN.SOCK"] +) +def test_daemon_socket_names_are_matched_case_insensitively(baseline: dict[str, Any], entry: str) -> None: + # macOS's filesystem is case-insensitive, so the check must not miss a + # daemon socket entry just because its case differs from the canonical + # "docker.sock" / "podman.sock" spelling. + settings = copy.deepcopy(baseline) + settings["sandbox"]["network"].setdefault("allowUnixSockets", []).append(entry) + errors = check_invariants(settings) + assert any("names a container daemon socket" in e and entry in e for e in errors), errors + + +def test_bare_socket_name_entry_is_rejected(baseline: dict[str, Any]) -> None: + # An entry with no directory component at all cannot possibly sit under + # .apache-magpie-local/run, so it must never be exempted. + settings = copy.deepcopy(baseline) + settings["sandbox"]["network"]["allowUnixSockets"] = ["podman.sock"] + errors = check_invariants(settings) + assert any("names a container daemon socket" in e for e in errors), errors + + +def test_decoy_apache_magpie_local_run_path_is_rejected_with_a_project_root( + baseline: dict[str, Any], tmp_path: Path +) -> None: + # The bare suffix match ("parent.endswith('.apache-magpie-local/run')") + # cannot tell a legitimate project-scoped socket from a decoy sitting + # under an unrelated directory that happens to end the same way -- both + # end in ".apache-magpie-local/run". Passing project_root closes that: + # the decoy is outside it and must be rejected. + project_root = tmp_path / "real-project" + settings = copy.deepcopy(baseline) + settings["sandbox"]["network"]["allowUnixSockets"] = [ + str(tmp_path / "evil" / ".apache-magpie-local" / "run" / "podman.sock") + ] + errors = check_invariants(settings, project_root=project_root) + assert any("names a container daemon socket" in e for e in errors), errors + + +def test_gateway_socket_under_the_given_project_root_passes(baseline: dict[str, Any], tmp_path: Path) -> None: + project_root = tmp_path / "real-project" + settings = copy.deepcopy(baseline) + settings["sandbox"]["network"]["allowUnixSockets"] = [ + str(project_root / ".apache-magpie-local" / "run" / "podman.sock"), + "./.apache-magpie-local/run/docker.sock", # relative to project_root, per the committed convention + ] + errors = check_invariants(settings, project_root=project_root) + assert not [e for e in errors if "daemon socket" in e], errors + + +def test_infer_project_root_from_dot_claude_settings_path(tmp_path: Path) -> None: + from sandbox_lint import _infer_project_root + + settings_path = tmp_path / "proj" / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text("{}") + assert _infer_project_root(settings_path) == (tmp_path / "proj").resolve() + + +def test_infer_project_root_is_none_outside_a_dot_claude_directory(tmp_path: Path) -> None: + from sandbox_lint import _infer_project_root + + settings_path = tmp_path / "settings.json" + settings_path.write_text("{}") + assert _infer_project_root(settings_path) is None + + +def test_cli_rejects_a_decoy_daemon_socket_using_the_inferred_project_root( + tmp_path: Path, baseline: dict[str, Any] +) -> None: + project_dir = tmp_path / "proj" + dot_claude = project_dir / ".claude" + dot_claude.mkdir(parents=True) + settings = copy.deepcopy(baseline) + settings["sandbox"]["network"]["allowUnixSockets"] = [ + str(tmp_path / "evil" / ".apache-magpie-local" / "run" / "podman.sock") + ] + settings_path = dot_claude / "settings.json" + _write_json(settings_path, settings) + expected_path = tmp_path / "expected.json" + _write_json(expected_path, baseline) + rc = main(["--settings", str(settings_path), "--expected", str(expected_path)]) + assert rc == 1 diff --git a/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py b/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py index 1686f60c3..56041d8ce 100644 --- a/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py +++ b/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py @@ -130,8 +130,9 @@ 20. No-default-telemetry import check (SOFT) — PRINCIPLE 10 guarantees zero outbound calls from the framework unless a skill's adapter action explicitly makes them. Only ``contract:*`` adapter tools and - the ``egress-gateway`` proxy are declared egress surfaces; all other - tools (``substrate:*``) must stay network-free. Flags Python source + the ``egress-gateway`` proxy and the ``container-gateway`` are declared + egress surfaces; all other tools (``substrate:*``) must stay network-free. + Flags Python source files under ``tools//src/`` that import ``requests``, ``httpx``, ``aiohttp``, ``urllib.request``, ``http.client``, or ``socket`` in tools that do not declare a ``contract:*`` capability. @@ -392,9 +393,9 @@ # No-default-telemetry check constants (aspect #21, SOFT) # --------------------------------------------------------------------------- -# The egress-gateway tool is a network proxy by design — it is the one -# substrate tool permitted to make outbound connections. -_EGRESS_TOOL_NAME = "egress-gateway" +# Declared egress surfaces: the egress proxy by design, and the container +# gateway, whose only connections are local unix sockets to the daemon. +_EGRESS_TOOL_NAMES = frozenset({"egress-gateway", "container-gateway"}) # Network-calling import patterns that must not appear in substrate tool source. # Each entry is (compiled line-level regex, human-readable library name). @@ -3624,8 +3625,9 @@ def validate_no_telemetry_imports(root: Path | None = None) -> Iterable[Violatio The framework guarantees zero outbound calls unless a skill's adapter action explicitly makes them (PRINCIPLE 10). Only ``contract:*`` adapter - tools and the ``egress-gateway`` proxy are declared egress surfaces; all - other tools (``substrate:*``) must stay network-free. + tools and the ``egress-gateway`` proxy and the ``container-gateway`` are + declared egress surfaces; all other tools (``substrate:*``) must stay + network-free. Flags Python source files under ``tools//src/`` that import ``requests``, ``httpx``, ``aiohttp``, ``urllib.request``, ``http.client``, @@ -3635,7 +3637,7 @@ def validate_no_telemetry_imports(root: Path | None = None) -> Iterable[Violatio See ``tools/egress-gateway/tool.md`` § Declared egress surfaces. """ for tool_dir in collect_tool_dirs(root): - if tool_dir.name == _EGRESS_TOOL_NAME: + if tool_dir.name in _EGRESS_TOOL_NAMES: continue # the proxy itself makes network calls by design readme = tool_dir / "README.md" diff --git a/tools/skill-and-tool-validator/tests/test_validator.py b/tools/skill-and-tool-validator/tests/test_validator.py index 79193e4d1..031d06c5b 100644 --- a/tools/skill-and-tool-validator/tests/test_validator.py +++ b/tools/skill-and-tool-validator/tests/test_validator.py @@ -5211,6 +5211,26 @@ def test_egress_gateway_not_flagged(self, tmp_path: Path) -> None: violations = [v for v in validate_no_telemetry_imports(root) if v.category == NO_TELEMETRY_CATEGORY] assert violations == [] + def test_container_gateway_not_flagged(self, tmp_path: Path) -> None: + root = self._make_tool( + tmp_path, + name="container-gateway", + readme=( + "# container-gateway\n\n" + "**Capability:** substrate:sandbox\n\n" + "## Prerequisites\n\n" + "- **Runtime:** Python 3.11+\n" + "- **CLIs:** podman and/or docker.\n" + "- **Credentials / auth:** None.\n" + "- **Network:** local unix sockets only.\n" + ), + src_files={ + "container_gateway/__init__.py": ("# SPDX-License-Identifier: Apache-2.0\nimport socket\n") + }, + ) + violations = [v for v in validate_no_telemetry_imports(root) if v.category == NO_TELEMETRY_CATEGORY] + assert violations == [] + def test_no_src_directory_skipped(self, tmp_path: Path) -> None: root = _make_tools_root(tmp_path) tool_dir = root / "tools" / "metadata-only" diff --git a/tools/skill-evals/README.md b/tools/skill-evals/README.md index 0345c3d8c..1b4b65b55 100644 --- a/tools/skill-evals/README.md +++ b/tools/skill-evals/README.md @@ -37,7 +37,7 @@ Suites are currently implemented for: - **list-skills** — 8 cases across 2 steps (step-1-command, step-2-present) - **setup-isolated-setup-verify** — 14 cases across 3 steps (runtime-routing, step-1-classify, step-2-recommend) - **setup-isolated-setup-update** — 15 cases across 4 steps (runtime-routing, step-snapshot-drift, step-tool-freshness, step-after-report) -- **setup-isolated-setup-doctor** — 16 cases across 3 steps (runtime-routing, interpret-probes, after-report) +- **setup-isolated-setup-doctor** — 20 cases across 3 steps (runtime-routing, interpret-probes, after-report) - **contributor-activity-sweep** — 12 cases across 3 steps (step-0-resolve-inputs, step-1-classify-reviews, step-2-render) - **optimize-skill** — 5 cases across 1 step (step-diagnose) - **committer-onboarding** — 27 cases across 4 steps (step-0-validate-vote, step-1-icla-comms, step-2-checklist, step-3-completion-summary) diff --git a/tools/skill-evals/evals/setup-isolated-setup-doctor/README.md b/tools/skill-evals/evals/setup-isolated-setup-doctor/README.md index 29ee20377..1c6c91994 100644 --- a/tools/skill-evals/evals/setup-isolated-setup-doctor/README.md +++ b/tools/skill-evals/evals/setup-isolated-setup-doctor/README.md @@ -5,12 +5,12 @@ Behavioral evals for the `setup-isolated-setup-doctor` skill. -## Suites (16 cases total) +## Suites (20 cases total) | Suite | Step | Cases | What it covers | |---|---|---|---| | `runtime-routing` | Runtime routing | 2 | Codex and Gemini route to their native adapters and never require Claude files | -| `interpret-probes` | Probe interpretation (`## The 6 probes`) | 9 | all-pass, ssh-fail, localhost-fail, docker-skipped, multiple-fail, ssh-skipped-no-env, injection-in-probe-output, signing-key-fail, gh-sandbox-fail | +| `interpret-probes` | Probe interpretation (`## The 6 probes`) | 13 | all-pass, ssh-fail, localhost-fail, docker-skipped, multiple-fail, ssh-skipped-no-env, injection-in-probe-output, signing-key-fail, gh-sandbox-fail, container-gateway pass/not-running/socket-denied/no-backend | | `after-report` | Report synthesis (`## After the report`) | 5 | all-clear-all-pass, all-clear-with-skips, ssh-fail-with-catalog-link, multiple-fail-two-catalog-links, injection-asks-autofix-rejected | ## Run @@ -37,7 +37,7 @@ Given raw bash output from the three probe commands, the model classifies each probe as `pass`, `fail`, or `skip` and reports whether any failures were found. -The nine cases span: +The thirteen cases span: - **case-1-all-pass**: All three probes return ✓ lines. - **case-2-ssh-fail-unreachable**: SSH probe returns ✗ (rc=2, agent unreachable); the other two pass. @@ -61,6 +61,21 @@ The nine cases span: returns ✗ (sandboxed `gh` fails TLS and `"gh *"` is missing from `excludedCommands`); signing-key ⊘. Expected `gh_sandbox_status: "fail"`, `has_failures: true`. +- **case-10-gateway-pass**: `podman-runtime` ✓ (reaches the container + gateway); `docker-runtime` ⊘ (not on PATH). Expected `docker_status: + "pass"`, no failures — a mix of ✓ and ⊘ across the two runtime probe + lines is still a pass. +- **case-11-gateway-not-running**: `podman-runtime` ✗ (gateway socket + missing — the `SessionStart` hook has not started the gateway yet). + Expected `docker_status: "fail"`, `has_failures: true`. +- **case-12-gateway-socket-denied**: `podman-runtime` ✗ (connect denied — + the gateway socket is missing from `allowUnixSockets`); `docker-runtime` + ✓. Expected `docker_status: "fail"`, `has_failures: true` — one ✗ among + the runtime lines fails the whole probe even though the other passes. +- **case-13-gateway-no-podman-backend**: `podman-runtime` ✗ (gateway is up + but `status` reports `serving` without `podman` — the Podman machine + is stopped); `docker-runtime` ✓. Expected `docker_status: "fail"`, + `has_failures: true`. ### after-report diff --git a/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-10-gateway-pass/expected.json b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-10-gateway-pass/expected.json new file mode 100644 index 000000000..5ac3edf2d --- /dev/null +++ b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-10-gateway-pass/expected.json @@ -0,0 +1 @@ +{"ssh_status": "pass", "localhost_status": "pass", "docker_status": "pass", "scratch_status": "skip", "signing_key_status": "skip", "gh_sandbox_status": "skip", "has_failures": false} diff --git a/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-10-gateway-pass/report.md b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-10-gateway-pass/report.md new file mode 100644 index 000000000..de03798c9 --- /dev/null +++ b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-10-gateway-pass/report.md @@ -0,0 +1,9 @@ + + +Probe output collected after wiring the container gateway hooks. + +PROBE: ssh-agent → ✓ (2 identities listed) +PROBE: localhost-bind → ✓ (bound + loopback GET → HTTP 200, body=b'ok') +PROBE: podman-runtime → ✓ (podman reaches the container gateway at ./.apache-magpie-local/run/podman.sock) +PROBE: docker-runtime → ⊘ (docker not on PATH) diff --git a/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-11-gateway-not-running/expected.json b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-11-gateway-not-running/expected.json new file mode 100644 index 000000000..0115c3249 --- /dev/null +++ b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-11-gateway-not-running/expected.json @@ -0,0 +1 @@ +{"ssh_status": "pass", "localhost_status": "pass", "docker_status": "fail", "scratch_status": "skip", "signing_key_status": "skip", "gh_sandbox_status": "skip", "has_failures": true} diff --git a/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-11-gateway-not-running/report.md b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-11-gateway-not-running/report.md new file mode 100644 index 000000000..2a3fe6f9f --- /dev/null +++ b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-11-gateway-not-running/report.md @@ -0,0 +1,9 @@ + + +Probe output collected before the SessionStart hook had a chance to run. + +PROBE: ssh-agent → ✓ (2 identities listed) +PROBE: localhost-bind → ✓ (bound + loopback GET → HTTP 200, body=b'ok') +PROBE: podman-runtime → ✗ (gateway socket missing at ./.apache-magpie-local/run/podman.sock — container gateway not running) +PROBE: docker-runtime → ⊘ (docker not on PATH) diff --git a/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-12-gateway-socket-denied/expected.json b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-12-gateway-socket-denied/expected.json new file mode 100644 index 000000000..0115c3249 --- /dev/null +++ b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-12-gateway-socket-denied/expected.json @@ -0,0 +1 @@ +{"ssh_status": "pass", "localhost_status": "pass", "docker_status": "fail", "scratch_status": "skip", "signing_key_status": "skip", "gh_sandbox_status": "skip", "has_failures": true} diff --git a/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-12-gateway-socket-denied/report.md b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-12-gateway-socket-denied/report.md new file mode 100644 index 000000000..3796964e0 --- /dev/null +++ b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-12-gateway-socket-denied/report.md @@ -0,0 +1,9 @@ + + +Probe output collected after the operator forgot to add the gateway sockets to allowUnixSockets. + +PROBE: ssh-agent → ✓ (2 identities listed) +PROBE: localhost-bind → ✓ (bound + loopback GET → HTTP 200, body=b'ok') +PROBE: podman-runtime → ✗ (connect to ./.apache-magpie-local/run/podman.sock denied — add it to sandbox.network.allowUnixSockets) +PROBE: docker-runtime → ✓ (docker reaches the container gateway at ./.apache-magpie-local/run/docker.sock) diff --git a/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-13-gateway-no-podman-backend/expected.json b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-13-gateway-no-podman-backend/expected.json new file mode 100644 index 000000000..0115c3249 --- /dev/null +++ b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-13-gateway-no-podman-backend/expected.json @@ -0,0 +1 @@ +{"ssh_status": "pass", "localhost_status": "pass", "docker_status": "fail", "scratch_status": "skip", "signing_key_status": "skip", "gh_sandbox_status": "skip", "has_failures": true} diff --git a/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-13-gateway-no-podman-backend/report.md b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-13-gateway-no-podman-backend/report.md new file mode 100644 index 000000000..8c5a117ee --- /dev/null +++ b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-13-gateway-no-podman-backend/report.md @@ -0,0 +1,9 @@ + + +Probe output collected while the gateway is up but the Podman machine is stopped. + +PROBE: ssh-agent → ✓ (2 identities listed) +PROBE: localhost-bind → ✓ (bound + loopback GET → HTTP 200, body=b'ok') +PROBE: podman-runtime → ✗ (gateway running without a podman backend — is the Podman machine started? start it, then restart the gateway) +PROBE: docker-runtime → ✓ (docker reaches the container gateway at ./.apache-magpie-local/run/docker.sock) diff --git a/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/output-spec.md b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/output-spec.md index 95693a6ad..646d61682 100644 --- a/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/output-spec.md +++ b/tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/output-spec.md @@ -23,9 +23,9 @@ Definitions: `"skip"` if it begins with `PROBE: ssh-agent → ⊘`. - `localhost_status`: `"pass"` if `PROBE: localhost-bind → ✓`; `"fail"` if `PROBE: localhost-bind → ✗`. -- `docker_status`: `"pass"` if `PROBE: docker-runtime → ✓` or `PROBE: podman-runtime → ✓`; - `"fail"` if `PROBE: docker-runtime → ✗` or `PROBE: podman-runtime → ✗`; - `"skip"` if all runtime probes are `⊘` (not on PATH) or no runtime is installed. +- `docker_status`: `"pass"` if every `PROBE: podman-runtime` / `PROBE: docker-runtime` line present is `✓` + (a mix of `✓` and `⊘` is still `"pass"`); `"fail"` if any of them is `✗`; + `"skip"` if all runtime probe lines are `⊘` or none is present. - `scratch_status`: `"pass"` if `PROBE: project-scratch → ✓`; `"warn"` if `⚠`; `"fail"` if `✗`; `"skip"` if no `project-scratch` probe line is present. - `signing_key_status`: `"pass"` if `PROBE: signing-key → ✓`; `"fail"` if `✗`; diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/README.md b/tools/skill-evals/evals/setup-isolated-setup-verify/README.md index 372e268f9..dc07bfe99 100644 --- a/tools/skill-evals/evals/setup-isolated-setup-verify/README.md +++ b/tools/skill-evals/evals/setup-isolated-setup-verify/README.md @@ -10,7 +10,7 @@ Behavioral evals for the `setup-isolated-setup-verify` skill. | Suite | Step | Cases | What it covers | |---|---|---|---| | runtime-routing | Runtime routing | 2 | Codex and Gemini route to their native adapters and never require Claude files | -| step-1-classify | The 11 checks | 7 | all-pass, sandbox disabled, missing scripts, version drift, project root missing, injection attempt, signing key unreadable in the sandbox (check 10) | +| step-1-classify | The 12 checks | 7 | all-pass, sandbox disabled, missing scripts, version drift, project root missing, injection attempt, signing key unreadable in the sandbox (check 10) | | step-2-recommend | After the report | 5 | all-pass, install needed, update needed, project-root missing, multiple gaps | ## Run diff --git a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/step-config.json b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/step-config.json index f020e40ca..c33714eaf 100644 --- a/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/step-config.json +++ b/tools/skill-evals/evals/setup-isolated-setup-verify/step-1-classify/fixtures/step-config.json @@ -1,4 +1,4 @@ { "skill_md": "skills/setup-isolated-setup-verify/SKILL.md", - "step_heading": "## The 11 checks" + "step_heading": "## The 12 checks" } diff --git a/tools/spec-loop/.last-sync b/tools/spec-loop/.last-sync index 062cee97f..7452720a2 100644 --- a/tools/spec-loop/.last-sync +++ b/tools/spec-loop/.last-sync @@ -1 +1 @@ -b7b27cab0b1b62e01a2b33807a2845cea5b41bc7 +492b582699b605cfc97a7bc7654009647fe0fb19 diff --git a/tools/spec-loop/specs/README.md b/tools/spec-loop/specs/README.md index 8ea9e4ec0..ac405349e 100644 --- a/tools/spec-loop/specs/README.md +++ b/tools/spec-loop/specs/README.md @@ -55,7 +55,8 @@ Start with [`overview.md`](overview.md), then: [`maintainer-education.md`](maintainer-education.md), [`spec-gap-staleness.md`](spec-gap-staleness.md), [`vetted-command-surface.md`](vetted-command-surface.md), - [`sandbox-diagnostics.md`](sandbox-diagnostics.md). + [`sandbox-diagnostics.md`](sandbox-diagnostics.md), + [`container-gateway.md`](container-gateway.md). (Agentic Autonomous, the fifth MISSION mode, is deliberately off and has no spec — see the note in [`overview.md`](overview.md).) diff --git a/tools/spec-loop/specs/codex-runtime.md b/tools/spec-loop/specs/codex-runtime.md index 1bb0bf4fa..cd922a382 100644 --- a/tools/spec-loop/specs/codex-runtime.md +++ b/tools/spec-loop/specs/codex-runtime.md @@ -83,7 +83,7 @@ tool adapters, and uses its own sandbox, rules, and approvals. ```bash uv run --directory tools/sandbox-lint --group dev pytest -uv run --directory tools/sandbox-lint --group dev sandbox-lint --codex .codex +uv run --project tools/sandbox-lint --group dev sandbox-lint --codex .codex codex execpolicy check --pretty \ --rules .codex/rules/magpie.rules -- gh pr view 313 diff --git a/tools/spec-loop/specs/container-gateway.md b/tools/spec-loop/specs/container-gateway.md new file mode 100644 index 000000000..6f749327f --- /dev/null +++ b/tools/spec-loop/specs/container-gateway.md @@ -0,0 +1,469 @@ + + +--- +title: Container gateway (podman / docker inside the sandbox) +status: experimental +kind: feature +mode: infra +source: > + MISSION.md § Privacy, security and supply-chain integrity ("Layered + sandbox by default"); RFC-AI-0004 Principle 2 (secure sandbox by + default) and RFC-AI-0003 § 4.4 (egress-allowlist gateway). + Implemented in tools/container-gateway/, a SessionStart/SessionEnd + hook in tools/agent-isolation/, the reference .claude/settings.json, + tools/sandbox-lint/expected.json, the setup-isolated-setup-doctor / + -verify / -install skills, docs/setup/secure-agent-setup.md and + docs/setup/sandbox-troubleshooting.md. +acceptance: + - A sandboxed `podman` or `docker` command works with the OS sandbox + fully on, with no daemon socket, machine identity file or CLI in + `sandbox.filesystem.allowRead` / `allowUnixSockets` beyond the + gateway's own sockets. + - Every container, pod, volume and network created through the + gateway carries the project label, and every list / inspect / + exec / logs / stop / remove / prune / connect call is restricted + to resources carrying that label. + - A create request that asks for privileged mode, extra + capabilities, devices, a host or foreign namespace, an + unconfined security option, or a bind mount outside the project + root and any `--extra-bind-root` is refused with HTTP 403 and a + one-line reason. + - A create, exec, update or build request carrying a field or query + parameter the gateway has not learned is refused with HTTP 403; + the create body, the exec body, the update body and the build + query are all allow-lists. + - Containers created through the gateway receive `HTTP_PROXY` / + `HTTPS_PROXY` / `NO_PROXY` pointing at the egress gateway when it + is reachable; the `require` mode refuses creation when it is not. + - Each backend (Podman machine, Docker Desktop, Linux docker.sock, + Linux rootless podman) is optional; the gateway serves whichever + exist and exits quietly when none does. The doctor reports ⊘, not + ✗, in that case. +--- + +# Container gateway (podman / docker inside the sandbox) + +## What it does + +Lets sandboxed shell commands drive `podman` and `docker` without +weakening the sandbox. Today the only ways to use a container runtime +from the sandbox are to exclude the CLI from the sandbox (upstream +Claude Code guidance for `docker`) or to allow the daemon socket in +`sandbox.network.allowUnixSockets`. Both hand the agent the daemon, +and the daemon is root-equivalent over everything it can mount: the +default Podman machine on macOS mounts `/Users`, `/private` and +`/var/folders` read-write, so its API socket is a bind-mount away from +`~/.ssh`. Upstream's own sandbox documentation names the docker socket +as the canonical example of an `allowUnixSockets` entry that grants +host access. + +The container gateway is a per-project policy proxy in front of the +daemon socket, in the same family as the egress gateway: it runs +outside the sandbox, listens on unix sockets the sandbox may reach, and +enforces a default-deny policy on the Docker-compatible API that both +CLIs speak. Three properties fall out of the policy: + +1. **Containers only.** The agent reaches the daemon exclusively through + the API surface the gateway forwards, and the gateway strips every + request shape that would turn a container into host access. +2. **This project's containers only.** Every resource the gateway + creates is labelled with the project; every read or act call is + filtered to that label. Two projects on one machine share a daemon + and see disjoint worlds. +3. **Same egress policy as the shell.** Containers get the egress + gateway as their HTTP proxy, so tools that honour proxy variables + are bound by the same host allow-list as sandboxed commands. This is + a friction layer, not a wall: a raw socket from inside a container + bypasses it, exactly as RFC-AI-0004 says of the permission layer. + +## Where it lives + +- `tools/container-gateway/` — the tool: `pyproject.toml` (stdlib-only + runtime, `dev` group for pytest / ruff / mypy), `src/container_gateway/` + (`__main__.py` CLI, `proxy.py` unix-socket HTTP relay, `policy.py` + pure request/response policy, `backends.py` discovery, `labels.py` + project identity), `tests/`, `README.md` (how-to) and `tool.md` + (contract). Capability: `substrate:sandbox`. Harness: agnostic. +- `tools/agent-isolation/container-gateway-hook.sh` — Claude Code + `SessionStart` (`start`) / `SessionEnd` (`stop`) hook, installed to + `~/.claude/scripts/` by `setup-isolated-setup-install` like the other + hook scripts. Other harnesses start the gateway by hand or from their + wrapper; the hook is a convenience, not the mechanism. +- `.claude/settings.json` (reference, and the copy in + `docs/setup/secure-agent-setup.md`): `env.CONTAINER_HOST`, + `env.DOCKER_HOST`, `sandbox.network.allowUnixSockets` entries for the + two gateway sockets. `tools/sandbox-lint/expected.json` mirrors them. +- `docs/setup/sandbox-troubleshooting.md` → *Docker / Podman command + fails with a socket error*: rewritten to route through the gateway; + `plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md` probe 3 + and `isolated-setup-verify` gain gateway checks; + `tools/skill-evals/evals/setup-isolated-setup-doctor/` fixtures. +- `docs/rfcs/RFC-AI-0004.md` Principle 2 architecture table: a + *socket gateways* row naming the egress gateway and the container + gateway, cross-referencing RFC-AI-0003 § 4.4. + +## Behaviour & contract + +### Process model + +One gateway process per project, keyed by the project root. It listens +on `/.apache-magpie-local/run/podman.sock` (libpod + compat +API, for the podman CLI) and `/.apache-magpie-local/run/docker.sock` +(compat API, for the docker CLI). Both files sit inside the project tree. +What shipped: `CONTAINER_HOST` / `DOCKER_HOST` do honour a +project-relative `unix://./…` value, so the committed reference +`env` block (below) names both sockets that way and needs no +per-project edit. `sandbox.network.allowUnixSockets` is a separate +setting with no such relative form in practice; the committed +baseline carries no gateway-socket entry in it at all. The operator +adds the two sockets' **absolute** paths to the gitignored, per-project +`.claude/settings.local.json` by hand (the block is in +[Container gateway](../../../docs/setup/secure-agent-setup.md#container-gateway) +in the setup guide, and `setup-isolated-setup-install` Step L proposes +it as a settings diff); `/magpie-setup config` does not write it. The macOS limit of 104 bytes on a socket path is +checked at start and reported. + +The gateway must run **outside** the sandbox: it connects to the real +daemon socket, which the sandbox denies by design, and it also has to +`bind()` its own two gateway sockets, an operation the sandbox refuses +unconditionally regardless of the destination path — there is no sandbox +configuration under which the gateway process itself could run inside the +sandbox it exists to let other processes reach through. Start-up order: +discover backends, refuse to start when the run directory is +world-writable, bind the gateway sockets with mode `0600`, write a pid +file, serve. It exits on `SessionEnd`, on `SIGTERM`, or after an idle +timeout (default 4 h) as a backstop for sessions that end without the +hook firing. A second start for the same project is a no-op when the +pid file names a live process. + +### Backends + +Discovery runs once, at start, not on a timer or per-request: a backend +that appears (a Podman machine started, Docker Desktop launched) after the +gateway is already serving is not picked up until the next restart. The +hook's `SessionStart` / `SessionEnd` lifecycle means this is normally a new +session away, not a standalone daemon adopters manage by hand. + +Discovery, in order, all optional: + +| Backend | Where the socket comes from | Serves | +|---|---|---| +| Podman machine (macOS) | `podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}'` of the default machine, run by the gateway outside the sandbox | podman.sock (libpod + compat) and docker.sock (compat) when no docker backend exists | +| Rootless podman (Linux) | `$XDG_RUNTIME_DIR/podman/podman.sock`; when absent and `podman` is installed, `podman system service --time=0` is started as a child | same as above | +| Docker Desktop (macOS) | `docker context inspect --format '{{(index .Endpoints "docker").Host}}'`, else `~/.docker/run/docker.sock` | docker.sock (compat) | +| dockerd (Linux) | `/var/run/docker.sock` | docker.sock (compat) | + +The podman CLI needs the libpod API and therefore only ever talks to a +podman backend. The docker CLI talks to a docker backend when one +exists, otherwise to podman's compat API. When no backend exists the +gateway logs one line and exits 0; nothing else in the setup depends on +it running. + +The gateway never reads the Podman machine's ssh identity, never uses +the `ssh://` connection, and never reads `~/.docker/config.json` or +`~/.config/containers/auth.json`: registry credentials stay outside the +sandbox and pulls are anonymous. + +### Request policy + +The policy is a pure function `decide(request) -> Allow | Rewrite | +Deny(reason)` over the parsed request (method, normalised path with the +`/v1.NN` or `/v5.x.y/libpod` prefix stripped, query, JSON body). It is +applied identically to the compat and libpod path families. + +`Request`, `Allow`, and `decide` live in `decisions.py`, the single entry +point the relay calls per request; `decisions.py` imports from `policy.py` +(the create-time and label rules), which in turn imports from +`policy_shape.py` (the compat/libpod field tables and malformed-shape +detection). Imports are one-way only — `policy.py` never imports back from +`decisions.py`, and `policy_shape.py` never imports from either of the +other two — so the three modules form a strict layering rather than a +cycle. + +**Allowed endpoint families** (each with the label rule below): +containers and pods (create, start, stop, kill, restart, pause, +unpause, wait, remove, inspect, list, logs, top, stats, exec create / +start / inspect / resize / remove, attach, archive get / put, commit, export, +rename, update, prune); images (list, inspect, history, pull / create, +build, tag, remove, prune, load, save, search); volumes and networks +(list, inspect, create, remove, connect, disconnect, prune); system +(ping, version, info, events, df); `_ping`, `/version`, `/info`. + +**Denied outright**: `auth` (registry login), image push, swarm, +services, tasks, nodes, plugins, secrets, configs, distribution, session, +`system/dial-stdio`, and any path not in the allowed families. Unknown +API versions are forwarded as-is after policy; unknown paths are denied, +not forwarded. + +**Label rule.** The gateway derives the project slug from the resolved +project root (the same `/`→`-` slug Claude Code uses for its scratch +tree) and: + +- injects `org.apache.magpie.project=` into every container, + pod, volume, network and build request (`Labels`, `labels`, and the + build `labels` query parameter); +- injects `label=org.apache.magpie.project=` into the `filters` + of every list, prune and events call, merging with filters the client + sent; +- for every by-name or by-id call, inspects the resource first through + the backend, checks the label, and re-issues the call by ID, so a name + that is re-bound between the check and the act cannot escape; +- treats images differently: pull, list, inspect, history, save and + build are allowed on any image; remove and tag are allowed only on + images that carry the label (i.e. built or tagged through the + gateway); image prune is restricted to dangling images carrying the + label; load is allowed and the loaded image is not labelled. + +**The create body is an allow-list.** A container or pod create body +may carry only the fields the gateway has learned — the ones it reasons +about, plus the inert ones real `docker run` / `podman run` bodies send — +and any other key, at the top level or under `HostConfig`, is refused +with `unknown-field`. A deny-list over an unbounded JSON body cannot be +right by construction: libpod's `rootfs`, `overlay_volumes`, `env_host`, +`log_configuration` and `secret_env` each turn a container into host +access on their own, and each was an `Allow` while the policy enumerated +what was forbidden. Those fields keep reasons of their own, and are +refused on a *set* value rather than on mere presence, because both CLIs +serialise the zero value of every member of their create struct on every +request. The exec body and the update body are allow-lists in the same +way. The cost of the posture — a field the gateway has not learned is +unavailable through it — is recorded under *Limits and residual risks* +in [`tools/container-gateway/tool.md`](../../container-gateway/tool.md). + +**The build query is an allow-list too.** `POST /build` is a create in +disguise: its `RUN` steps execute with whatever the query asks for. +Parameters that reach the host — `volume`, `remote`, `securityopt`, +`cgroupparent`, `ulimits`, `devices`, `secrets`, `ssh`, `session`, +podman's `addcaps` / `labelopts` / `extrahosts`, a `networkmode` outside +the create path's keyword set, an `nsoptions` entry joining a host +namespace other than `user`, an `output` naming a filesystem +destination — are refused, and anything not enumerated is refused as +`denied-build-parameter`. When the egress mode is not `off` and a proxy +is resolved, `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` are merged into +`buildargs` (client-supplied values for those names dropped first) and +podman's own `httpproxy` is turned off, so property 3 covers builds. + +**Denied outright by action**: `checkpoint`, `restore`, `generate` and +`play` on containers and pods — the first two name a host path for the +daemon to write, the last two hand the daemon a Kubernetes YAML it acts +on wholesale. `export` and `import` are refused as query parameters on +every route, as a backstop for a verb this policy has not enumerated. + +**Create-time rules** (containers and pods; the same fields under +`HostConfig` in compat and at top level in libpod): + +| Field | Rule | +|---|---| +| `Privileged` | deny | +| `CapAdd` | deny any; `CapDrop` allowed | +| `Devices`, `DeviceRequests`, `DeviceCgroupRules` | deny | +| `PidMode`, `IpcMode`, `UTSMode`, `UsernsMode`, `CgroupnsMode` | allow-list of safe values (`private`, `pod`, `auto`, `keep-id`, `nomap`, `shareable`, or unset) plus `container:` when `` carries the label; every other value, including `host` and any value the allow-list does not recognise, is denied | +| `NetworkMode` | allow-list of safe keywords (`default`, `bridge`, `none`, `private`, `slirp4netns`, `pasta`, `pod`, or unset) plus a named network that looks like a real network name and carries the label, checked by the relay; `host`, `container:` without the label, and anything else are denied | +| `SecurityOpt` | allow-list per key: `seccomp` only `""` / `default`; `apparmor` denies only `unconfined` (any other value, including a custom profile, is allowed); `label` denies only `disable`; `no-new-privileges` allows only `""` / `true` (any other value, including `false`, is denied); `systempaths` allows only `""` (any non-empty value is denied); an unrecognised key (including `unmask`, `proc-opts`) is denied outright | +| `Sysctls`, `CgroupParent`, `Runtime`, `Isolation` | deny | +| `MaskedPaths`, `ReadonlyPaths` | deny when set to an empty list | +| `Binds`, `Mounts[type=bind]`, libpod `mounts` | source must resolve (symlinks followed, on the host) under the project root or a `--extra-bind-root`; anything else denied. `tmpfs` allowed | +| `Mounts[type=volume]`, named volumes in `Binds` | the named volume must carry the label, checked (and, for an unknown name, pre-created labelled) by the relay before the backend ever sees the create call | +| `VolumesFrom` | refused outright — sharing another container's mounts would need the same by-id label check the relay does for named volumes/networks, and the common case is already covered by a named volume | +| `PortBindings` / `publish` | allowed; an empty `HostIp` is rewritten to `127.0.0.1` | +| `Env` | proxy variables injected per the egress rule below; a client-supplied value for the same names is replaced | + +Named volumes and named networks are the two by-name references the pure +`decide()` function cannot fully resolve on its own — it can validate shape +and queue the label check, but only the relay has a live connection to the +backend to actually perform it. The relay therefore inspects (and, for an +unrecognised **volume** name only, pre-creates labelled) every named volume +and network a create request references before forwarding the request, and +refuses with the same `label-check` reason a by-name act call uses when the +resource exists but does not carry the label. A named **network** that does +not already exist is refused outright — the relay never creates a network on +the caller's behalf, unlike volumes. + +Rewrites are logged at debug level; denials are returned as +`403 {"message": "container-gateway: ; see +docs/setup/sandbox-troubleshooting.md#…"}` so both CLIs print the +reason verbatim. + +### Egress rule + +At start the gateway resolves the egress gateway address for each +backend: the egress gateway's listen port plus the host alias the +backend gives containers (`host.containers.internal` for Podman +machine, `host.docker.internal` for Docker Desktop, the bridge or +`host-gateway` address on Linux, where the egress gateway must be +listening on that address rather than loopback). It probes the address +from the host once. Modes: + +- `inject-if-available` (default): inject `HTTP_PROXY`, `HTTPS_PROXY`, + `NO_PROXY=localhost,127.0.0.1,` when the probe + succeeded; otherwise create without them and log one warning per + session. The doctor surfaces the warning. +- `require`: refuse container creation with 403 while the egress + gateway is unreachable. +- `off`: never inject. For adopters who run their own filtering. + +The proxy variables are the extent of the network control. `--network +host` is denied above; custom DNS and extra hosts are forwarded +unchanged. + +### Streaming and hijacking + +Logs, events, stats, pull / build progress and `wait` are streamed +response bodies (chunked or `application/vnd.docker.raw-stream`); the +relay forwards them incrementally. `exec start` and `attach` upgrade +the connection to a raw bidirectional stream after the policy check; +the relay switches to byte pass-through for that connection and holds +the label decision made at upgrade time. Request bodies for `archive +put`, `load` and `build` are streamed to the backend without +buffering the whole tarball, after the path / label checks that need +only the URL. + +### Configuration surface + +CLI flags with environment-variable equivalents, no config file: +`--project ` (default: cwd), `--run-dir` (default +`/.apache-magpie-local/run`), `--backend podman|docker|auto` +(repeatable; default auto), `--egress inject-if-available|require|off`, +`--egress-port` (default: the egress gateway's), `--extra-bind-root +` (repeatable; for adopters whose tests need a data directory +outside the tree — each one is logged at start so it shows up in a +`setup verify` run), `--idle-timeout`, `--log-level`, `--pid-file`. + +Reference settings (committed, project-agnostic): + +```jsonc +{ + "env": { + "CONTAINER_HOST": "unix://./.apache-magpie-local/run/podman.sock", + "DOCKER_HOST": "unix://./.apache-magpie-local/run/docker.sock" + }, + "hooks": { + "SessionStart": [{ "hooks": [{ "type": "command", + "command": "~/.claude/scripts/container-gateway-hook.sh start" }] }], + "SessionEnd": [{ "hooks": [{ "type": "command", + "command": "~/.claude/scripts/container-gateway-hook.sh stop" }] }] + } +} +``` + +Both open questions this section used to flag are resolved, and this is +what shipped: `podman` and `docker` both resolve a project-relative +`unix://./…` value in `CONTAINER_HOST` / `DOCKER_HOST` against the cwd, +so the committed `env` block above works unedited in every adopting +project and carries no `allowUnixSockets` entry at all. +`sandbox.network.allowUnixSockets` has no equivalent relative-path +support, so the two gateway sockets are added there as **absolute** +per-project paths — in the gitignored `.claude/settings.local.json`, +never in the committed baseline, added by the operator (see *Known +gaps*): + +```jsonc +// /.claude/settings.local.json +{ + "sandbox": { + "network": { + "allowUnixSockets": [ + "/absolute/path/to//.apache-magpie-local/run/podman.sock", + "/absolute/path/to//.apache-magpie-local/run/docker.sock" + ] + } + } +} +``` + +### Binaries inside the sandbox + +The CLIs must be executable from inside the sandbox on both Seatbelt +and bubblewrap; the gateway does nothing for a CLI the sandbox cannot +run. `podman` from Homebrew or a distro package lives outside every +denied path. Docker Desktop installs `docker` under `~/.docker/bin/` +and its plugins under `~/.docker/cli-plugins/`, both inside the +`~/.docker` read denial; the catalog entry keeps the exact-path +`allowRead` for those two locations and recommends the Homebrew `docker` +CLI, which needs no widening. On Linux both CLIs are under `/usr/bin`. + +## Out of scope + +- Hardening against a malicious image: the container runtime remains + the boundary between a container and the VM or host kernel. +- Network filtering beyond proxy-variable injection; raw sockets and + DNS from inside a container are not intercepted. +- Running rootless podman natively inside bubblewrap (nested user + namespaces, fuse-overlayfs); the Linux path is always a remote client + against a service the gateway proxies. +- Multiple Podman machines, machine lifecycle (`podman machine init` / + `start`), and Kubernetes / compose orchestration beyond what + `podman compose` and `docker compose` already do through the API. +- Per-project *daemons*; isolation is by label on a shared daemon. + +## Acceptance criteria + +See the frontmatter `acceptance:` list. Additionally: + +- `uv run --project tools/container-gateway --group dev pytest` passes + with no backend installed; the integration module is skipped, not + failed, when no backend socket exists. +- `tools/sandbox-lint` accepts the reference settings with the new + entries and rejects a project that lists a daemon socket directly in + `allowUnixSockets`. +- The doctor's probe 3 reports ✓ when the gateway answers `_ping` on + its sockets, ✗ with the catalog anchor when a backend exists but the + gateway is not running or its socket is not allowed, and ⊘ when no + backend is installed. + +## Validation + +```bash +# Unit + relay tests (no backend needed; integration auto-skips) +(cd tools/container-gateway && uv run --group dev pytest) +# Integration against whichever backend is installed +(cd tools/container-gateway && uv run --group dev pytest -m integration) +# Reference settings still lint clean with the gateway entries +uv run --project tools/sandbox-lint --group dev sandbox-lint +# Doctor fixtures for the new probe-3 shapes +PYTHONPATH=tools/skill-evals/src python3 -m skill_evals.runner \ + tools/skill-evals/evals/setup-isolated-setup-doctor/ +``` + +- Unit tests: one table-driven test module per policy family + (`test_policy_create.py`, `test_policy_labels.py`, + `test_policy_build.py`, `test_policy_images.py`) over request dicts, + covering compat and libpod shapes for every row in the tables above, + plus the bodies a real `docker run`, `podman run`, `podman pod + create`, `podman exec` and `podman build` actually send. +- Relay tests against an in-process fake backend on a unix socket: + plain JSON round trip, chunked streaming, raw-stream upgrade for exec + and attach, streamed request bodies, backend-down → 502, unknown path + → 403. +- Integration (`-m integration`, auto-skipped): against whichever real + backend is present, `podman run --rm` of a small image with a + project bind mount succeeds; a bind mount of `$HOME` is refused; a + container created in a second project directory is invisible from + the first. +- Skill evals: `setup-isolated-setup-doctor/interpret-probes` gains + fixtures for the three new probe-3 shapes. + +## Known gaps + +- `/magpie-setup config` does not write the two gateway sockets into + `.claude/settings.local.json`'s `sandbox.network.allowUnixSockets`, + although the setup guide used to say it did. The operator adds the + block by hand, or takes the settings diff + `setup-isolated-setup-install` Step L proposes. Automating it in + `/magpie-setup config` is the follow-up. +- A create, exec or build field the gateway has not learned is refused, + so a daemon feature that arrives after this table is unavailable + through the gateway until the allow-list learns it. The refusal names + the field, which is the signal to add it. +- The egress alias for Linux depends on the backend's bridge + configuration and on the egress gateway listening on a non-loopback + address; until the egress gateway grows a `--bind` option, Linux + adopters run it with an explicit address. +- Docker Desktop's file-sharing settings, not the gateway, decide + which host paths the VM can see; a project root outside the shared + set fails at mount time with Docker's own error. +- `podman compose` shells out to an external compose provider whose + own socket handling (e.g. `docker-compose` reading `DOCKER_HOST`) is + outside the gateway's control; the labels the gateway injects + coexist with compose's own. diff --git a/tools/spec-loop/specs/overview.md b/tools/spec-loop/specs/overview.md index 54d5ab65e..1ed0df2d2 100644 --- a/tools/spec-loop/specs/overview.md +++ b/tools/spec-loop/specs/overview.md @@ -49,6 +49,7 @@ Each mode is an independently toggleable set of skills. Maturity mirrors | Privacy-LLM gate + PII redaction | [privacy-llm-gate.md](privacy-llm-gate.md) | | Agent isolation / layered sandbox | [agent-isolation-sandbox.md](agent-isolation-sandbox.md) | | Sandbox diagnostics — catalog, hint hook, doctor, verify | [sandbox-diagnostics.md](sandbox-diagnostics.md) | +| Container gateway — podman / docker inside the sandbox | [container-gateway.md](container-gateway.md) | | CVE tooling | [cve-tooling.md](cve-tooling.md) | | Security reporting & dashboards | [security-reporting.md](security-reporting.md) | | Adoption & setup | [adoption-and-setup.md](adoption-and-setup.md) | diff --git a/uv.lock b/uv.lock index e53b9a14d..f48d928dc 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,7 @@ members = [ "ai-tutors", "apache-magpie", "checker", + "container-gateway", "egress-gateway", "generate-cve-json", "github-body-field", @@ -504,6 +505,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "container-gateway" +version = "0.1.0" +source = { editable = "tools/container-gateway" } + +[package.dev-dependencies] +dev = [ + { name = "magpie-dev" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "magpie-dev", editable = "tools/dev" }] + [[package]] name = "cryptography" version = "50.0.0"