From a1a4429a8762a6adc6685de7befc4d12cc3c8665 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 18:28:19 +0200 Subject: [PATCH 01/45] spec(container-gateway): design for podman / docker inside the sandbox Add the proposed spec for a per-project container gateway: a policy proxy in front of the podman / docker daemon socket that lets sandboxed shell commands drive containers without exposing the daemon, machine identity or credentials. Every resource is labelled with the project and every call is filtered to that label; create requests that would turn a container into host access are refused; containers get the egress gateway as their HTTP proxy. Both backends are optional. Index the spec in the specs README and overview. Generated-by: Claude Opus 5 --- tools/spec-loop/specs/README.md | 3 +- tools/spec-loop/specs/container-gateway.md | 372 +++++++++++++++++++++ tools/spec-loop/specs/overview.md | 1 + 3 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 tools/spec-loop/specs/container-gateway.md diff --git a/tools/spec-loop/specs/README.md b/tools/spec-loop/specs/README.md index 8ea9e4ec..ac405349 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/container-gateway.md b/tools/spec-loop/specs/container-gateway.md new file mode 100644 index 00000000..cbbfbcc2 --- /dev/null +++ b/tools/spec-loop/specs/container-gateway.md @@ -0,0 +1,372 @@ + + +--- +title: Container gateway (podman / docker inside the sandbox) +status: proposed +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). To be + 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 the project scratch tree is refused with HTTP 403 and a + one-line reason. + - 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 +so the committed reference settings can name them with the +project-relative prefix the sandbox's path syntax supports; if +`allowUnixSockets` turns out not to honour that prefix, `/magpie-setup +config` writes the absolute paths into the gitignored +`.claude/settings.local.json` instead. 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. 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, 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. + +**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, 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. + +**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` | 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 the project scratch tree; anything else denied. `tmpfs` allowed | +| `Mounts[type=volume]`, named volumes in `Binds`, `VolumesFrom` | the volume / container must carry the label | +| `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 | + +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" + }, + "sandbox": { + "network": { + "allowUnixSockets": [ + "./.apache-magpie-local/run/podman.sock", + "./.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" }] }] + } +} +``` + +`CONTAINER_HOST` / `DOCKER_HOST` are resolved by the CLIs relative to +the cwd only if the CLI does so; if either CLI rejects a relative unix +path, the hook exports the absolute form through the same env block in +`.claude/settings.local.json` written by `/magpie-setup config`. The +implementation plan starts with a spike that settles both questions +(relative `allowUnixSockets` entries; relative `unix://` URLs in the +two CLIs) before any settings text is written. + +### 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 --directory 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_paths.py`, `test_policy_images.py`) over request dicts, + covering compat and libpod shapes for every row in the tables above. +- 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 + +- 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 54d5ab65..92a510a3 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 (proposed) | [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) | From 8a7c331ad37912e18a026262d130eab938ecec92 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 19:58:42 +0200 Subject: [PATCH 02/45] feat(container-gateway): scaffold the tool and register it Empty stdlib-only package, README with capability and prerequisites, tool.md contract, workspace membership, capability map and vendor-neutrality rows, and the validator exemption for its unix-socket relay. Generated-by: Claude Opus 5 --- docs/labels-and-capabilities.md | 1 + docs/vendor-neutrality.md | 3 +- pyproject.toml | 1 + tools/container-gateway/.gitignore | 2 + tools/container-gateway/README.md | 114 ++++++++++++++++ tools/container-gateway/pyproject.toml | 58 ++++++++ .../src/container_gateway/__init__.py | 19 +++ tools/container-gateway/tests/__init__.py | 16 +++ tools/container-gateway/tests/test_package.py | 25 ++++ tools/container-gateway/tool.md | 127 ++++++++++++++++++ .../src/skill_and_tool_validator/__init__.py | 18 +-- .../tests/test_validator.py | 20 +++ uv.lock | 16 +++ 13 files changed, 411 insertions(+), 9 deletions(-) create mode 100644 tools/container-gateway/.gitignore create mode 100644 tools/container-gateway/README.md create mode 100644 tools/container-gateway/pyproject.toml create mode 100644 tools/container-gateway/src/container_gateway/__init__.py create mode 100644 tools/container-gateway/tests/__init__.py create mode 100644 tools/container-gateway/tests/test_package.py create mode 100644 tools/container-gateway/tool.md diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md index 61d099ea..d2605462 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/vendor-neutrality.md b/docs/vendor-neutrality.md index cef10247..59ad6deb 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -597,6 +597,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 14, agnostic = 61. |---|---|---|---| | `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/pyproject.toml b/pyproject.toml index 8ec2c7e6..6e08286d 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/container-gateway/.gitignore b/tools/container-gateway/.gitignore new file mode 100644 index 00000000..a230a78a --- /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 00000000..25031b92 --- /dev/null +++ b/tools/container-gateway/README.md @@ -0,0 +1,114 @@ + + + + +**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) + + + +# 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-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 the project scratch tree; anything else denied. `tmpfs` allowed | +| `Mounts[type=volume]`, named volumes in `Binds`, `VolumesFrom` | the volume / container must carry the label | +| `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 | + +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. + +## 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. diff --git a/tools/container-gateway/pyproject.toml b/tools/container-gateway/pyproject.toml new file mode 100644 index 00000000..6ef0b6b4 --- /dev/null +++ b/tools/container-gateway/pyproject.toml @@ -0,0 +1,58 @@ +# 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" +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 00000000..34a2243a --- /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/tests/__init__.py b/tools/container-gateway/tests/__init__.py new file mode 100644 index 00000000..13a83393 --- /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/test_package.py b/tools/container-gateway/tests/test_package.py new file mode 100644 index 00000000..7a34d29c --- /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/tool.md b/tools/container-gateway/tool.md new file mode 100644 index 00000000..115fac66 --- /dev/null +++ b/tools/container-gateway/tool.md @@ -0,0 +1,127 @@ + + + + +**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) + - [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. +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. + +## 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/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 1686f60c..56041d8c 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 79193e4d..031d06c5 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/uv.lock b/uv.lock index e53b9a14..f48d928d 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" From 0068883d1bfd98d09dba57303fd646b83bdc9212 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 20:07:09 +0200 Subject: [PATCH 03/45] docs(container-gateway): drop em dashes from README and tool.md Generated-by: Claude Opus 5 --- tools/container-gateway/README.md | 11 +++++++---- tools/container-gateway/tool.md | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/tools/container-gateway/README.md b/tools/container-gateway/README.md index 25031b92..ae98da2a 100644 --- a/tools/container-gateway/README.md +++ b/tools/container-gateway/README.md @@ -39,7 +39,8 @@ The contract (what / why) is in [`tool.md`](tool.md); this file is the how-to. ## 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. +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 . @@ -59,7 +60,7 @@ 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. +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 @@ -91,7 +92,8 @@ At start the gateway resolves the egress gateway address for each backend and pr `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. +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 @@ -110,5 +112,6 @@ Integration tests (`-m integration`) exercise whichever real backend is installe ## 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. +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. diff --git a/tools/container-gateway/tool.md b/tools/container-gateway/tool.md index 115fac66..319f79fb 100644 --- a/tools/container-gateway/tool.md +++ b/tools/container-gateway/tool.md @@ -18,7 +18,7 @@ # Tool: container-gateway -This directory documents the **container-gateway** tool — a per-project policy +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. @@ -46,7 +46,8 @@ policy: ## Why this is its own tool -Container access is cross-cutting — it is not specific to one skill, so it +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 @@ -55,7 +56,7 @@ to speak on a skill's behalf, it is framework substrate that makes an 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 — +policy proxy rather than growing a container-orchestration dependency. `podman` and `docker` remain external CLIs the gateway forwards to, never a library it imports. @@ -67,7 +68,7 @@ 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 +`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 @@ -79,10 +80,10 @@ 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, +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 + 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 @@ -113,7 +114,8 @@ 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 +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). @@ -123,5 +125,5 @@ imports (`socket`) are the mechanism, not an egress surface, per |---|---|---| | 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) | +| 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 | From dbf65d33e45a95517f720034dbce8b00200b1138 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 20:13:24 +0200 Subject: [PATCH 04/45] feat(container-gateway): project slug, label and filter helpers Generated-by: Claude Opus 5 --- .../src/container_gateway/labels.py | 77 ++++++++++++++++ tools/container-gateway/tests/test_labels.py | 87 +++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 tools/container-gateway/src/container_gateway/labels.py create mode 100644 tools/container-gateway/tests/test_labels.py 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 00000000..68873d8f --- /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/tests/test_labels.py b/tools/container-gateway/tests/test_labels.py new file mode 100644 index 00000000..3d663fe1 --- /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") From f9d59efb6da107982e24050d244a6ddb3feb27b5 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 20:23:18 +0200 Subject: [PATCH 05/45] feat(container-gateway): route compat and libpod API paths Generated-by: Claude Opus 5 --- .../src/container_gateway/routes.py | 244 ++++++++++++++++++ tools/container-gateway/tests/test_routes.py | 93 +++++++ 2 files changed, 337 insertions(+) create mode 100644 tools/container-gateway/src/container_gateway/routes.py create mode 100644 tools/container-gateway/tests/test_routes.py 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 00000000..8b2e0060 --- /dev/null +++ b/tools/container-gateway/src/container_gateway/routes.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. +"""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 == "exec" and rest: + exec_verb = rest[-1] if len(rest) > 1 else "" + action = {"start": "exec_start", "json": "exec_inspect", "resize": "exec_resize"}.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: + assert verb is not None + action = verb + return Route(family, action, name, libpod, version) + + +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")} + | {(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/test_routes.py b/tools/container-gateway/tests/test_routes.py new file mode 100644 index 00000000..ce62dbd3 --- /dev/null +++ b/tools/container-gateway/tests/test_routes.py @@ -0,0 +1,93 @@ +# 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, 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 From d163adc9411c77546b1bd24a449ff4c111888b0a Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 20:33:31 +0200 Subject: [PATCH 06/45] feat(container-gateway): create-time policy rules and rewrites Generated-by: Claude Opus 5 --- .../src/container_gateway/policy.py | 187 ++++++++++++++++++ .../tests/test_policy_create.py | 168 ++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 tools/container-gateway/src/container_gateway/policy.py create mode 100644 tools/container-gateway/tests/test_policy_create.py 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 00000000..f7eb8302 --- /dev/null +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -0,0 +1,187 @@ +# 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. Nothing talks to +the backend; the label pre-check that needs backend I/O lives in relay.py. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .labels import with_label + +CATALOG_ANCHOR = "docs/setup/sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error" +PROXY_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy") + + +@dataclass(frozen=True) +class Deny: + reason: str + status: int = 403 + + @property + def message(self) -> str: + return f"container-gateway: {self.reason}; see {CATALOG_ANCHOR}" + + +@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" + extra: dict[str, Any] = field(default_factory=dict) + + +def _host(body: dict[str, Any], libpod: bool) -> dict[str, Any]: + return body if libpod else body.get("HostConfig") or {} + + +def _nsmode(value: Any) -> str: + """Namespace mode as a string for both shapes: ``"host"`` or ``{"nsmode": "host"}``.""" + if isinstance(value, dict): + return str(value.get("nsmode", "")) + return str(value or "") + + +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 _bind_sources(host: dict[str, Any], body: dict[str, Any], libpod: bool) -> list[str]: + sources: list[str] = [] + for spec in host.get("Binds") or []: + src = str(spec).split(":", 1)[0] + # A bare name (no slash) is a named volume, label-checked by the relay. + if src.startswith(("/", ".", "~")): + sources.append(src) + mounts = body.get("mounts") if libpod else host.get("Mounts") + for m in mounts or []: + if str(m.get("type", m.get("Type", ""))).lower() == "bind": + sources.append(str(m.get("source", m.get("Source", "")))) + return sources + + +_BAD_SECURITY_OPTS = ( + "seccomp=unconfined", + "apparmor=unconfined", + "label=disable", + "no-new-privileges=false", + "systempaths=unconfined", +) + + +def check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> Deny | None: + 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")): + 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 == "host" or mode.startswith("container:"): + return Deny( + f"namespace: {key}={mode} is refused; joining host or foreign namespaces is not allowed" + ) + + net = _nsmode(host.get("NetworkMode") or host.get("netns")) + if net == "host" or net.startswith("container:"): + return Deny( + f"network: NetworkMode={net} is refused; use a bridge network created through the gateway" + ) + + for opt in host.get("SecurityOpt") or host.get("security_opt") or []: + if str(opt).replace(" ", "").lower() in _BAD_SECURITY_OPTS: + return Deny(f"security-opt: {opt} is refused") + 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 "MaskedPaths" in host and host["MaskedPaths"] == []: + return Deny("masked-paths: emptying MaskedPaths is refused") + if "ReadonlyPaths" in host and host["ReadonlyPaths"] == []: + return Deny("readonly-paths: emptying ReadonlyPaths is refused") + + for src in _bind_sources(host, body, libpod): + if 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})") + + 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 + + +def apply_create_rewrites(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> dict[str, Any]: + 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 = dict(out.get("env") or {}) + for k in PROXY_VARS: + env.pop(k, None) + env.update(ctx.proxy_env) + out["env"] = env + else: + out["Labels"] = with_label(out.get("Labels"), ctx.slug) + hc = out.setdefault("HostConfig", {}) + 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] not in PROXY_VARS] + 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/tests/test_policy_create.py b/tools/container-gateway/tests/test_policy_create.py new file mode 100644 index 00000000..a245f0fa --- /dev/null +++ b/tools/container-gateway/tests/test_policy_create.py @@ -0,0 +1,168 @@ +# 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 + + +@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} + + +@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"), + ], +) +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"]) From ef003b086659a5a980b0f89fbfad68bd5e9b736b Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 20:56:38 +0200 Subject: [PATCH 07/45] =?UTF-8?q?fix(container-gateway):=20fail-closed=20c?= =?UTF-8?q?reate=20policy=20=E2=80=94=20canonical=20spellings,=20mount=20t?= =?UTF-8?q?ypes,=20allow-lists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated-by: Claude Opus 5 --- .../src/container_gateway/policy.py | 318 +++++++++++++++--- .../src/container_gateway/policy_shape.py | 234 +++++++++++++ .../tests/test_policy_create.py | 119 ++++++- 3 files changed, 616 insertions(+), 55 deletions(-) create mode 100644 tools/container-gateway/src/container_gateway/policy_shape.py diff --git a/tools/container-gateway/src/container_gateway/policy.py b/tools/container-gateway/src/container_gateway/policy.py index f7eb8302..0538e137 100644 --- a/tools/container-gateway/src/container_gateway/policy.py +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -16,31 +16,58 @@ # 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. Nothing talks to -the backend; the label pre-check that needs backend I/O lives in relay.py. +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. """ from __future__ import annotations import copy -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import Any from .labels import with_label +from .policy_shape import CATALOG_ANCHOR, Deny, canonical_spelling_violation + +__all__ = [ + "CATALOG_ANCHOR", + "PROXY_VARS", + "Deny", + "PolicyContext", + "apply_create_rewrites", + "check_create", + "named_network", + "named_volumes", + "resolve_bind_source", +] -CATALOG_ANCHOR = "docs/setup/sandbox-troubleshooting.md#docker--podman-command-fails-with-a-socket-error" 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"}) -@dataclass(frozen=True) -class Deny: - reason: str - status: int = 403 +# NetworkMode / netns: a fixed set of safe keywords is allowed outright; a +# named network (any other value) is allowed here and left to the relay's +# label check (Task 9); anything that looks like it targets a host or +# foreign namespace is refused regardless of spelling case. +_NETWORK_MODE_KEYWORDS = frozenset({"", "default", "bridge", "none", "private", "slirp4netns", "pasta"}) +_NETWORK_HOST_LIKE_PREFIXES = ("host", "container:", "ns:", "path") + +# SecurityOpt keys the policy recognises at all; every other key (including +# unmask, proc-opts) is refused outright. +_SECCOMP_ALLOWED_VALUES = frozenset({"", "default"}) - @property - def message(self) -> str: - return f"container-gateway: {self.reason}; see {CATALOG_ANCHOR}" +_MOUNT_PASSTHROUGH_TYPES = frozenset({"tmpfs"}) +_MOUNT_REFUSED_TYPES = frozenset({"image", "devpts", "npipe"}) @dataclass(frozen=True) @@ -50,11 +77,13 @@ class PolicyContext: bind_roots: tuple[Path, ...] proxy_env: dict[str, str] | None egress_mode: str = "inject-if-available" - extra: dict[str, Any] = field(default_factory=dict) def _host(body: dict[str, Any], libpod: bool) -> dict[str, Any]: - return body if libpod else body.get("HostConfig") or {} + if libpod: + return body + host_config = body.get("HostConfig") + return host_config if isinstance(host_config, dict) else {} def _nsmode(value: Any) -> str: @@ -64,6 +93,11 @@ def _nsmode(value: Any) -> str: 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) @@ -72,37 +106,204 @@ def resolve_bind_source(src: str, ctx: PolicyContext) -> bool: return any(real == root.resolve() or root.resolve() in real.parents for root in ctx.bind_roots) -def _bind_sources(host: dict[str, Any], body: dict[str, Any], libpod: bool) -> list[str]: - sources: list[str] = [] - for spec in host.get("Binds") or []: - src = str(spec).split(":", 1)[0] - # A bare name (no slash) is a named volume, label-checked by the relay. - if src.startswith(("/", ".", "~")): - sources.append(src) +def _malformed_shape(body: dict[str, Any], libpod: bool) -> Deny | None: + """Type-guard every field the rest of ``check_create`` iterates over. + + Never lets a wrongly-typed field reach an iteration or ``.get()`` call + that would raise; every such shape is refused as ``malformed`` instead. + """ + if not libpod: + 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) + + list_fields: list[tuple[str, Any]] = [] + if libpod: + list_fields.append(("cap_add", host.get("cap_add"))) + list_fields.append(("selinux_opts", host.get("selinux_opts"))) + mounts_field, mounts = "mounts", body.get("mounts") + port_field, ports = "portmappings", body.get("portmappings") + else: + list_fields.append(("CapAdd", host.get("CapAdd"))) + list_fields.append(("SecurityOpt", host.get("SecurityOpt"))) + list_fields.append(("Binds", host.get("Binds"))) + mounts_field, mounts = "Mounts", host.get("Mounts") + port_field, ports = None, None + + for name, value in list_fields: + if value is not None and not isinstance(value, list): + return Deny(f"malformed: {name} has the wrong type") + + if mounts is not None: + if not isinstance(mounts, list): + return Deny(f"malformed: {mounts_field} has the wrong type") + for entry in mounts: + if not isinstance(entry, dict): + return Deny(f"malformed: {mounts_field} entries must be objects") + + if not libpod: + 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(): + if bindings is None: + continue + if not isinstance(bindings, list): + return Deny("malformed: PortBindings has the wrong type") + for entry in bindings: + if not isinstance(entry, dict): + return Deny("malformed: PortBindings entries must be objects") + elif ports is not None: + if not isinstance(ports, list): + return Deny(f"malformed: {port_field} has the wrong type") + for entry in ports: + if not isinstance(entry, dict): + return Deny(f"malformed: {port_field} entries must be objects") + + 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 _security_opt_deny(host: dict[str, Any]) -> Deny | None: + for opt in host.get("SecurityOpt") 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 + + +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": + 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 + return None + + +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 m in mounts or []: - if str(m.get("type", m.get("Type", ""))).lower() == "bind": - sources.append(str(m.get("source", m.get("Source", "")))) - return sources + for entry in mounts or []: + if isinstance(entry, dict) and str(entry.get(type_key, "")).casefold() == "volume": + names.append(str(entry.get(source_key, ""))) + return names -_BAD_SECURITY_OPTS = ( - "seccomp=unconfined", - "apparmor=unconfined", - "label=disable", - "no-new-privileges=false", - "systempaths=unconfined", -) +def named_network(body: dict[str, Any], libpod: bool) -> str | None: + """The named network this create body attaches to, if any (the relay label-checks it, Task 9). + + Returns ``None`` for the fixed keywords and for the host/foreign-namespace + forms that ``check_create`` refuses outright — only an actual named + network is returned. + """ + host = _host(body, libpod) + net = _nsmode(host.get("netns") if libpod else host.get("NetworkMode")) + net_cf = net.casefold() + if net_cf in _NETWORK_MODE_KEYWORDS or net_cf.startswith(_NETWORK_HOST_LIKE_PREFIXES): + return None + return net def check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> Deny | None: + spelling_violation = canonical_spelling_violation(body, libpod) + if spelling_violation is not None: + return spelling_violation + + malformed = _malformed_shape(body, libpod) + if malformed is not None: + return malformed + 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")): + 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 ( @@ -118,20 +319,21 @@ def check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> D "cgroupns", ): mode = _nsmode(host.get(key)) - if mode == "host" or mode.startswith("container:"): + if mode.casefold() not in _NAMESPACE_ALLOWED_MODES: return Deny( - f"namespace: {key}={mode} is refused; joining host or foreign namespaces is not allowed" + f"namespace: {key}={mode} is refused; only private, pod, auto, keep-id and nomap are allowed" ) - net = _nsmode(host.get("NetworkMode") or host.get("netns")) - if net == "host" or net.startswith("container:"): - return Deny( - f"network: NetworkMode={net} is refused; use a bridge network created through the gateway" - ) + for key in ("NetworkMode", "netns"): + net = _nsmode(host.get(key)) + net_cf = net.casefold() + if net_cf not in _NETWORK_MODE_KEYWORDS and net_cf.startswith(_NETWORK_HOST_LIKE_PREFIXES): + return Deny(f"network: {key}={net} is refused; use a bridge network created through the gateway") + + security_opt_deny = _security_opt_deny(host) + if security_opt_deny is not None: + return security_opt_deny - for opt in host.get("SecurityOpt") or host.get("security_opt") or []: - if str(opt).replace(" ", "").lower() in _BAD_SECURITY_OPTS: - return Deny(f"security-opt: {opt} is refused") if host.get("Sysctls") or host.get("sysctl"): return Deny("sysctls: kernel parameters are refused") if host.get("CgroupParent") or host.get("cgroup_parent"): @@ -140,15 +342,21 @@ def check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> D return Deny("runtime: alternative OCI runtimes are refused") if host.get("Isolation"): return Deny("isolation: the Isolation field is refused") - if "MaskedPaths" in host and host["MaskedPaths"] == []: - return Deny("masked-paths: emptying MaskedPaths is refused") - if "ReadonlyPaths" in host and host["ReadonlyPaths"] == []: - return Deny("readonly-paths: emptying ReadonlyPaths is refused") + if "MaskedPaths" in host: + return Deny("masked-paths: MaskedPaths is refused") + if "mask" in host: + return Deny("masked-paths: mask is refused") + if "ReadonlyPaths" in host: + return Deny("readonly-paths: ReadonlyPaths is refused") + if host.get("unmask"): + return Deny("security-opt: unmask is refused") - for src in _bind_sources(host, body, libpod): - if 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})") + 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 @@ -168,9 +376,9 @@ def apply_create_rewrites(body: dict[str, Any], ctx: PolicyContext, *, libpod: b if not pm.get("host_ip"): pm["host_ip"] = "127.0.0.1" if ctx.proxy_env and ctx.egress_mode != "off": - env = dict(out.get("env") or {}) - for k in PROXY_VARS: - env.pop(k, None) + 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 else: @@ -181,7 +389,9 @@ def apply_create_rewrites(body: dict[str, Any], ctx: PolicyContext, *, libpod: b 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] not in PROXY_VARS] + 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 00000000..adcd1d35 --- /dev/null +++ b/tools/container-gateway/src/container_gateway/policy_shape.py @@ -0,0 +1,234 @@ +# 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", + "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", + } +) + +LIBPOD_MOUNT_KEYS = frozenset({"type", "source", "destination", "options"}) + +LIBPOD_PORTMAPPING_KEYS = frozenset({"host_ip", "host_port", "container_port", "protocol", "range"}) + + +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 it (an aliased field the policy would not recognise). + """ + 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 = {k.casefold(): k for k in known} + for cf, keys in by_casefold.items(): + canonical = known_by_casefold.get(cf) + if canonical is not None and keys[0] != canonical: + return Deny(f"ambiguous-field: {keys[0]} is not the canonical spelling of {canonical}") + return None + + +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``, and ``NetworkingConfig`` + if present (collision-only there; the API does not fix its own 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 + 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 + + return None diff --git a/tools/container-gateway/tests/test_policy_create.py b/tools/container-gateway/tests/test_policy_create.py index a245f0fa..369fcaba 100644 --- a/tools/container-gateway/tests/test_policy_create.py +++ b/tools/container-gateway/tests/test_policy_create.py @@ -24,7 +24,14 @@ import pytest from container_gateway.labels import LABEL_KEY -from container_gateway.policy import Deny, PolicyContext, apply_create_rewrites, check_create +from container_gateway.policy import ( + Deny, + PolicyContext, + apply_create_rewrites, + check_create, + named_network, + named_volumes, +) @pytest.fixture @@ -88,6 +95,43 @@ def libpod(**top: Any) -> dict[str, Any]: (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"), ], ) def test_denied_shapes(ctx: PolicyContext, body: dict[str, Any], rule: str) -> None: @@ -166,3 +210,76 @@ 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 + ({"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 + ({"image": "alpine", "env": {}, "Env": {}}, 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, + ), + ], +) +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, 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_network(compat(NetworkMode="mynet"), False) == "mynet" + assert named_network(compat(NetworkMode="host"), False) is None + assert named_network(compat(NetworkMode="container:deadbeef"), False) is None + assert named_network(libpod(netns={"nsmode": "bridge"}), True) is None + + +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"] + + +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"] From 1ce0baf4be468ac07138f48a3c78b9a0ba468ed5 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 21:20:56 +0200 Subject: [PATCH 08/45] fix(container-gateway): close namespace and network escapes; total shape guards Generated-by: Claude Opus 5 --- .../src/container_gateway/policy.py | 295 +++++++++++++----- .../src/container_gateway/policy_shape.py | 41 ++- .../tests/test_policy_create.py | 116 ++++++- 3 files changed, 367 insertions(+), 85 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/policy.py b/tools/container-gateway/src/container_gateway/policy.py index 0538e137..8aaf2d3b 100644 --- a/tools/container-gateway/src/container_gateway/policy.py +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -22,6 +22,17 @@ 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 @@ -41,7 +52,7 @@ "PolicyContext", "apply_create_rewrites", "check_create", - "named_network", + "named_networks", "named_volumes", "resolve_bind_source", ] @@ -55,12 +66,27 @@ # attempt at obfuscation) is refused rather than silently passed through. _NAMESPACE_ALLOWED_MODES = frozenset({"", "private", "pod", "auto", "keep-id", "nomap"}) -# NetworkMode / netns: a fixed set of safe keywords is allowed outright; a -# named network (any other value) is allowed here and left to the relay's -# label check (Task 9); anything that looks like it targets a host or -# foreign namespace is refused regardless of spelling case. -_NETWORK_MODE_KEYWORDS = frozenset({"", "default", "bridge", "none", "private", "slirp4netns", "pasta"}) -_NETWORK_HOST_LIKE_PREFIXES = ("host", "container:", "ns:", "path") +# 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 is 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"} +) +_NETWORK_DENIED_PREFIXES = ("host", "container", "ns", "path", "from-") + +# Built-in network names every project can already reach; never treated as a +# *named* (foreign) network the relay needs to label-check. +_BUILTIN_NETWORK_NAMES = frozenset({"bridge", "podman", "host", "none"}) # SecurityOpt keys the policy recognises at all; every other key (including # unmask, proc-opts) is refused outright. @@ -69,6 +95,55 @@ _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", + "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") +_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") +_STR_LIST_BODY_FIELDS = ("Env",) +_STR_DICT_BODY_FIELDS = ("env", "Labels", "labels") + @dataclass(frozen=True) class PolicyContext: @@ -87,8 +162,16 @@ def _host(body: dict[str, Any], libpod: bool) -> dict[str, Any]: def _nsmode(value: Any) -> str: - """Namespace mode as a string for both shapes: ``"host"`` or ``{"nsmode": "host"}``.""" + """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 "") @@ -106,62 +189,79 @@ def resolve_bind_source(src: str, ctx: PolicyContext) -> bool: 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`` iterates over. + """Type-guard every field the rest of ``check_create`` reads, in both shapes. - Never lets a wrongly-typed field reach an iteration or ``.get()`` call - that would raise; every such shape is refused as ``malformed`` instead. + 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. """ - if not libpod: - 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_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) - list_fields: list[tuple[str, Any]] = [] - if libpod: - list_fields.append(("cap_add", host.get("cap_add"))) - list_fields.append(("selinux_opts", host.get("selinux_opts"))) - mounts_field, mounts = "mounts", body.get("mounts") - port_field, ports = "portmappings", body.get("portmappings") - else: - list_fields.append(("CapAdd", host.get("CapAdd"))) - list_fields.append(("SecurityOpt", host.get("SecurityOpt"))) - list_fields.append(("Binds", host.get("Binds"))) - mounts_field, mounts = "Mounts", host.get("Mounts") - port_field, ports = None, None - - for name, value in list_fields: + 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") - if mounts is not None: - if not isinstance(mounts, list): - return Deny(f"malformed: {mounts_field} has the wrong type") - for entry in mounts: - if not isinstance(entry, dict): - return Deny(f"malformed: {mounts_field} entries must be objects") + 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") - if not libpod: - 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(): - if bindings is None: - continue - if not isinstance(bindings, list): - return Deny("malformed: PortBindings has the wrong type") - for entry in bindings: - if not isinstance(entry, dict): - return Deny("malformed: PortBindings entries must be objects") - elif ports is not None: - if not isinstance(ports, list): - return Deny(f"malformed: {port_field} has the wrong type") - for entry in ports: - if not isinstance(entry, dict): - return Deny(f"malformed: {port_field} entries must be objects") + 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 @@ -253,6 +353,14 @@ def _mounts_deny( return None +def _named_network_candidate(host: dict[str, Any], libpod: bool) -> str | None: + net = _nsmode(host.get("netns") if libpod else host.get("NetworkMode")) + net_cf = net.casefold() + if net_cf in _NETWORK_MODE_KEYWORDS or net_cf.startswith(_NETWORK_DENIED_PREFIXES): + 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) @@ -267,33 +375,69 @@ def named_volumes(body: dict[str, Any], libpod: bool) -> list[str]: for entry in mounts or []: if isinstance(entry, dict) and str(entry.get(type_key, "")).casefold() == "volume": names.append(str(entry.get(source_key, ""))) + 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_network(body: dict[str, Any], libpod: bool) -> str | None: - """The named network this create body attaches to, if any (the relay label-checks it, Task 9). +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). - Returns ``None`` for the fixed keywords and for the host/foreign-namespace - forms that ``check_create`` refuses outright — only an actual named - network is returned. + Covers the ``NetworkMode``/``netns`` named-network value, compat + ``NetworkingConfig.EndpointsConfig`` keys, and libpod top-level + ``networks`` keys. Built-in network names (``bridge``, ``podman``, + ``host``, ``none``) are excluded — ``host``/``none`` are refused + outright by ``check_create`` anyway, and ``bridge``/``podman`` are + reachable by every project already. Deduplicated, first-seen order. """ host = _host(body, libpod) - net = _nsmode(host.get("netns") if libpod else host.get("NetworkMode")) - net_cf = net.casefold() - if net_cf in _NETWORK_MODE_KEYWORDS or net_cf.startswith(_NETWORK_HOST_LIKE_PREFIXES): - return None - return net + candidates: list[str] = [] + + network_mode = _named_network_candidate(host, libpod) + if network_mode is not None: + candidates.append(network_mode) + + if libpod: + networks = body.get("networks") + if isinstance(networks, dict): + candidates.extend(str(k) for k in networks) + else: + networking_config = body.get("NetworkingConfig") + if isinstance(networking_config, dict): + endpoints_config = networking_config.get("EndpointsConfig") + if isinstance(endpoints_config, dict): + candidates.extend(str(k) for k in endpoints_config) + + 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: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> Deny | None: - spelling_violation = canonical_spelling_violation(body, libpod) - if spelling_violation is not None: - return spelling_violation + 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 + host = _host(body, libpod) if host.get("Privileged") or host.get("privileged"): @@ -327,7 +471,7 @@ def check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> D for key in ("NetworkMode", "netns"): net = _nsmode(host.get(key)) net_cf = net.casefold() - if net_cf not in _NETWORK_MODE_KEYWORDS and net_cf.startswith(_NETWORK_HOST_LIKE_PREFIXES): + if net_cf not in _NETWORK_MODE_KEYWORDS and net_cf.startswith(_NETWORK_DENIED_PREFIXES): return Deny(f"network: {key}={net} is refused; use a bridge network created through the gateway") security_opt_deny = _security_opt_deny(host) @@ -342,13 +486,13 @@ def check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> D return Deny("runtime: alternative OCI runtimes are refused") if host.get("Isolation"): return Deny("isolation: the Isolation field is refused") - if "MaskedPaths" in host: + if host.get("MaskedPaths") is not None: return Deny("masked-paths: MaskedPaths is refused") - if "mask" in host: + if host.get("mask") is not None: return Deny("masked-paths: mask is refused") - if "ReadonlyPaths" in host: + if host.get("ReadonlyPaths") is not None: return Deny("readonly-paths: ReadonlyPaths is refused") - if host.get("unmask"): + if host.get("unmask") is not None: return Deny("security-opt: unmask is refused") if host.get("PublishAllPorts") or host.get("publish_image_ports"): @@ -369,6 +513,13 @@ def check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> D 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) diff --git a/tools/container-gateway/src/container_gateway/policy_shape.py b/tools/container-gateway/src/container_gateway/policy_shape.py index adcd1d35..51d47508 100644 --- a/tools/container-gateway/src/container_gateway/policy_shape.py +++ b/tools/container-gateway/src/container_gateway/policy_shape.py @@ -126,6 +126,7 @@ def message(self) -> str: "userns", "cgroupns", "netns", + "networks", "selinux_opts", "apparmor_profile", "seccomp_policy", @@ -148,6 +149,18 @@ def message(self) -> str: 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"}) + def _spelling_violation_in(obj: dict[str, Any], known: frozenset[str]) -> Deny | None: """Ambiguous-spelling check for one object's own keys. @@ -183,8 +196,11 @@ def canonical_spelling_violation(body: dict[str, Any], libpod: bool) -> Deny | N Inspected objects: the top-level body, ``HostConfig`` (compat only), every entry of ``Mounts`` / ``mounts``, every entry of the - ``PortBindings`` value lists / ``portmappings``, and ``NetworkingConfig`` - if present (collision-only there; the API does not fix its own key set). + ``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) @@ -200,6 +216,22 @@ def canonical_spelling_violation(body: dict[str, Any], libpod: bool) -> Deny | N 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 + networks = body.get("networks") + 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) @@ -230,5 +262,10 @@ def canonical_spelling_violation(body: dict[str, Any], libpod: bool) -> Deny | N 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 diff --git a/tools/container-gateway/tests/test_policy_create.py b/tools/container-gateway/tests/test_policy_create.py index 369fcaba..5a9beea4 100644 --- a/tools/container-gateway/tests/test_policy_create.py +++ b/tools/container-gateway/tests/test_policy_create.py @@ -29,7 +29,7 @@ PolicyContext, apply_create_rewrites, check_create, - named_network, + named_networks, named_volumes, ) @@ -132,6 +132,12 @@ def libpod(**top: Any) -> dict[str, Any]: (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"), ], ) def test_denied_shapes(ctx: PolicyContext, body: dict[str, Any], rule: str) -> None: @@ -224,12 +230,13 @@ def test_egress_off_injects_nothing(ctx: PolicyContext) -> None: (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 - ({"Image": "alpine", "HostConfig": {}, "Env": ["A=1"], "env": ["A=1"]}, 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 - ({"image": "alpine", "env": {}, "Env": {}}, 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 @@ -240,6 +247,11 @@ def test_egress_off_injects_nothing(ctx: PolicyContext) -> None: }, 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( @@ -257,13 +269,14 @@ def test_security_opt_allowed_values_pass(ctx: PolicyContext) -> 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, so check_create allows it; the relay - # label-checks the network by name (Task 9). + # 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_network(compat(NetworkMode="mynet"), False) == "mynet" - assert named_network(compat(NetworkMode="host"), False) is None - assert named_network(compat(NetworkMode="container:deadbeef"), False) is None - assert named_network(libpod(netns={"nsmode": "bridge"}), True) 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: @@ -283,3 +296,84 @@ def test_proxy_env_filtered_case_insensitively(ctx: PolicyContext) -> None: 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), + ], +) +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 From 797fee0e2250f10d001f10ddc00b57d4f7d6bc4f Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 21:38:39 +0200 Subject: [PATCH 09/45] fix(container-gateway): fail-closed network classifier for netns and endpoint maps Generated-by: Claude Opus 5 --- .../src/container_gateway/policy.py | 125 ++++++++++++++++-- .../tests/test_policy_create.py | 43 ++++++ 2 files changed, 154 insertions(+), 14 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/policy.py b/tools/container-gateway/src/container_gateway/policy.py index 8aaf2d3b..47080a0f 100644 --- a/tools/container-gateway/src/container_gateway/policy.py +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -38,6 +38,7 @@ from __future__ import annotations import copy +import re from dataclasses import dataclass from pathlib import Path from typing import Any @@ -64,7 +65,7 @@ # 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"}) +_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 @@ -77,16 +78,30 @@ # 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 is a named network, -# allowed here and left to the relay's label check (Task 9). +# 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"} ) _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. +_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`/`default` are real built-in +# networks every project can already reach without a label check. +_NETWORK_HOST_LIKE_KEY_NAMES = frozenset({"host", "none"}) +_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. -_BUILTIN_NETWORK_NAMES = frozenset({"bridge", "podman", "host", "none"}) +_BUILTIN_NETWORK_NAMES = frozenset({"bridge", "podman", "default", "host", "none"}) # SecurityOpt keys the policy recognises at all; every other key (including # unmask, proc-opts) is refused outright. @@ -353,10 +368,64 @@ def _mounts_deny( 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; 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; use a bridge network created through the gateway") + if not _NETWORK_NAME_RE.match(net): + return Deny(f"network: {net} is not a valid network name") + return None + + +def _network_key_deny(source: str, 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``/``default``) 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 malformed-sentinel / denied-prefix / + name-grammar checks apply to everything else. + """ + name_cf = name.casefold() + if name_cf in _ENDPOINT_KEY_ALLOWED_KEYWORDS: + return None + if name == _NSMODE_MALFORMED_SENTINEL: + return Deny(f"network: {source} netns object has unexpected keys") + if name_cf in _NETWORK_HOST_LIKE_KEY_NAMES or name_cf.startswith(_NETWORK_DENIED_PREFIXES): + return Deny(f"network: {source}={name} is refused; use a bridge network created through the gateway") + if not _NETWORK_NAME_RE.match(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_cf.startswith(_NETWORK_DENIED_PREFIXES): + 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.match(net): return None return net @@ -388,9 +457,15 @@ def named_networks(body: dict[str, Any], libpod: bool) -> list[str]: Covers the ``NetworkMode``/``netns`` named-network value, compat ``NetworkingConfig.EndpointsConfig`` keys, and libpod top-level ``networks`` keys. Built-in network names (``bridge``, ``podman``, - ``host``, ``none``) are excluded — ``host``/``none`` are refused - outright by ``check_create`` anyway, and ``bridge``/``podman`` are - reachable by every project already. Deduplicated, first-seen order. + ``default``, ``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``/``default`` are + real built-in networks every project can already reach without a label + check. 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] = [] @@ -465,14 +540,31 @@ def _check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> 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 and nomap are allowed" + f"namespace: {key}={mode} is refused; " + "only private, pod, auto, keep-id, nomap and shareable are allowed" ) for key in ("NetworkMode", "netns"): - net = _nsmode(host.get(key)) - net_cf = net.casefold() - if net_cf not in _NETWORK_MODE_KEYWORDS and net_cf.startswith(_NETWORK_DENIED_PREFIXES): - return Deny(f"network: {key}={net} is refused; use a bridge network created through the gateway") + net_deny = _network_mode_deny(key, _nsmode(host.get(key))) + if net_deny is not None: + return net_deny + + if libpod: + networks = body.get("networks") + if isinstance(networks, dict): + for network_key in networks: + key_deny = _network_key_deny("networks", str(network_key)) + if key_deny is not None: + return key_deny + else: + networking_config = body.get("NetworkingConfig") + if isinstance(networking_config, dict): + endpoints_config = networking_config.get("EndpointsConfig") + if isinstance(endpoints_config, dict): + for network_key in endpoints_config: + key_deny = _network_key_deny("NetworkingConfig.EndpointsConfig", 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: @@ -534,7 +626,12 @@ def apply_create_rewrites(body: dict[str, Any], ctx: PolicyContext, *, libpod: b out["env"] = env else: out["Labels"] = with_label(out.get("Labels"), ctx.slug) - hc = out.setdefault("HostConfig", {}) + # `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"): diff --git a/tools/container-gateway/tests/test_policy_create.py b/tools/container-gateway/tests/test_policy_create.py index 5a9beea4..679f57ed 100644 --- a/tools/container-gateway/tests/test_policy_create.py +++ b/tools/container-gateway/tests/test_policy_create.py @@ -57,6 +57,12 @@ 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"), [ @@ -138,6 +144,19 @@ def libpod(**top: Any) -> dict[str, Any]: (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: @@ -370,6 +389,10 @@ def test_malformed_bodies_never_raise(ctx: PolicyContext, body: Any, libpod: boo (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( @@ -377,3 +400,23 @@ def test_apply_create_rewrites_succeeds_on_every_allowed_body( ) -> 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"] From b20ff84de3b5b8522c5c7fda09f8c699354ab01a Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 22:00:03 +0200 Subject: [PATCH 10/45] fix(container-gateway): network grammar fullmatch, default network label check, both-shape endpoint maps Generated-by: Claude Opus 5 --- .../src/container_gateway/policy.py | 92 +++++++++++-------- .../tests/test_policy_create.py | 48 ++++++++++ 2 files changed, 101 insertions(+), 39 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/policy.py b/tools/container-gateway/src/container_gateway/policy.py index 47080a0f..46842013 100644 --- a/tools/container-gateway/src/container_gateway/policy.py +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -87,21 +87,29 @@ _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. -_NETWORK_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") +# 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`/`default` are real built-in -# networks every project can already reach without a label check. +# 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"}) -_ENDPOINT_KEY_ALLOWED_KEYWORDS = frozenset({"bridge", "podman", "default"}) +_ENDPOINT_KEY_ALLOWED_KEYWORDS = frozenset({"bridge", "podman"}) # Built-in network names every project can already reach; never treated as a -# *named* (foreign) network the relay needs to label-check. -_BUILTIN_NETWORK_NAMES = frozenset({"bridge", "podman", "default", "host", "none"}) +# *named* (foreign) network the relay needs to label-check. `default` is +# deliberately absent — see `_ENDPOINT_KEY_ALLOWED_KEYWORDS` above. +_BUILTIN_NETWORK_NAMES = frozenset({"bridge", "podman", "host", "none"}) # SecurityOpt keys the policy recognises at all; every other key (including # unmask, proc-opts) is refused outright. @@ -371,11 +379,13 @@ def _mounts_deny( def _network_mode_deny(key: str, net: str) -> Deny | None: """Exact classifier for a ``NetworkMode``/``netns`` value. - keyword -> allow; the ``_nsmode`` malformed sentinel -> deny; 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. + 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: @@ -383,30 +393,31 @@ def _network_mode_deny(key: str, net: str) -> Deny | 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; use a bridge network created through the gateway") - if not _NETWORK_NAME_RE.match(net): + 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(source: str, name: str) -> Deny | 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``/``default``) 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 malformed-sentinel / denied-prefix / - name-grammar checks apply to everything else. + 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 == _NSMODE_MALFORMED_SENTINEL: - return Deny(f"network: {source} netns object has unexpected keys") if name_cf in _NETWORK_HOST_LIKE_KEY_NAMES or name_cf.startswith(_NETWORK_DENIED_PREFIXES): - return Deny(f"network: {source}={name} is refused; use a bridge network created through the gateway") - if not _NETWORK_NAME_RE.match(name): + 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 @@ -425,7 +436,7 @@ def _named_network_candidate(host: dict[str, Any], libpod: bool) -> str | None: return None if net_cf.startswith(_NETWORK_DENIED_PREFIXES): return None - if not _NETWORK_NAME_RE.match(net): + if not _NETWORK_NAME_RE.fullmatch(net): return None return net @@ -549,22 +560,25 @@ def _check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> if net_deny is not None: return net_deny - if libpod: - networks = body.get("networks") - if isinstance(networks, dict): - for network_key in networks: - key_deny = _network_key_deny("networks", str(network_key)) + # Read both the libpod and compat endpoint-map shapes 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`. + networks = body.get("networks") + if isinstance(networks, dict): + for network_key in networks: + key_deny = _network_key_deny(str(network_key)) + if key_deny is not None: + return key_deny + + networking_config = body.get("NetworkingConfig") + if isinstance(networking_config, dict): + endpoints_config = networking_config.get("EndpointsConfig") + if isinstance(endpoints_config, dict): + for network_key in endpoints_config: + key_deny = _network_key_deny(str(network_key)) if key_deny is not None: return key_deny - else: - networking_config = body.get("NetworkingConfig") - if isinstance(networking_config, dict): - endpoints_config = networking_config.get("EndpointsConfig") - if isinstance(endpoints_config, dict): - for network_key in endpoints_config: - key_deny = _network_key_deny("NetworkingConfig.EndpointsConfig", 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: diff --git a/tools/container-gateway/tests/test_policy_create.py b/tools/container-gateway/tests/test_policy_create.py index 679f57ed..ea9030fc 100644 --- a/tools/container-gateway/tests/test_policy_create.py +++ b/tools/container-gateway/tests/test_policy_create.py @@ -420,3 +420,51 @@ def test_endpoints_config_and_networks_still_allow_real_names(ctx: PolicyContext 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_named_network_reaches_label_check(ctx: PolicyContext) -> None: + # A user-created network literally named "default" is not the built-in + # default network; it must reach the relay's label check like any other + # named network, not be silently treated as always-reachable. + body = compat_with_endpoints({"default": {}}) + assert check_create(body, ctx, libpod=False) is None + assert named_networks(body, False) == ["default"] + + +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" From 686f611f6a7d7137285d41f33a8f8116c541330e Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 22:02:43 +0200 Subject: [PATCH 11/45] feat(container-gateway): request decisions, label filters, image rules Generated-by: Claude Opus 5 --- .../src/container_gateway/decisions.py | 125 ++++++++++++++++++ .../src/container_gateway/policy.py | 12 ++ .../tests/test_policy_images.py | 72 ++++++++++ .../tests/test_policy_labels.py | 109 +++++++++++++++ 4 files changed, 318 insertions(+) create mode 100644 tools/container-gateway/src/container_gateway/decisions.py create mode 100644 tools/container-gateway/tests/test_policy_images.py create mode 100644 tools/container-gateway/tests/test_policy_labels.py 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 00000000..99966c0e --- /dev/null +++ b/tools/container-gateway/src/container_gateway/decisions.py @@ -0,0 +1,125 @@ +# 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 (Task +5 already left it at 645 lines before this module's ~90-line addition). +``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. + +Imports from ``policy.py`` at module load time, and is in turn imported +*from* ``policy.py`` (at the very bottom of that module, after every name +this module needs is already defined) so ``Allow``, ``Request`` and +``decide`` are re-exported as ``container_gateway.policy`` attributes. +That makes ``policy.py`` the load-bearing entry point for this module: +importing ``container_gateway.decisions`` directly, before anything has +imported ``container_gateway.policy``, would hit the same names mid +circular-import and fail. Always reach these three names through +``container_gateway.policy``. +""" + +from __future__ import annotations + +import json as _json +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote + +from .labels import merge_filters, with_label +from .policy import Deny, PolicyContext, apply_create_rewrites, check_create +from .routes import ACT_BY_NAME, LIST_LIKE, Family, Route +from .routes import route as _route + +__all__ = ["Allow", "Request", "decide"] + + +@dataclass +class Request: + method: str + path: str + query: dict[str, list[str]] + headers: dict[str, str] + body: Any | None + + def raw_target(self) -> str: + if not self.query: + return self.path + pairs = [f"{quote(k, safe='')}={quote(v, safe='')}" for k, vs in self.query.items() for v in vs] + return f"{self.path}?{'&'.join(pairs)}" + + +@dataclass(frozen=True) +class Allow: + request: Request + route: Route + label_check: 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"}) + + +def decide(req: Request, ctx: PolicyContext) -> Allow | Deny: + 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 ((Family.CONTAINERS, "create"), (Family.PODS, "create")): + body = req.body if isinstance(req.body, dict) else {} + denied = check_create(body, ctx, libpod=r.libpod) + if denied: + return denied + req.body = apply_create_rewrites(body, ctx, libpod=r.libpod) + return Allow(req, r) + if key in ((Family.VOLUMES, "create"), (Family.NETWORKS, "create")): + body = dict(req.body) if isinstance(req.body, dict) else {} + k = "labels" if r.libpod or "labels" in body else "Labels" + body[k] = with_label(body.get(k), ctx.slug) + req.body = body + return Allow(req, r) + if r.family is Family.BUILD: + query_labels = req.query.get("labels") + raw: str | None = query_labels[0] if query_labels else None + labels = _json.loads(raw) if raw else {} + req.query["labels"] = [_json.dumps(with_label(labels, ctx.slug), separators=(",", ":"))] + return Allow(req, r) + + if key in LIST_LIKE: + query_filters = req.query.get("filters") + raw = query_filters[0] if query_filters else None + merged = merge_filters(raw, ctx.slug) + if key == (Family.IMAGES, "prune"): + f = _json.loads(merged) + f["dangling"] = ["true"] + merged = _json.dumps(f, separators=(",", ":")) + req.query["filters"] = [merged] + return Allow(req, r) + + 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) + return Allow(req, r) diff --git a/tools/container-gateway/src/container_gateway/policy.py b/tools/container-gateway/src/container_gateway/policy.py index 46842013..ed2bedb7 100644 --- a/tools/container-gateway/src/container_gateway/policy.py +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -49,10 +49,13 @@ __all__ = [ "CATALOG_ANCHOR", "PROXY_VARS", + "Allow", "Deny", "PolicyContext", + "Request", "apply_create_rewrites", "check_create", + "decide", "named_networks", "named_volumes", "resolve_bind_source", @@ -657,3 +660,12 @@ def apply_create_rewrites(body: dict[str, Any], ctx: PolicyContext, *, libpod: b env_list.extend(f"{k}={v}" for k, v in ctx.proxy_env.items()) out["Env"] = env_list return out + + +# Deliberately not at the top of the file: decisions.py imports Deny, +# PolicyContext, check_create and apply_create_rewrites from this module, so +# this import must run after all four are defined above, or the circular +# import between the two modules deadlocks. Re-exported (see __all__) so +# `from container_gateway.policy import Allow, Request, decide` — the shape +# every caller and test in this package uses — keeps working. +from .decisions import Allow, Request, decide # noqa: E402 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 00000000..f56ae466 --- /dev/null +++ b/tools/container-gateway/tests/test_policy_images.py @@ -0,0 +1,72 @@ +# 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.labels import LABEL_KEY +from container_gateway.policy import Allow, Deny, PolicyContext, Request, decide + + +@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 00000000..c9626619 --- /dev/null +++ b/tools/container-gateway/tests/test_policy_labels.py @@ -0,0 +1,109 @@ +# 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.labels import LABEL_KEY +from container_gateway.policy import Allow, Deny, PolicyContext, Request, decide + + +@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" From 943808e0542b03f291cc3400d1755ff17cc703cd Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 19 Sep 2026 22:23:11 +0200 Subject: [PATCH 12/45] =?UTF-8?q?fix(container-gateway):=20fail-closed=20d?= =?UTF-8?q?ecide()=20=E2=80=94=20label=20spelling=20on=20resource=20create?= =?UTF-8?q?,=20malformed=20queries,=20path=20normalisation,=20one-way=20im?= =?UTF-8?q?ports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated-by: Claude Opus 5 --- .../src/container_gateway/decisions.py | 150 ++++++++++++++---- .../src/container_gateway/policy.py | 68 ++++---- .../src/container_gateway/policy_shape.py | 21 +++ tools/container-gateway/tests/test_imports.py | 50 ++++++ .../tests/test_policy_create.py | 15 ++ .../tests/test_policy_images.py | 3 +- .../tests/test_policy_labels.py | 111 ++++++++++++- 7 files changed, 356 insertions(+), 62 deletions(-) create mode 100644 tools/container-gateway/tests/test_imports.py diff --git a/tools/container-gateway/src/container_gateway/decisions.py b/tools/container-gateway/src/container_gateway/decisions.py index 99966c0e..645456a4 100644 --- a/tools/container-gateway/src/container_gateway/decisions.py +++ b/tools/container-gateway/src/container_gateway/decisions.py @@ -16,23 +16,25 @@ # 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 (Task -5 already left it at 645 lines before this module's ~90-line addition). +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. -Imports from ``policy.py`` at module load time, and is in turn imported -*from* ``policy.py`` (at the very bottom of that module, after every name -this module needs is already defined) so ``Allow``, ``Request`` and -``decide`` are re-exported as ``container_gateway.policy`` attributes. -That makes ``policy.py`` the load-bearing entry point for this module: -importing ``container_gateway.decisions`` directly, before anything has -imported ``container_gateway.policy``, would hit the same names mid -circular-import and fail. Always reach these three names through -``container_gateway.policy``. +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 @@ -43,15 +45,40 @@ from urllib.parse import quote from .labels import merge_filters, with_label -from .policy import Deny, PolicyContext, apply_create_rewrites, check_create +from .policy import ( + Deny, + PolicyContext, + apply_create_rewrites, + check_create, + resource_create_spelling_violation, +) from .routes import ACT_BY_NAME, LIST_LIKE, Family, Route from .routes import route as _route __all__ = ["Allow", "Request", "decide"] +# 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]] @@ -59,14 +86,23 @@ class Request: body: Any | None def raw_target(self) -> str: - if not self.query: - return self.path + # 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 @@ -79,7 +115,42 @@ class Allow: _IMAGE_READS = frozenset({"list", "pull", "inspect", "history", "save", "search", "load"}) +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 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") @@ -88,33 +159,47 @@ def decide(req: Request, ctx: PolicyContext) -> Allow | Deny: key = (r.family, r.action) if key in ((Family.CONTAINERS, "create"), (Family.PODS, "create")): - body = req.body if isinstance(req.body, dict) else {} - denied = check_create(body, ctx, libpod=r.libpod) + denied = check_create(req.body, ctx, libpod=r.libpod) if denied: return denied - req.body = apply_create_rewrites(body, ctx, libpod=r.libpod) + # 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")): - body = dict(req.body) if isinstance(req.body, dict) else {} - k = "labels" if r.libpod or "labels" in body else "Labels" + 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 r.family is Family.BUILD: - query_labels = req.query.get("labels") - raw: str | None = query_labels[0] if query_labels else None - labels = _json.loads(raw) if raw else {} + raw_labels = _first(req.query, "labels") + try: + labels = _json.loads(raw_labels) if raw_labels else {} + except _json.JSONDecodeError as exc: + return Deny(f"malformed: build labels is not valid JSON: {exc}") + 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 Allow(req, r) if key in LIST_LIKE: - query_filters = req.query.get("filters") - raw = query_filters[0] if query_filters else None - merged = merge_filters(raw, ctx.slug) - if key == (Family.IMAGES, "prune"): - f = _json.loads(merged) - f["dangling"] = ["true"] - merged = _json.dumps(f, separators=(",", ":")) + 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) @@ -122,4 +207,11 @@ def decide(req: Request, ctx: PolicyContext) -> Allow | Deny: 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/policy.py b/tools/container-gateway/src/container_gateway/policy.py index ed2bedb7..55c5cd35 100644 --- a/tools/container-gateway/src/container_gateway/policy.py +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -44,21 +44,24 @@ from typing import Any from .labels import with_label -from .policy_shape import CATALOG_ANCHOR, Deny, canonical_spelling_violation +from .policy_shape import ( + CATALOG_ANCHOR, + Deny, + canonical_spelling_violation, + resource_create_spelling_violation, +) __all__ = [ "CATALOG_ANCHOR", "PROXY_VARS", - "Allow", "Deny", "PolicyContext", - "Request", "apply_create_rewrites", "check_create", - "decide", "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") @@ -470,16 +473,21 @@ def named_networks(body: dict[str, Any], libpod: bool) -> list[str]: Covers the ``NetworkMode``/``netns`` named-network value, compat ``NetworkingConfig.EndpointsConfig`` keys, and libpod top-level - ``networks`` keys. Built-in network names (``bridge``, ``podman``, - ``default``, ``host``, ``none``) are excluded — ``check_create`` already + ``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``/``default`` are - real built-in networks every project can already reach without a label - check. 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. + `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] = [] @@ -488,16 +496,15 @@ def named_networks(body: dict[str, Any], libpod: bool) -> list[str]: if network_mode is not None: candidates.append(network_mode) - if libpod: - networks = body.get("networks") - if isinstance(networks, dict): - candidates.extend(str(k) for k in networks) - else: - networking_config = body.get("NetworkingConfig") - if isinstance(networking_config, dict): - endpoints_config = networking_config.get("EndpointsConfig") - if isinstance(endpoints_config, dict): - candidates.extend(str(k) for k in endpoints_config) + networks = body.get("networks") + if isinstance(networks, dict): + candidates.extend(str(k) for k in networks) + + networking_config = body.get("NetworkingConfig") + if isinstance(networking_config, dict): + endpoints_config = networking_config.get("EndpointsConfig") + if isinstance(endpoints_config, dict): + candidates.extend(str(k) for k in endpoints_config) seen: set[str] = set() result: list[str] = [] @@ -509,7 +516,15 @@ def named_networks(body: dict[str, Any], libpod: bool) -> list[str]: return result -def check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> Deny | None: +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: @@ -660,12 +675,3 @@ def apply_create_rewrites(body: dict[str, Any], ctx: PolicyContext, *, libpod: b env_list.extend(f"{k}={v}" for k, v in ctx.proxy_env.items()) out["Env"] = env_list return out - - -# Deliberately not at the top of the file: decisions.py imports Deny, -# PolicyContext, check_create and apply_create_rewrites from this module, so -# this import must run after all four are defined above, or the circular -# import between the two modules deadlocks. Re-exported (see __all__) so -# `from container_gateway.policy import Allow, Request, decide` — the shape -# every caller and test in this package uses — keeps working. -from .decisions import Allow, Request, decide # noqa: E402 diff --git a/tools/container-gateway/src/container_gateway/policy_shape.py b/tools/container-gateway/src/container_gateway/policy_shape.py index 51d47508..4dd4a34a 100644 --- a/tools/container-gateway/src/container_gateway/policy_shape.py +++ b/tools/container-gateway/src/container_gateway/policy_shape.py @@ -161,6 +161,11 @@ def message(self) -> str: # 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. @@ -269,3 +274,19 @@ def canonical_spelling_violation(body: dict[str, Any], libpod: bool) -> Deny | N 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) diff --git a/tools/container-gateway/tests/test_imports.py b/tools/container-gateway/tests/test_imports.py new file mode 100644 index 00000000..47226da5 --- /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_policy_create.py b/tools/container-gateway/tests/test_policy_create.py index ea9030fc..cb70d515 100644 --- a/tools/container-gateway/tests/test_policy_create.py +++ b/tools/container-gateway/tests/test_policy_create.py @@ -468,3 +468,18 @@ def test_network_deny_wording_per_position(ctx: PolicyContext) -> None: 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"] diff --git a/tools/container-gateway/tests/test_policy_images.py b/tools/container-gateway/tests/test_policy_images.py index f56ae466..ad5148cd 100644 --- a/tools/container-gateway/tests/test_policy_images.py +++ b/tools/container-gateway/tests/test_policy_images.py @@ -23,8 +23,9 @@ import pytest +from container_gateway.decisions import Allow, Request, decide from container_gateway.labels import LABEL_KEY -from container_gateway.policy import Allow, Deny, PolicyContext, Request, decide +from container_gateway.policy import Deny, PolicyContext @pytest.fixture diff --git a/tools/container-gateway/tests/test_policy_labels.py b/tools/container-gateway/tests/test_policy_labels.py index c9626619..4b93a98c 100644 --- a/tools/container-gateway/tests/test_policy_labels.py +++ b/tools/container-gateway/tests/test_policy_labels.py @@ -23,8 +23,10 @@ import pytest +from container_gateway.decisions import Allow, Request, decide from container_gateway.labels import LABEL_KEY -from container_gateway.policy import Allow, Deny, PolicyContext, Request, decide +from container_gateway.policy import Deny, PolicyContext +from container_gateway.routes import _VERBS, Family @pytest.fixture @@ -107,3 +109,110 @@ def test_ping_and_version_pass_through(ctx: PolicyContext) -> 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": ["[1,2]"]}), + ("/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") + + +@pytest.mark.parametrize("verb", sorted(_VERBS - {"push"})) +@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}"), ctx) + assert isinstance(a, Allow), (path_prefix, verb) + assert a.label_check == name + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("POST", "/v1.45/containers/web1/checkpoint"), + ("POST", "/v1.45/containers/web1/restore"), + ("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 From 078e9d0e83fca539467c90789bd09e31a507a4a3 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 11:05:52 +0200 Subject: [PATCH 13/45] feat(container-gateway): discover podman and docker backends Generated-by: Claude Opus 5 --- .../src/container_gateway/backends.py | 89 ++++++++++++++++++ .../container-gateway/tests/test_backends.py | 94 +++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 tools/container-gateway/src/container_gateway/backends.py create mode 100644 tools/container-gateway/tests/test_backends.py 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 00000000..ce07d80a --- /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/tests/test_backends.py b/tools/container-gateway/tests/test_backends.py new file mode 100644 index 00000000..64936963 --- /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" From 341f525e2dd630e4955cd41b730c6ac8e3bb4dd2 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 11:26:44 +0200 Subject: [PATCH 14/45] feat(container-gateway): HTTP/1.1 framing for the unix-socket relay Generated-by: Claude Opus 5 --- .../src/container_gateway/http.py | 210 ++++++++++++++++++ tools/container-gateway/tests/test_http.py | 187 ++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 tools/container-gateway/src/container_gateway/http.py create mode 100644 tools/container-gateway/tests/test_http.py 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 00000000..1326979a --- /dev/null +++ b/tools/container-gateway/src/container_gateway/http.py @@ -0,0 +1,210 @@ +# 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.""" + +from __future__ import annotations + +import asyncio +import json +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", +} + + +class HttpError(Exception): + pass + + +@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: + 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") + return int(v) if v is not None and v.isdigit() else None + + @property + def chunked(self) -> bool: + return "chunked" in (self.get("transfer-encoding") or "").lower() + + @property + def upgrade(self) -> bool: + return self.get("upgrade") is not None or "upgrade" in (self.get("connection") or "").lower() + + def encode(self) -> bytes: + lines = [self.start_line, *[f"{k}: {v}" for k, v in self.headers], "", ""] + return "\r\n".join(lines).encode("latin-1") + + +async def read_head(reader: asyncio.StreamReader, limit: int = 65536) -> Head | None: + 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") + headers: list[tuple[str, str]] = [] + for line in rest.split("\r\n"): + if not line: + continue + k, sep, v = line.partition(":") + if not sep: + raise HttpError(f"bad header line: {line!r}") + headers.append((k.strip(), v.strip())) + return Head(first, headers) + + +def parse_request_line(line: str) -> tuple[str, str, dict[str, list[str]]]: + parts = line.split(" ") + if len(parts) != 3 or not parts[2].startswith("HTTP/"): + raise HttpError(f"bad request line: {line!r}") + url = urlsplit(parts[1]) + return parts[0].upper(), 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: + size_line = await reader.readuntil(b"\r\n") + size = int(size_line.split(b";", 1)[0].strip() or b"0", 16) + chunk = await reader.readexactly(size + 2) if size else b"" + if sink is not None: + sink.write(size_line + chunk) + await sink.drain() + else: + out += chunk[:-2] + if len(out) > limit: + raise HttpError("body too large") + if size == 0: + # trailers, if any, end with an empty line + while True: + line = await reader.readuntil(b"\r\n") + 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") + return await reader.readexactly(n) if n else b"" + + +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: + break + n -= len(chunk) + writer.write(chunk) + await writer.drain() + + +def _suppress_close(writer: asyncio.StreamWriter) -> None: + try: + if writer.can_write_eof(): + writer.write_eof() + except (OSError, RuntimeError, NotImplementedError): + 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: + _suppress_close(writer) + + +async def pipe( + a_reader: asyncio.StreamReader, + b_writer: asyncio.StreamWriter, + b_reader: asyncio.StreamReader, + a_writer: asyncio.StreamWriter, +) -> None: + """Copy a to b and b to a until either direction closes, then close both.""" + tasks = [asyncio.create_task(_copy(a_reader, b_writer)), asyncio.create_task(_copy(b_reader, a_writer))] + try: + await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + finally: + for t in tasks: + t.cancel() + for w in (a_writer, b_writer): + w.close() + + +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/tests/test_http.py b/tools/container-gateway/tests/test_http.py new file mode 100644 index 00000000..e4e3a241 --- /dev/null +++ b/tools/container-gateway/tests/test_http.py @@ -0,0 +1,187 @@ +# 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 +from collections.abc import Coroutine +from typing import Any, TypeVar + +import pytest + +from container_gateway.http import ( + HttpError, + error_response, + parse_request_line, + pipe, + pump, + read_body, + read_head, +) + +_T = TypeVar("_T") + + +def run(coro: Coroutine[Any, _T, _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 + + +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 + 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") + sink_r, sink_w = await _pair() + await pump(src, sink_w, head) + sink_w.close() + assert await sink_r.read() == b"3\r\nabc\r\n0\r\n\r\n" + + run(scenario()) + + +def test_pipe_is_bidirectional() -> None: + async def scenario() -> None: + a_r, a_w = await _pair() + b_r, b_w = await _pair() + task = asyncio.create_task(pipe(a_r, b_w, b_r, a_w)) + await asyncio.sleep(0) + a_w_peer = a_w # writing into a's writer is read by a_r in this in-memory pair + a_w_peer.write(b"ping") + await a_w_peer.drain() + assert await b_r.read(4) == b"ping" + a_w_peer.close() + await asyncio.wait_for(task, 2) + + 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"} + + +async def _pair() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """An in-memory reader/writer pair: bytes written to the writer are read from the reader. + + ``write``/``close`` hand off to the reader via ``call_soon`` rather than feeding it + synchronously in-line. A synchronous feed lets ``pipe()``'s own opposite-direction copy + task -- which starts reading the *same* reader the test also reads directly -- win the + race for freshly arrived bytes before the test's own ``read()`` call has had a chance to + register as the waiter, so the test observes an empty read instead of the forwarded + payload. Deferring by one loop tick gives whichever caller reaches ``read()`` first (here, + the test) the chance to register before the reader has any data to hand out, which is what + ``pipe`` (unmodified) requires to terminate deterministically in this test. + """ + reader = asyncio.StreamReader() + loop = asyncio.get_running_loop() + + class _Transport(asyncio.Transport): + def __init__(self) -> None: + super().__init__() + self._closing = False + + def write(self, data: bytes) -> None: + if self._closing: + return + loop.call_soon(reader.feed_data, data) + + def close(self) -> None: + if self._closing: + return + self._closing = True + loop.call_soon(reader.feed_eof) + + def is_closing(self) -> bool: + return self._closing + + protocol = asyncio.StreamReaderProtocol(reader) + writer = asyncio.StreamWriter(_Transport(), protocol, reader, loop) + return reader, writer From 881661bf31d164bbe4103f30e24102fa0318841e Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 14:39:32 +0200 Subject: [PATCH 15/45] =?UTF-8?q?fix(container-gateway):=20strict=20HTTP?= =?UTF-8?q?=20framing=20=E2=80=94=20reject=20smuggling=20shapes,=20validat?= =?UTF-8?q?e=20chunks,=20drain=20pipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated-by: Claude Opus 5 --- .../src/container_gateway/http.py | 210 ++++++++++-- tools/container-gateway/tests/test_http.py | 309 +++++++++++++++--- 2 files changed, 453 insertions(+), 66 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/http.py b/tools/container-gateway/src/container_gateway/http.py index 1326979a..400fcfd3 100644 --- a/tools/container-gateway/src/container_gateway/http.py +++ b/tools/container-gateway/src/container_gateway/http.py @@ -15,12 +15,24 @@ # specific language governing permissions and limitations # under the License. -"""Just enough HTTP/1.1 to relay the Docker API over unix streams.""" +"""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 @@ -33,11 +45,69 @@ 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"[0-9]{3}") +_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: ``METHOD SP + target SP HTTP/1.x`` or ``HTTP/1.x SP status SP reason``. Exactly three + space-separated tokens, no control characters anywhere (this also + rejects a tab or an extra space that would otherwise silently shift + which token is which). + """ + _check_no_control_chars(line, "start line") + parts = line.split(" ") + if len(parts) != 3: + raise HttpError(f"bad start line: {line!r}") + if _VERSION_RE.fullmatch(parts[0]): + if not _STATUS_RE.fullmatch(parts[1]): + raise HttpError(f"bad status line: {line!r}") + return + if _VERSION_RE.fullmatch(parts[2]) and _METHOD_RE.fullmatch(parts[0]): + return + raise HttpError(f"bad start line: {line!r}") + + @dataclass class Head: start_line: str @@ -51,6 +121,8 @@ def get(self, name: str) -> str | None: 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)) @@ -61,22 +133,68 @@ def remove(self, name: str) -> None: @property def content_length(self) -> int | None: v = self.get("content-length") - return int(v) if v is not None and v.isdigit() else None + if v is None or not _CONTENT_LENGTH_RE.fullmatch(v): + return None + return int(v) @property def chunked(self) -> bool: - return "chunked" in (self.get("transfer-encoding") or "").lower() + 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 headers even though `set()` and `read_head` + # already validate on the way in, so a `Head` built by appending to + # `.headers` directly (bypassing `set()`) still cannot smuggle a + # control character or a non-latin-1 value out onto the wire. + 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], "", ""] - return "\r\n".join(lines).encode("latin-1") + 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: @@ -89,42 +207,72 @@ async def read_head(reader: asyncio.StreamReader, limit: int = 65536) -> Head | 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}") - headers.append((k.strip(), v.strip())) - return Head(first, headers) + 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 parts[2].startswith("HTTP/"): + 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}") - url = urlsplit(parts[1]) - return parts[0].upper(), url.path or "/", parse_qs(url.query, keep_blank_values=True) + 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: - size_line = await reader.readuntil(b"\r\n") - size = int(size_line.split(b";", 1)[0].strip() or b"0", 16) - chunk = await reader.readexactly(size + 2) if size else b"" + 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 len(out) > limit: - raise HttpError("body too large") + out += chunk[:-2] if chunk else b"" if size == 0: # trailers, if any, end with an empty line while True: - line = await reader.readuntil(b"\r\n") + try: + line = await reader.readuntil(b"\r\n") + except asyncio.IncompleteReadError as exc: + raise HttpError("truncated trailer") from exc if sink is not None: sink.write(line) if line == b"\r\n": @@ -140,7 +288,12 @@ async def read_body(reader: asyncio.StreamReader, head: Head, limit: int) -> byt n = head.content_length or 0 if n > limit: raise HttpError("body too large") - return await reader.readexactly(n) if n else b"" + 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: @@ -157,13 +310,13 @@ async def pump(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, head: while n > 0: chunk = await reader.read(min(65536, n)) if not chunk: - break + raise HttpError("body truncated") n -= len(chunk) writer.write(chunk) await writer.drain() -def _suppress_close(writer: asyncio.StreamWriter) -> None: +def _try_write_eof(writer: asyncio.StreamWriter) -> None: try: if writer.can_write_eof(): writer.write_eof() @@ -177,7 +330,7 @@ async def _copy(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> N writer.write(chunk) await writer.drain() finally: - _suppress_close(writer) + _try_write_eof(writer) async def pipe( @@ -186,15 +339,28 @@ async def pipe( b_reader: asyncio.StreamReader, a_writer: asyncio.StreamWriter, ) -> None: - """Copy a to b and b to a until either direction closes, then close both.""" + """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))] try: - await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + 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: diff --git a/tools/container-gateway/tests/test_http.py b/tools/container-gateway/tests/test_http.py index e4e3a241..5532f5bd 100644 --- a/tools/container-gateway/tests/test_http.py +++ b/tools/container-gateway/tests/test_http.py @@ -27,12 +27,14 @@ 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, @@ -45,7 +47,7 @@ _T = TypeVar("_T") -def run(coro: Coroutine[Any, _T, _T]) -> _T: +def run(coro: Coroutine[Any, Any, _T]) -> _T: """Drive a coroutine to completion without pytest-asyncio.""" return asyncio.run(coro) @@ -57,6 +59,71 @@ def reader_of(data: bytes) -> asyncio.StreamReader: 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 = ( @@ -83,6 +150,18 @@ async def scenario() -> None: 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")) @@ -116,26 +195,47 @@ 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") - sink_r, sink_w = await _pair() + (verify_r, verify_w), (_sink_r, sink_w) = await _socket_pair() await pump(src, sink_w, head) sink_w.close() - assert await sink_r.read() == b"3\r\nabc\r\n0\r\n\r\n" + 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: - a_r, a_w = await _pair() - b_r, b_w = await _pair() - task = asyncio.create_task(pipe(a_r, b_w, b_r, a_w)) - await asyncio.sleep(0) - a_w_peer = a_w # writing into a's writer is read by a_r in this in-memory pair - a_w_peer.write(b"ping") - await a_w_peer.drain() - assert await b_r.read(4) == b"ping" - a_w_peer.close() - await asyncio.wait_for(task, 2) + (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()) @@ -148,40 +248,161 @@ def test_error_response_shape() -> None: assert json.loads(body) == {"message": "container-gateway: nope"} -async def _pair() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: - """An in-memory reader/writer pair: bytes written to the writer are read from the reader. +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()) - ``write``/``close`` hand off to the reader via ``call_soon`` rather than feeding it - synchronously in-line. A synchronous feed lets ``pipe()``'s own opposite-direction copy - task -- which starts reading the *same* reader the test also reads directly -- win the - race for freshly arrived bytes before the test's own ``read()`` call has had a chance to - register as the waiter, so the test observes an empty read instead of the forwarded - payload. Deferring by one loop tick gives whichever caller reaches ``read()`` first (here, - the test) the chance to register before the reader has any data to hand out, which is what - ``pipe`` (unmodified) requires to terminate deterministically in this test. - """ - reader = asyncio.StreamReader() - loop = asyncio.get_running_loop() - class _Transport(asyncio.Transport): - def __init__(self) -> None: - super().__init__() - self._closing = False +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 write(self, data: bytes) -> None: - if self._closing: - return - loop.call_soon(reader.feed_data, data) - def close(self) -> None: - if self._closing: - return - self._closing = True - loop.call_soon(reader.feed_eof) +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) - def is_closing(self) -> bool: - return self._closing + run(scenario()) - protocol = asyncio.StreamReaderProtocol(reader) - writer = asyncio.StreamWriter(_Transport(), protocol, reader, loop) - return reader, writer + +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()) From ecc52bf09a3d6d501a54089d160165b7acf27e55 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 14:55:08 +0200 Subject: [PATCH 16/45] fix(container-gateway): parse multi-word status reasons; encode re-checks start line Generated-by: Claude Opus 5 --- .../src/container_gateway/http.py | 50 ++++++++------ tools/container-gateway/tests/test_http.py | 68 +++++++++++++++++++ 2 files changed, 98 insertions(+), 20 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/http.py b/tools/container-gateway/src/container_gateway/http.py index 400fcfd3..c1e4adf4 100644 --- a/tools/container-gateway/src/container_gateway/http.py +++ b/tools/container-gateway/src/container_gateway/http.py @@ -55,7 +55,7 @@ _METHOD_RE = re.compile(r"[A-Z]+") _VERSION_RE = re.compile(r"HTTP/1\.[01]") -_STATUS_RE = re.compile(r"[0-9]{3}") +_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}") @@ -89,23 +89,30 @@ 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: ``METHOD SP - target SP HTTP/1.x`` or ``HTTP/1.x SP status SP reason``. Exactly three - space-separated tokens, no control characters anywhere (this also - rejects a tab or an extra space that would otherwise silently shift - which token is which). + (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") - parts = line.split(" ") - if len(parts) != 3: - raise HttpError(f"bad start line: {line!r}") - if _VERSION_RE.fullmatch(parts[0]): - if not _STATUS_RE.fullmatch(parts[1]): + 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 - if _VERSION_RE.fullmatch(parts[2]) and _METHOD_RE.fullmatch(parts[0]): - return - raise HttpError(f"bad start line: {line!r}") + 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 @@ -169,10 +176,12 @@ def validate_framing(self) -> None: raise HttpError("Content-Length and Transfer-Encoding both present") def encode(self) -> bytes: - # Defence in depth: re-check headers even though `set()` and `read_head` - # already validate on the way in, so a `Head` built by appending to - # `.headers` directly (bypassing `set()`) still cannot smuggle a - # control character or a non-latin-1 value out onto the wire. + # 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) @@ -271,8 +280,8 @@ async def _read_chunked(reader: asyncio.StreamReader, sink: asyncio.StreamWriter while True: try: line = await reader.readuntil(b"\r\n") - except asyncio.IncompleteReadError as exc: - raise HttpError("truncated trailer") from exc + 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": @@ -350,6 +359,7 @@ async def pipe( 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: diff --git a/tools/container-gateway/tests/test_http.py b/tools/container-gateway/tests/test_http.py index 5532f5bd..011fbc68 100644 --- a/tools/container-gateway/tests/test_http.py +++ b/tools/container-gateway/tests/test_http.py @@ -406,3 +406,71 @@ async def scenario() -> None: 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()) From e549faf2f23cb162318fd0d2c96eb0153025f0c2 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 15:15:43 +0200 Subject: [PATCH 17/45] feat(container-gateway): relay with label pre-check, streaming and hijack Generated-by: Claude Opus 5 --- .../src/container_gateway/relay.py | 443 ++++++++++++++++++ tools/container-gateway/tests/fakebackend.py | 193 ++++++++ tools/container-gateway/tests/test_relay.py | 315 +++++++++++++ 3 files changed, 951 insertions(+) create mode 100644 tools/container-gateway/src/container_gateway/relay.py create mode 100644 tools/container-gateway/tests/fakebackend.py create mode 100644 tools/container-gateway/tests/test_relay.py 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 00000000..b2cef14e --- /dev/null +++ b/tools/container-gateway/src/container_gateway/relay.py @@ -0,0 +1,443 @@ +# 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 +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}") + +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, + ) -> None: + self.connect = connect + self.ctx = ctx + self.backend_label = backend_label + self.json_limit = json_limit + + # ------------------------------------------------------------ backend + async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + return await self.connect() + + 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 -- and the + body is ``None`` when it was absent or not JSON. Every caller treats + an unknown status as a failure. + """ + 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 + try: + 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) + except (HttpError, OSError, ValueError, asyncio.IncompleteReadError) as exc: + 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 + for name in named_volumes(body, route.libpod): + denial = await self._volume_denial(name, route.libpod) + if denial is not None: + return denial + for name in named_networks(body, route.libpod): + denial = await self._network_denial(name, route.libpod) + if denial is not None: + return denial + return None + + 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 status == 404: + await self._precreate_volume(name, libpod) + return None + if status is not None and 200 <= status < 300 and has_label(_labels_of(info), self.ctx.slug): + return None + return _label_denial("volume", name) + + async def _precreate_volume(self, name: str, libpod: bool) -> None: + """Create the volume with this project's label before the daemon creates it without one.""" + 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, _ = await self._request_json("POST", target, payload) + if status is None or not (200 <= status < 300 or status == 409): + log.warning( + "pre-creating volume %s returned %s; the daemon may create it unlabelled", name, status + ) + + 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") + if status is not None and 200 <= status < 300 and has_label(_labels_of(info), self.ctx.slug): + return None + return _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, head) + raw_body = await read_body(reader, head, self.json_limit) if buffered else b"" + 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, head: Head) -> bool: + """Whether the policy needs the whole body in hand before anything is forwarded.""" + if route_of(req.method, req.path).action in _BUFFERED_ACTIONS: + return True + length = head.content_length + return _content_type(head) == "application/json" and length is not None and length <= self.json_limit + + 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: + ident = await self.resource_id_if_labelled(allow.route, allow.label_check) + if ident is None: + name = allow.label_check + 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")) + req.path = _rewrite_name(req.path, allow.label_check, ident) + 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 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") + 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: + # 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) + + 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 + if not (status.startswith("1") or status in ("204", "304") or 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(path: str, name: str, ident: str) -> str: + """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. + """ + if ident == name: + return path + needle = f"/{name}/" + if needle in path: + return path.replace(needle, f"/{ident}/", 1) + if path.endswith(f"/{name}"): + return f"{path[: -len(name)]}{ident}" + return path + + +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.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.unlink(missing_ok=True) + 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/tests/fakebackend.py b/tools/container-gateway/tests/fakebackend.py new file mode 100644 index 00000000..208a406a --- /dev/null +++ b/tools/container-gateway/tests/fakebackend.py @@ -0,0 +1,193 @@ +# 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 + +_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 + 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 + transports: list[asyncio.BaseTransport] = field(default_factory=list) + + # ------------------------------------------------------------- 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_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 = json.loads(body) if body and is_json else None + self.seen.append((method, head.start_line.split(" ")[1], parsed)) + 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 and not p.startswith("v1.")] + 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] == ["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 == ["containers", "create"]: + 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 {} + self.add_volume(name, labels) + writer.write(_json(201, self.volumes[name])) + 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] == ["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 _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_relay.py b/tools/container-gateway/tests/test_relay.py new file mode 100644 index 00000000..58d886a2 --- /dev/null +++ b/tools/container-gateway/tests/test_relay.py @@ -0,0 +1,315 @@ +# 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 json +from collections.abc import Coroutine +from pathlib import Path +from typing import Any, TypeVar + +import pytest + +from container_gateway.labels import LABEL_KEY +from container_gateway.policy import PolicyContext +from container_gateway.relay import Relay, 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) + + +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 + + +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]) -> bytes: + body = json.dumps(payload).encode() + head = ( + b"POST /v1.45/containers/create HTTP/1.1\r\nHost: x\r\n" + 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" + try: + server = await serve_unix(sock, handler) + except PermissionError: + pytest.skip("sandbox denies unix bind; runs in CI") + try: + assert sock.stat().st_mode & 0o777 == 0o600 + finally: + server.close() + await server.wait_closed() + + 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()) From a79c48917a45c003f1924b72aae5d108ff91832d Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 15:34:54 +0200 Subject: [PATCH 18/45] fix(container-gateway): segment-exact ID rewrite; volume pre-create fails closed; 100-continue and hop-by-hop headers Generated-by: Claude Opus 5 --- .../src/container_gateway/policy.py | 7 +- .../src/container_gateway/relay.py | 150 +++++++++--- .../src/container_gateway/routes.py | 30 +++ tools/container-gateway/tests/fakebackend.py | 49 +++- .../tests/test_policy_create.py | 3 + tools/container-gateway/tests/test_relay.py | 222 +++++++++++++++++- tools/container-gateway/tests/test_routes.py | 29 ++- 7 files changed, 445 insertions(+), 45 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/policy.py b/tools/container-gateway/src/container_gateway/policy.py index 55c5cd35..aa4f17fc 100644 --- a/tools/container-gateway/src/container_gateway/policy.py +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -460,7 +460,12 @@ def named_volumes(body: dict[str, Any], libpod: bool) -> list[str]: 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": - names.append(str(entry.get(source_key, ""))) + 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"): diff --git a/tools/container-gateway/src/container_gateway/relay.py b/tools/container-gateway/src/container_gateway/relay.py index b2cef14e..b267aae6 100644 --- a/tools/container-gateway/src/container_gateway/relay.py +++ b/tools/container-gateway/src/container_gateway/relay.py @@ -51,7 +51,7 @@ 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 +from .routes import Family, Route, name_span from .routes import route as route_of log = logging.getLogger("container-gateway") @@ -87,6 +87,11 @@ # 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") + Connector = Callable[[], Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]]] Handler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], Awaitable[None]] @@ -189,38 +194,55 @@ async def _resource_denial(self, allow: Allow) -> bytes | None: body = allow.request.body if not isinstance(body, dict): return None - for name in named_volumes(body, route.libpod): - denial = await self._volume_denial(name, route.libpod) - if denial is not None: - return denial + # 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 status == 404: - await self._precreate_volume(name, libpod) - return None - if status is not None and 200 <= status < 300 and has_label(_labels_of(info), self.ctx.slug): + if self._owned(status, info): return None - return _label_denial("volume", name) + 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. - async def _precreate_volume(self, name: str, libpod: bool) -> None: - """Create the volume with this project's label before the daemon creates it without one.""" + 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, _ = await self._request_json("POST", target, payload) - if status is None or not (200 <= status < 300 or status == 409): - log.warning( - "pre-creating volume %s returned %s; the daemon may create it unlabelled", name, status - ) + if status is not None and 200 <= status < 300: + return None + if status == 409: + log.info("volume %s was created concurrently; re-checking its label", name) + again = await self._request_json("GET", _inspect_target(Family.VOLUMES, name, libpod)) + return None if self._owned(*again) else _label_denial("volume", name) + 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 _network_denial(self, name: str, libpod: bool) -> bytes | None: """Networks are never auto-created, so an unknown one is refused too.""" @@ -229,9 +251,7 @@ async def _network_denial(self, name: str, libpod: bool) -> bytes | None: 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") - if status is not None and 200 <= status < 300 and has_label(_labels_of(info), self.ctx.slug): - return None - return _label_denial("network", name) + return None if self._owned(status, info) else _label_denial("network", name) # ------------------------------------------------------------- client async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: @@ -254,8 +274,11 @@ async def _one(self, head: Head, reader: asyncio.StreamReader, writer: asyncio.S """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, head) - raw_body = await read_body(reader, head, self.json_limit) if buffered else b"" + 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) @@ -268,12 +291,14 @@ async def _one(self, head: Head, reader: asyncio.StreamReader, writer: asyncio.S 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, head: Head) -> bool: - """Whether the policy needs the whole body in hand before anything is forwarded.""" - if route_of(req.method, req.path).action in _BUFFERED_ACTIONS: - return True - length = head.content_length - return _content_type(head) == "application/json" and length is not None and length <= self.json_limit + 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, @@ -291,7 +316,7 @@ async def _forward( name = allow.label_check 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")) - req.path = _rewrite_name(req.path, allow.label_check, ident) + req.path = _rewrite_name(req.method, req.path, allow.label_check, ident) 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) @@ -325,6 +350,11 @@ async def _exchange( """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") @@ -335,12 +365,17 @@ async def _exchange( 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) resp = await read_head(backend_reader) + 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. + resp = await read_head(backend_reader) if resp is None: return await _refuse( writer, error_response(502, "container-gateway: backend closed without a response") @@ -355,7 +390,8 @@ async def _exchange( except HttpError as exc: log.debug("hijacked connection ended: %s", exc) return False - if not (status.startswith("1") or status in ("204", "304") or req.method == "HEAD"): + # 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 @@ -394,21 +430,61 @@ def _labels_of(inspect: Any) -> dict[str, str] | None: return labels if isinstance(labels, dict) else None -def _rewrite_name(path: str, name: str, ident: str) -> str: +def _rewrite_name(method: str, path: str, name: str, ident: str) -> str: """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. + 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. When the span does not spell the name the policy + checked, nothing is rewritten and the request goes on exactly as the + policy read it. """ if ident == name: return path - needle = f"/{name}/" - if needle in path: - return path.replace(needle, f"/{ident}/", 1) - if path.endswith(f"/{name}"): - return f"{path[: -len(name)]}{ident}" - return path + span = name_span(method, path) + if span is None: + return path + start, end = span + segments = [s for s in path.split("/") if s] + if "/".join(segments[start:end]) != name: + return path + 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: diff --git a/tools/container-gateway/src/container_gateway/routes.py b/tools/container-gateway/src/container_gateway/routes.py index 8b2e0060..a4bf9b0a 100644 --- a/tools/container-gateway/src/container_gateway/routes.py +++ b/tools/container-gateway/src/container_gateway/routes.py @@ -183,6 +183,36 @@ def route(method: str, path: str) -> Route: 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) diff --git a/tools/container-gateway/tests/fakebackend.py b/tools/container-gateway/tests/fakebackend.py index 208a406a..009af6d3 100644 --- a/tools/container-gateway/tests/fakebackend.py +++ b/tools/container-gateway/tests/fakebackend.py @@ -34,6 +34,7 @@ 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 @@ -43,16 +44,26 @@ 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 + interim_continue: bool = False # emit a 100 Continue head 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)} @@ -90,8 +101,10 @@ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWrit 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 = json.loads(body) if body and is_json else None + 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 @@ -107,7 +120,12 @@ async def _respond( reader: asyncio.StreamReader, writer: asyncio.StreamWriter, ) -> None: - parts = [p for p in path.split("/") if p and not p.startswith("v1.")] + 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: @@ -135,16 +153,32 @@ async def _respond( 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"]: + if self.interim_continue: + 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 {} - self.add_volume(name, labels) - writer.write(_json(201, self.volumes[name])) + 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: + self.add_volume(name, labels) + writer.write(_json(201, self.volumes[name])) 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")) @@ -179,6 +213,13 @@ async def wrap(sock: socket.socket) -> tuple[asyncio.StreamReader, asyncio.Strea 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") diff --git a/tools/container-gateway/tests/test_policy_create.py b/tools/container-gateway/tests/test_policy_create.py index cb70d515..a662070b 100644 --- a/tools/container-gateway/tests/test_policy_create.py +++ b/tools/container-gateway/tests/test_policy_create.py @@ -306,6 +306,9 @@ def test_named_volumes_helper(ctx: PolicyContext) -> None: 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: diff --git a/tools/container-gateway/tests/test_relay.py b/tools/container-gateway/tests/test_relay.py index 58d886a2..74a1e3ff 100644 --- a/tools/container-gateway/tests/test_relay.py +++ b/tools/container-gateway/tests/test_relay.py @@ -78,6 +78,19 @@ async def call(relay: Relay, raw: bytes) -> tuple[int, bytes, bytes]: 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: @@ -89,11 +102,12 @@ def dechunk(body: bytes) -> bytes: return out -def create_request(payload: dict[str, Any]) -> bytes: +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" - b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + + extra + + b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) ) return head + body @@ -313,3 +327,207 @@ async def scenario() -> None: 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_continue = True + 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()) diff --git a/tools/container-gateway/tests/test_routes.py b/tools/container-gateway/tests/test_routes.py index ce62dbd3..28e644e8 100644 --- a/tools/container-gateway/tests/test_routes.py +++ b/tools/container-gateway/tests/test_routes.py @@ -20,7 +20,7 @@ import pytest -from container_gateway.routes import ACT_BY_NAME, LIST_LIKE, Family, route, strip_version +from container_gateway.routes import ACT_BY_NAME, LIST_LIKE, Family, name_span, route, strip_version @pytest.mark.parametrize( @@ -91,3 +91,30 @@ def test_act_by_name_and_list_like_sets() -> None: 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 From e4ee1e5014d6e710b8e46ddcfe5b1e51b6720baa Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 15:45:30 +0200 Subject: [PATCH 19/45] fix(container-gateway): volume pre-create verifies ownership; bound interim responses; refuse unrewritable targets Generated-by: Claude Opus 5 --- .../src/container_gateway/relay.py | 63 +++++++-- tools/container-gateway/tests/fakebackend.py | 16 ++- tools/container-gateway/tests/test_relay.py | 125 +++++++++++++++++- 3 files changed, 185 insertions(+), 19 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/relay.py b/tools/container-gateway/src/container_gateway/relay.py index b267aae6..fd79c850 100644 --- a/tools/container-gateway/src/container_gateway/relay.py +++ b/tools/container-gateway/src/container_gateway/relay.py @@ -92,6 +92,12 @@ # 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]] @@ -234,16 +240,29 @@ async def _precreate_volume(self, name: str, libpod: bool) -> bytes | None: 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, _ = await self._request_json("POST", target, payload) + status, created = await self._request_json("POST", target, payload) if status is not None and 200 <= status < 300: - return None + # 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) - again = await self._request_json("GET", _inspect_target(Family.VOLUMES, name, libpod)) - return None if self._owned(*again) else _label_denial("volume", 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): @@ -311,12 +330,21 @@ async def _forward( ) -> bool: req = allow.request if allow.label_check is not None: - ident = await self.resource_id_if_labelled(allow.route, allow.label_check) + 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: - name = allow.label_check 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")) - req.path = _rewrite_name(req.method, req.path, allow.label_check, ident) + 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) @@ -372,9 +400,16 @@ async def _exchange( await pump(reader, backend_writer, head) resp = await read_head(backend_reader) + 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( @@ -430,7 +465,7 @@ def _labels_of(inspect: Any) -> dict[str, str] | None: return labels if isinstance(labels, dict) else None -def _rewrite_name(method: str, path: str, name: str, ident: str) -> str: +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 @@ -439,19 +474,21 @@ def _rewrite_name(method: str, path: str, name: str, ident: str) -> str: ``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. When the span does not spell the name the policy - checked, nothing is rewritten and the request goes on exactly as the - policy read it. + 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 path + return None start, end = span segments = [s for s in path.split("/") if s] if "/".join(segments[start:end]) != name: - return path + return None segments[start:end] = [ident] return "/" + "/".join(segments) diff --git a/tools/container-gateway/tests/fakebackend.py b/tools/container-gateway/tests/fakebackend.py index 009af6d3..e8b8701b 100644 --- a/tools/container-gateway/tests/fakebackend.py +++ b/tools/container-gateway/tests/fakebackend.py @@ -55,7 +55,9 @@ class FakeBackend: # 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 - interim_continue: bool = False # emit a 100 Continue head before the real one + 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: @@ -156,7 +158,7 @@ async def _respond( 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"]: - if self.interim_continue: + 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": []})) @@ -169,8 +171,14 @@ async def _respond( elif self.volume_create_status is not None: writer.write(error_response(self.volume_create_status, "volume create failed")) else: - self.add_volume(name, labels) - writer.write(_json(201, self.volumes[name])) + # 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")) diff --git a/tools/container-gateway/tests/test_relay.py b/tools/container-gateway/tests/test_relay.py index 74a1e3ff..de71cff3 100644 --- a/tools/container-gateway/tests/test_relay.py +++ b/tools/container-gateway/tests/test_relay.py @@ -35,9 +35,10 @@ 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, serve_unix +from container_gateway.relay import Relay, _rewrite_name, serve_unix from .fakebackend import FakeBackend, socket_pair @@ -425,7 +426,7 @@ async def scenario() -> None: def test_interim_head_from_the_backend_is_consumed(tmp_path: Path) -> None: async def scenario() -> None: backend, relay = stack(tmp_path) - backend.interim_continue = True + 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 @@ -531,3 +532,123 @@ async def scenario() -> None: 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()) From 156add25214bc6a96cfa3a6694467965f40f8604 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 16:12:11 +0200 Subject: [PATCH 20/45] feat(container-gateway): daemon lifecycle and serve/stop/status CLI Generated-by: Claude Opus 5 --- .../src/container_gateway/__main__.py | 161 ++++++++++++ .../src/container_gateway/daemon.py | 227 +++++++++++++++++ .../src/container_gateway/relay.py | 51 +++- tools/container-gateway/tests/test_daemon.py | 232 ++++++++++++++++++ tools/container-gateway/tests/test_relay.py | 35 +++ 5 files changed, 700 insertions(+), 6 deletions(-) create mode 100644 tools/container-gateway/src/container_gateway/__main__.py create mode 100644 tools/container-gateway/src/container_gateway/daemon.py create mode 100644 tools/container-gateway/tests/test_daemon.py 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 00000000..50861df9 --- /dev/null +++ b/tools/container-gateway/src/container_gateway/__main__.py @@ -0,0 +1,161 @@ +# 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 signal +import sys +import time +from pathlib import Path + +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)") + + +def _config(ns: argparse.Namespace) -> daemon.Config: + root = ns.project.resolve() + run_dir = (ns.run_dir or root / ".apache-magpie-local" / "run").resolve() + 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=(ns.pid_file or run_dir / "container-gateway.pid").resolve(), + backend_timeout=getattr(ns, "backend_timeout", 60.0), + ) + + +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. + """ + if os.fork(): + os._exit(0) + os.setsid() + if os.fork(): + os._exit(0) + fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + for target in (1, 2): + os.dup2(fd, target) + os.dup2(os.open(os.devnull, os.O_RDONLY), 0) + + +def cmd_serve(ns: argparse.Namespace) -> int: + cfg = _config(ns) + pid = daemon.read_pid(cfg.pid_file) + if pid and daemon.pid_alive(pid): + return 0 # already running for this project + if ns.daemon: + daemon.check_run_dir(cfg.run_dir) + _daemonize(cfg.run_dir / "container-gateway.log") # Task 15 integration run covers this path + return asyncio.run(daemon.run(cfg)) + + +def cmd_stop(ns: argparse.Namespace) -> int: + cfg = _config(ns) + pid = daemon.read_pid(cfg.pid_file) + if not pid or not daemon.pid_alive(pid): + return 0 + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and daemon.pid_alive(pid): + time.sleep(0.1) + return 0 if not daemon.pid_alive(pid) else 1 + + +def cmd_status(ns: argparse.Namespace) -> int: + cfg = _config(ns) + p = daemon.paths(cfg.run_dir) + pid = daemon.read_pid(cfg.pid_file) + running = bool(pid and daemon.pid_alive(pid)) + sockets = {k: (str(p[k]) if running and p[k].exists() else None) for k in ("podman", "docker")} + backends = [k for k, v in sockets.items() if v is not None] + print( + json.dumps( + {"running": running, "pid": pid if running else None, "sockets": sockets, "backends": backends} + ) + ) + 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/daemon.py b/tools/container-gateway/src/container_gateway/daemon.py new file mode 100644 index 00000000..d26cb2a1 --- /dev/null +++ b/tools/container-gateway/src/container_gateway/daemon.py @@ -0,0 +1,227 @@ +# 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.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import platform as _platform +import signal +import stat +import sys +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +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 + + +@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 check_run_dir(run_dir: Path) -> None: + run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + mode = run_dir.stat().st_mode + if mode & (stat.S_IWGRP | stat.S_IWOTH): + print( + f"container-gateway: {run_dir} is group- or world-writable; refusing to bind sockets there", + file=sys.stderr, + ) + raise SystemExit(2) + + +def read_pid(pid_file: Path) -> int | None: + try: + return int(pid_file.read_text().strip()) + except (OSError, ValueError): + return None + + +def pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +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() + 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 scratch root comes from ``TMPDIR`` only when it is actually set -- + never a hardcoded ``/tmp`` fallback, which would let every project on + the machine bind-mount out of the same shared, world-writable + directory. An unset ``TMPDIR`` means the project root and any + ``--extra-bind-root`` entries are the only allowed bind-mount roots. + """ + roots = [cfg.project_root.resolve()] + tmpdir = os.environ.get("TMPDIR") + if tmpdir: + roots.append(Path(tmpdir).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 + + +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) + p = paths(cfg.run_dir) + for key in ("podman", "docker"): + check_socket_path(p[key]) + + 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] = [] + activity = _Activity() + 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) + 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, + ) + server = await serve_unix(p[key], activity.wrap(relay)) + servers.append(server) + log.info("%s CLI -> %s (backend %s at %s)", key, p[key], backend.kind, backend.socket) + + cfg.pid_file.write_text(f"{os.getpid()}\n") + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, stop.set) + try: + 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 s in servers: + s.close() + await s.wait_closed() + for key in ("podman", "docker"): + p[key].unlink(missing_ok=True) + cfg.pid_file.unlink(missing_ok=True) + return 0 diff --git a/tools/container-gateway/src/container_gateway/relay.py b/tools/container-gateway/src/container_gateway/relay.py index fd79c850..47567687 100644 --- a/tools/container-gateway/src/container_gateway/relay.py +++ b/tools/container-gateway/src/container_gateway/relay.py @@ -115,15 +115,37 @@ def __init__( *, 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]: - return await self.connect() + 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 @@ -132,9 +154,11 @@ async def _request_json( 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 -- and the - body is ``None`` when it was absent or not JSON. Every caller treats - an unknown status as a failure. + 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() @@ -142,7 +166,9 @@ async def _request_json( log.debug("backend call %s %s could not connect: %s", method, target, exc) return None, None status: int | None = None - try: + + 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: @@ -158,7 +184,15 @@ async def _request_json( 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: @@ -352,6 +386,8 @@ async def _forward( 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( @@ -399,7 +435,10 @@ async def _exchange( # connection rather than leaving the daemon mid-request. await pump(reader, backend_writer, head) - resp = await read_head(backend_reader) + 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 diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py new file mode 100644 index 00000000..d9f407fc --- /dev/null +++ b/tools/container-gateway/tests/test_daemon.py @@ -0,0 +1,232 @@ +# 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. + +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 asyncio +import json +import os +import shutil +import subprocess +import sys +import tempfile +from collections.abc import Coroutine, Iterator +from pathlib import Path +from typing import Any, TypeVar + +import pytest + +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") + + +def run(coro: Coroutine[Any, Any, _T]) -> _T: + """Drive a coroutine to completion without pytest-asyncio.""" + return asyncio.run(coro) + + +@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. + """ + 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: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + + +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" + daemon.check_socket_path(tmp_path / "ok.sock") + with pytest.raises(SystemExit) as exc: + daemon.check_socket_path(Path("/" + "x" * 120 + "/podman.sock")) + assert exc.value.code == 2 + + +def test_run_dir_must_not_be_group_or_world_writable(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + daemon.check_run_dir(run_dir) + assert run_dir.stat().st_mode & 0o777 == 0o700 + run_dir.chmod(0o777) + with pytest.raises(SystemExit): + daemon.check_run_dir(run_dir) + + +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 daemon.run(cfg, discover_fn=lambda *a, **k: [], platform="Darwin") + 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: + backend = _RealSocketBackend(short_run_dir / "d.sock") + try: + await backend.start() + except PermissionError: + pytest.skip("sandbox denies unix bind; runs in CI") + try: + cfg = daemon.Config( + tmp_path, + short_run_dir, + ("podman", "docker"), + "off", + 8899, + None, + (), + 1.5, + "INFO", + short_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: + for name in ("podman.sock", "docker.sock"): + r, w = await asyncio.open_unix_connection(str(short_run_dir / name)) + w.write(b"GET /_ping HTTP/1.1\r\nHost: x\r\n\r\n") + await w.drain() + assert b"200 OK" in await asyncio.wait_for(r.read(), 5) + w.close() + except PermissionError: + pytest.skip("sandbox denies unix bind; runs in CI") + assert daemon.read_pid(cfg.pid_file) == os.getpid() + rc = await asyncio.wait_for(task, 10) # idle timeout fires + assert rc == 0 + assert not cfg.pid_file.exists() + finally: + await backend.stop() + + run(scenario()) + + +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, + ) + assert done.returncode == 3 + assert json.loads(done.stdout)["running"] is False + + +def test_cli_stop_when_not_running_is_quiet(tmp_path: Path) -> None: + 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, + ) + assert done.returncode == 0 and done.stdout.strip() == "" + + +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, + ) + 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 diff --git a/tools/container-gateway/tests/test_relay.py b/tools/container-gateway/tests/test_relay.py index de71cff3..c6a8e9e0 100644 --- a/tools/container-gateway/tests/test_relay.py +++ b/tools/container-gateway/tests/test_relay.py @@ -652,3 +652,38 @@ async def scenario() -> None: 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()) From 6621768da499a8e25e1594ef1964488afee8763d Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 17:55:09 +0200 Subject: [PATCH 21/45] =?UTF-8?q?fix(container-gateway):=20harden=20the=20?= =?UTF-8?q?daemon=20against=20a=20hostile=20project=20tree=20=E2=80=94=20p?= =?UTF-8?q?id=20validation,=20symlink=20refusal,=20flock=20single-instance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated-by: Claude Opus 5 --- .../src/container_gateway/__main__.py | 81 +++- .../src/container_gateway/daemon.py | 325 +++++++++++---- .../src/container_gateway/relay.py | 9 +- tools/container-gateway/tests/test_daemon.py | 374 +++++++++++++++++- 4 files changed, 691 insertions(+), 98 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/__main__.py b/tools/container-gateway/src/container_gateway/__main__.py index 50861df9..11ad56be 100644 --- a/tools/container-gateway/src/container_gateway/__main__.py +++ b/tools/container-gateway/src/container_gateway/__main__.py @@ -36,12 +36,29 @@ def _common(p: argparse.ArgumentParser) -> None: 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)") + 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() - run_dir = (ns.run_dir or root / ".apache-magpie-local" / "run").resolve() + 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, @@ -52,11 +69,20 @@ def _config(ns: argparse.Namespace) -> daemon.Config: 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=(ns.pid_file or run_dir / "container-gateway.pid").resolve(), + 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. @@ -64,52 +90,65 @@ def _daemonize(log_path: Path) -> None: ``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. + 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) - fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) - for target in (1, 2): - os.dup2(fd, target) - os.dup2(os.open(os.devnull, os.O_RDONLY), 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) - pid = daemon.read_pid(cfg.pid_file) - if pid and daemon.pid_alive(pid): + 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: - daemon.check_run_dir(cfg.run_dir) _daemonize(cfg.run_dir / "container-gateway.log") # Task 15 integration run covers this path return asyncio.run(daemon.run(cfg)) def cmd_stop(ns: argparse.Namespace) -> int: cfg = _config(ns) - pid = daemon.read_pid(cfg.pid_file) - if not pid or not daemon.pid_alive(pid): + 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 os.kill(pid, signal.SIGTERM) deadline = time.monotonic() + 5 - while time.monotonic() < deadline and daemon.pid_alive(pid): + while time.monotonic() < deadline: + if not daemon.pid_alive(pid): + break # cheap fast-path; the lock probe below is authoritative time.sleep(0.1) - return 0 if not daemon.pid_alive(pid) else 1 + running, _ = daemon.probe_pid_lock(cfg.pid_file) + return 0 if not running else 1 def cmd_status(ns: argparse.Namespace) -> int: cfg = _config(ns) + running, pid = daemon.probe_pid_lock(cfg.pid_file) p = daemon.paths(cfg.run_dir) - pid = daemon.read_pid(cfg.pid_file) - running = bool(pid and daemon.pid_alive(pid)) - sockets = {k: (str(p[k]) if running and p[k].exists() else None) for k in ("podman", "docker")} - backends = [k for k, v in sockets.items() if v is not None] + 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")} print( json.dumps( - {"running": running, "pid": pid if running else None, "sockets": sockets, "backends": backends} + {"running": running, "pid": pid if running else None, "sockets": sockets, "serving": serving} ) ) return 0 if running else 3 diff --git a/tools/container-gateway/src/container_gateway/daemon.py b/tools/container-gateway/src/container_gateway/daemon.py index d26cb2a1..8ffc899f 100644 --- a/tools/container-gateway/src/container_gateway/daemon.py +++ b/tools/container-gateway/src/container_gateway/daemon.py @@ -15,14 +15,28 @@ # specific language governing permissions and limitations # under the License. -"""Own the run directory, the pid file, the sockets and the process lifetime.""" +"""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 @@ -30,6 +44,7 @@ from collections.abc import Callable from dataclasses import dataclass from pathlib import Path +from typing import NoReturn from . import backends as _backends from .labels import project_slug @@ -38,6 +53,7 @@ log = logging.getLogger("container-gateway") MAX_SUN_PATH = 103 +_PID_RE = re.compile(r"^[0-9]{1,10}$") @dataclass @@ -73,25 +89,112 @@ def check_socket_path(p: Path) -> None: raise SystemExit(2) -def check_run_dir(run_dir: Path) -> None: - run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) - mode = run_dir.stat().st_mode - if mode & (stat.S_IWGRP | stat.S_IWOTH): - print( - f"container-gateway: {run_dir} is group- or world-writable; refusing to bind sockets there", - file=sys.stderr, +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 _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. + """ + _refuse_if_symlink(path, label) + st = _lstat_or_none(path) + if st is None: + path.mkdir(mode=0o700) + return + 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") + + +def check_run_dir(run_dir: Path, project_root: Path) -> None: + """Guarantee ``run_dir`` is a real, owned, non-symlinked, private directory. + + ``project_root`` is resolved once -- a symlinked *project root* is a + legitimate thing the operator pointed ``--project`` at. Everything + strictly below it is then walked top-down with ``lstat`` before being + trusted, one component at a time, so each check's parent is already + known-safe by the time the next one runs (the same discipline + ``openat(2)``-style code uses to refuse a symlink race): first + ``.apache-magpie-local``, then ``run``, in the default layout. A + custom ``--run-dir`` outside the project tree gets the same + symlink refusal on its own parent, then the ownership/mode check on + itself. + """ + resolved_root = project_root.resolve() + default_run_dir = resolved_root / ".apache-magpie-local" / "run" + if run_dir == default_run_dir: + _ensure_owned_private_dir( + resolved_root / ".apache-magpie-local", "the project's .apache-magpie-local directory" ) - raise SystemExit(2) + _ensure_owned_private_dir(run_dir, "the run directory") + else: + _refuse_if_symlink(run_dir.parent, "the run directory's parent") + _ensure_owned_private_dir(run_dir, "the run directory") + + +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 ``serve_unix``'s ``unlink(missing_ok=True)`` + should ever silently remove and replace. + """ + 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: - return int(pid_file.read_text().strip()) - except (OSError, ValueError): + 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_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: @@ -101,6 +204,68 @@ def pid_alive(pid: int) -> bool: 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 ``O_TRUNC`` regardless of whether the lock turns out + to be free, so a losing caller's content is clobbered even though it + never got to write; that is fine because the lock, never the file's + content, is what ``serve``/``status``/``stop`` treat as the liveness + signal. Only display (``status``'s reported pid) can go briefly stale + in that race, and only until the winner's own write lands. + """ + try: + fd = _open_pid_fd(pid_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC) + 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.write(fd, f"{os.getpid()}\n".encode()) + 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``. @@ -115,6 +280,8 @@ async def probe_egress(host: str, port: int) -> bool: except OSError: return False w.close() + with contextlib.suppress(OSError): + await w.wait_closed() return True @@ -159,69 +326,91 @@ async def run( 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) + check_run_dir(cfg.run_dir, cfg.project_root) p = paths(cfg.run_dir) for key in ("podman", "docker"): check_socket_path(p[key]) - 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") + 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 - 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] = [] - activity = _Activity() - 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) - 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, - ) - server = await serve_unix(p[key], activity.wrap(relay)) - servers.append(server) - log.info("%s CLI -> %s (backend %s at %s)", key, p[key], backend.kind, backend.socket) - - cfg.pid_file.write_text(f"{os.getpid()}\n") - stop = asyncio.Event() - loop = asyncio.get_running_loop() - for sig in (signal.SIGTERM, signal.SIGINT): - loop.add_signal_handler(sig, stop.set) + try: - 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 + 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() + 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) + server = await serve_unix(sock_path, 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() + await s.wait_closed() + # 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: - for s in servers: - s.close() - await s.wait_closed() - for key in ("podman", "docker"): - p[key].unlink(missing_ok=True) + os.close(pid_fd) cfg.pid_file.unlink(missing_ok=True) return 0 diff --git a/tools/container-gateway/src/container_gateway/relay.py b/tools/container-gateway/src/container_gateway/relay.py index 47567687..ca3fa099 100644 --- a/tools/container-gateway/src/container_gateway/relay.py +++ b/tools/container-gateway/src/container_gateway/relay.py @@ -583,8 +583,13 @@ async def _refuse(writer: asyncio.StreamWriter, payload: bytes) -> bool: async def serve_unix(path: Path, handler: Handler) -> asyncio.AbstractServer: - """Bind ``path`` with mode 0600 and serve ``handler`` on it.""" - path.parent.mkdir(parents=True, exist_ok=True) + """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. + """ path.unlink(missing_ok=True) old_umask = os.umask(0o177) try: diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py index d9f407fc..181db9b1 100644 --- a/tools/container-gateway/tests/test_daemon.py +++ b/tools/container-gateway/tests/test_daemon.py @@ -17,6 +17,13 @@ """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. @@ -24,10 +31,12 @@ from __future__ import annotations +import argparse import asyncio import json import os import shutil +import signal import subprocess import sys import tempfile @@ -37,6 +46,7 @@ import pytest +from container_gateway import __main__ as cli from container_gateway import daemon from container_gateway.backends import Backend @@ -52,6 +62,13 @@ def run(coro: Coroutine[Any, Any, _T]) -> _T: return asyncio.run(coro) +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) + + @pytest.fixture def short_run_dir() -> Iterator[Path]: """A run directory short enough to hold a unix-socket path. @@ -62,7 +79,8 @@ def short_run_dir() -> Iterator[Path]: 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. + ``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: @@ -97,6 +115,9 @@ async def stop(self) -> None: await self._server.wait_closed() +# --------------------------------------------------------------- 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" @@ -106,13 +127,346 @@ def test_paths_and_socket_length(tmp_path: Path) -> None: assert exc.value.code == 2 -def test_run_dir_must_not_be_group_or_world_writable(tmp_path: Path) -> None: - run_dir = tmp_path / "run" - daemon.check_run_dir(run_dir) +# ------------------------------------------------ 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): - daemon.check_run_dir(run_dir) + 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 + + +# --------------------------------------------------- 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) + with pytest.raises(SystemExit) as exc: + daemon.acquire_pid_lock(link) + assert exc.value.code == 2 + + +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) + with pytest.raises(SystemExit) as exc: + cli._open_log_fd(link) + assert exc.value.code == 2 + + +# --------------------------------------------------------------- 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) + 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 + 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" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + our_pid = os.getpid() + calls: list[tuple[int, int]] = [] + terminated = False + + def fake_kill(pid: int, sig: int) -> None: + nonlocal terminated + calls.append((pid, sig)) + if sig == signal.SIGTERM: + terminated = True + os.close(fd) # simulate the daemon exiting: release the flock + elif terminated: + raise ProcessLookupError + + monkeypatch.setattr(os, "kill", fake_kill) + 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 + + +# ---------------------------------------------------------------- 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 daemon.run(cfg, discover_fn=lambda *a, **k: found, platform="Darwin") + + 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: + try: + server = await asyncio.start_server(lambda r, w: None, host="127.0.0.1", port=0) + except PermissionError: + pytest.skip("sandbox denies TCP bind; runs in CI") + try: + port = server.sockets[0].getsockname()[1] + assert await daemon.probe_egress("host.containers.internal", port) is True + finally: + server.close() + await server.wait_closed() + + run(scenario()) + + +def test_probe_egress_false_when_nothing_listens() -> None: + async def scenario() -> None: + try: + server = await asyncio.start_server(lambda r, w: None, host="127.0.0.1", port=0) + except PermissionError: + pytest.skip("sandbox denies TCP bind; runs in CI") + port = server.sockets[0].getsockname()[1] + server.close() + await server.wait_closed() + 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: @@ -184,6 +538,9 @@ async def scenario() -> None: 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)], @@ -193,7 +550,10 @@ def test_cli_status_when_not_running(tmp_path: Path) -> None: check=False, ) assert done.returncode == 3 - assert json.loads(done.stdout)["running"] is False + out = json.loads(done.stdout) + assert out["running"] is False + assert out["serving"] == [] + assert "backends" not in out def test_cli_stop_when_not_running_is_quiet(tmp_path: Path) -> None: From 10c6874aa833343027f16958cb97202b59c90238 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 18:18:21 +0200 Subject: [PATCH 22/45] fix(container-gateway): validate run dir on status/stop; keep the live pid file intact; verify the stop target Generated-by: Claude Opus 5 --- .../src/container_gateway/__main__.py | 77 +++++- .../src/container_gateway/daemon.py | 193 +++++++++++--- .../src/container_gateway/relay.py | 7 +- tools/container-gateway/tests/test_daemon.py | 243 ++++++++++++++++++ tools/container-gateway/tests/test_relay.py | 27 ++ 5 files changed, 502 insertions(+), 45 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/__main__.py b/tools/container-gateway/src/container_gateway/__main__.py index 11ad56be..e147a4e1 100644 --- a/tools/container-gateway/src/container_gateway/__main__.py +++ b/tools/container-gateway/src/container_gateway/__main__.py @@ -28,6 +28,7 @@ import time from pathlib import Path +from . import backends as _backends from . import daemon @@ -121,8 +122,40 @@ def cmd_serve(ns: argparse.Namespace) -> int: return asyncio.run(daemon.run(cfg)) +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 closes that last gap; an unreadable + command line (``ps`` unavailable, the process already gone) refuses + rather than guesses. + """ + line = _backends.default_runner(["ps", "-o", "command=", "-p", str(pid)]) + if line is None: + return False + return "container_gateway" in line or "container-gateway" in line + + def cmd_stop(ns: argparse.Namespace) -> int: cfg = _config(ns) + if not daemon.validate_run_dir(cfg.run_dir, cfg.project_root): + return 0 # never served, or the run directory itself no longer exists + 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 @@ -130,27 +163,51 @@ def cmd_stop(ns: argparse.Namespace) -> int: # 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: - if not daemon.pid_alive(pid): - break # cheap fast-path; the lock probe below is authoritative + 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) - p = daemon.paths(cfg.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")} - print( - json.dumps( - {"running": running, "pid": pid if running else None, "sockets": sockets, "serving": serving} - ) - ) + print(json.dumps(_status_payload(running, pid, cfg.run_dir))) return 0 if running else 3 diff --git a/tools/container-gateway/src/container_gateway/daemon.py b/tools/container-gateway/src/container_gateway/daemon.py index 8ffc899f..052843e2 100644 --- a/tools/container-gateway/src/container_gateway/daemon.py +++ b/tools/container-gateway/src/container_gateway/daemon.py @@ -107,44 +107,99 @@ def _refuse_if_symlink(path: Path, label: str) -> None: _refuse(f"{label} ({path}) is a symlink; refusing") -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. +def _refuse_if_parent_missing_or_symlink(path: Path, label: str) -> None: + """An ancestor this module never creates on its own: it must already exist. + + Used for a custom ``--run-dir``'s parent, which may sit anywhere + outside the project tree -- "create it for the operator" would be + presumptuous, and letting a missing parent surface as a bare + ``FileNotFoundError`` out of a later ``mkdir()`` is not an error + message worth shipping. + """ + st = _lstat_or_none(path) + if st is None: + _refuse(f"{label} ({path}) does not exist") + if 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: - path.mkdir(mode=0o700) - return + 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 check_run_dir(run_dir: Path, project_root: Path) -> None: - """Guarantee ``run_dir`` is a real, owned, non-symlinked, private directory. + """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* is a - legitimate thing the operator pointed ``--project`` at. Everything - strictly below it is then walked top-down with ``lstat`` before being - trusted, one component at a time, so each check's parent is already - known-safe by the time the next one runs (the same discipline - ``openat(2)``-style code uses to refuse a symlink race): first - ``.apache-magpie-local``, then ``run``, in the default layout. A - custom ``--run-dir`` outside the project tree gets the same - symlink refusal on its own parent, then the ownership/mode check on - itself. + legitimate thing the operator pointed ``--project`` at, and it must + already exist (this function creates directories below it, never the + root itself). Everything strictly below it is walked top-down with + ``lstat``, refusing a symlink, a non-directory, a foreign owner or a + group/world-writable mode on each component before creating or + trusting the next one: first ``.apache-magpie-local``, then ``run``, + in the default layout. A custom ``--run-dir`` outside the project + tree must have an existing, non-symlinked parent, then gets the same + ownership/mode check on itself. + + 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 = project_root.resolve() + resolved_root = _resolved_existing_project_root(project_root) default_run_dir = resolved_root / ".apache-magpie-local" / "run" if run_dir == default_run_dir: _ensure_owned_private_dir( @@ -152,16 +207,47 @@ def check_run_dir(run_dir: Path, project_root: Path) -> None: ) _ensure_owned_private_dir(run_dir, "the run directory") else: - _refuse_if_symlink(run_dir.parent, "the run directory's parent") + _refuse_if_parent_missing_or_symlink(run_dir.parent, "the run directory's parent") _ensure_owned_private_dir(run_dir, "the run directory") +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 + default_run_dir = resolved_root / ".apache-magpie-local" / "run" + if run_dir == default_run_dir: + if not _owned_private_dir_status( + resolved_root / ".apache-magpie-local", "the project's .apache-magpie-local directory" + ): + return False + else: + st = _lstat_or_none(run_dir.parent) + if st is None: + return False + if stat.S_ISLNK(st.st_mode): + _refuse(f"the run directory's parent ({run_dir.parent}) is a symlink; refusing") + return _owned_private_dir_status(run_dir, "the run directory") + + 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 ``serve_unix``'s ``unlink(missing_ok=True)`` - should ever silently remove and replace. + 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): @@ -188,6 +274,32 @@ def read_pid(pid_file: Path) -> int | None: 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 @@ -218,15 +330,15 @@ def acquire_pid_lock(pid_file: Path) -> int | None: it, or letting it be garbage collected, drops the lock. Returns ``None`` when another live instance already holds the lock. - The fd is opened ``O_TRUNC`` regardless of whether the lock turns out - to be free, so a losing caller's content is clobbered even though it - never got to write; that is fine because the lock, never the file's - content, is what ``serve``/``status``/``stop`` treat as the liveness - signal. Only display (``status``'s reported pid) can go briefly stale - in that race, and only until the winner's own write lands. + 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 | os.O_TRUNC) + 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: @@ -234,7 +346,10 @@ def acquire_pid_lock(pid_file: Path) -> int | None: 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 @@ -383,6 +498,12 @@ async def run( ) 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, activity.wrap(relay)) servers.append(server) bound_sockets.append(sock_path) @@ -411,6 +532,12 @@ async def run( for sock_path in bound_sockets: sock_path.unlink(missing_ok=True) finally: - os.close(pid_fd) + # 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/relay.py b/tools/container-gateway/src/container_gateway/relay.py index ca3fa099..15891b83 100644 --- a/tools/container-gateway/src/container_gateway/relay.py +++ b/tools/container-gateway/src/container_gateway/relay.py @@ -588,9 +588,12 @@ async def serve_unix(path: Path, handler: Handler) -> asyncio.AbstractServer: 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. + 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. """ - path.unlink(missing_ok=True) old_umask = os.umask(0o177) try: server = await asyncio.start_unix_server(handler, path=str(path)) diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py index 181db9b1..3c1c07bb 100644 --- a/tools/container-gateway/tests/test_daemon.py +++ b/tools/container-gateway/tests/test_daemon.py @@ -185,6 +185,60 @@ def test_check_run_dir_refuses_symlinked_custom_run_dir(tmp_path: Path) -> None: 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 @@ -238,6 +292,152 @@ def test_open_log_fd_refuses_symlink(tmp_path: Path) -> None: assert exc.value.code == 2 +# --------------------- 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 + 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: + 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_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 @@ -266,6 +466,7 @@ def test_cli_stop_with_unsafe_pid_file_does_not_signal( ) -> 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)) @@ -348,6 +549,7 @@ def test_cli_status_removes_stale_pid_file_when_no_lock_held( ) -> 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 @@ -376,6 +578,10 @@ def fake_kill(pid: int, sig: int) -> None: 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 @@ -385,6 +591,43 @@ def test_cli_stop_when_lock_never_held_is_a_noop(tmp_path: Path, short_run_dir: assert cli.cmd_stop(_ns(tmp_path, short_run_dir)) == 0 +# ------------------------- 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 daemon.run(cfg, discover_fn=lambda *a, **k: [], platform="Darwin") + assert rc == 0 + + run(scenario()) + assert "unlink" in events + assert "close" in events + assert events.index("unlink") < events.index("close") + + # ---------------------------------------------------------------- I5 diff --git a/tools/container-gateway/tests/test_relay.py b/tools/container-gateway/tests/test_relay.py index c6a8e9e0..8d7dea58 100644 --- a/tools/container-gateway/tests/test_relay.py +++ b/tools/container-gateway/tests/test_relay.py @@ -28,6 +28,7 @@ from __future__ import annotations import asyncio +import contextlib import json from collections.abc import Coroutine from pathlib import Path @@ -141,6 +142,32 @@ async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> 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) From 74a6eca5759b96113495dbabea1984ec14292699 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 18:33:36 +0200 Subject: [PATCH 23/45] feat(agent-isolation): session hook that starts and stops the container gateway Generated-by: Claude Opus 5 --- tools/agent-isolation/README.md | 1 + .../agent-isolation/container-gateway-hook.sh | 72 ++++++++++++++++++ .../tests/test_container_gateway_hook.py | 74 +++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100755 tools/agent-isolation/container-gateway-hook.sh create mode 100644 tools/agent-isolation/tests/test_container_gateway_hook.py diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md index 811de7bf..28403c6c 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 adopter's `.apache-magpie/` snapshot or the framework checkout, and is a silent no-op when neither is present. See [`docs/setup/secure-agent-setup.md`](../../docs/setup/secure-agent-setup.md). | | [`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 00000000..b4780bb8 --- /dev/null +++ b/tools/agent-isolation/container-gateway-hook.sh @@ -0,0 +1,72 @@ +#!/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 +# +# Sources are looked up in order: $MAGPIE_CONTAINER_GATEWAY_SRC, +# /.apache-magpie/tools/container-gateway/src (snapshot adopters), +# /tools/container-gateway/src (the framework repo itself). +# Extra serve flags: $MAGPIE_CONTAINER_GATEWAY_ARGS (e.g. "--egress require"). +# 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:-}" \ + "$root/.apache-magpie/tools/container-gateway/src" \ + "$root/tools/container-gateway/src"; do + if [[ -n $candidate && -d $candidate/container_gateway ]]; then + src="$candidate" + break + fi +done +[[ -n $src ]] || exit 0 + +if [[ $action == start ]]; then + # shellcheck disable=SC2206 # word-splitting the extra args is the point + extra=(${MAGPIE_CONTAINER_GATEWAY_ARGS:-}) + 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="$src${PYTHONPATH:+:$PYTHONPATH}" "${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 00000000..4cd165bf --- /dev/null +++ b/tools/agent-isolation/tests/test_container_gateway_hook.py @@ -0,0 +1,74 @@ +# 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 + +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) -> subprocess.CompletedProcess[str]: + env = {**os.environ, "MAGPIE_CONTAINER_GATEWAY_DRY_RUN": "1", **(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 + 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) + 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: + 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"}) + assert done.stdout.strip().endswith(f"serve --project {tmp_path.resolve()} --daemon --egress require") + assert f"PYTHONPATH={alt}" in done.stdout + + +def test_stop_command(tmp_path: Path) -> None: + (tmp_path / "tools" / "container-gateway" / "src" / "container_gateway").mkdir(parents=True) + done = run("stop", tmp_path) + assert done.stdout.strip().endswith(f"stop --project {tmp_path.resolve()}") + + +def test_no_sources_is_silent_success(tmp_path: Path) -> None: + done = run("start", tmp_path) + assert done.returncode == 0 and done.stdout == "" + + +def test_cwd_from_payload_wins_over_pwd(tmp_path: Path) -> None: + proj = tmp_path / "proj" + (proj / "tools" / "container-gateway" / "src" / "container_gateway").mkdir(parents=True) + done = run("start", tmp_path, payload={"cwd": str(proj)}) + assert f"--project {proj.resolve()}" in done.stdout + + +def test_bad_action_exits_zero_with_message(tmp_path: Path) -> None: + done = run("frobnicate", tmp_path) + assert done.returncode == 0 and "expected start|stop" in done.stderr From 267a9dcb369e532b7f86853b7c2a0520bdb0da9b Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 18:43:45 +0200 Subject: [PATCH 24/45] fix(agent-isolation): container-gateway hook trusts only installed or pinned sources Generated-by: Claude Opus 5 --- tools/agent-isolation/README.md | 2 +- .../agent-isolation/container-gateway-hook.sh | 22 ++++++----- .../tests/test_container_gateway_hook.py | 38 +++++++++++++++---- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md index 28403c6c..07c6d7fc 100644 --- a/tools/agent-isolation/README.md +++ b/tools/agent-isolation/README.md @@ -90,7 +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 adopter's `.apache-magpie/` snapshot or the framework checkout, and is a silent no-op when neither is present. See [`docs/setup/secure-agent-setup.md`](../../docs/setup/secure-agent-setup.md). | +| [`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`](../../docs/setup/secure-agent-setup.md). | | [`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 index b4780bb8..7e2d9c4a 100755 --- a/tools/agent-isolation/container-gateway-hook.sh +++ b/tools/agent-isolation/container-gateway-hook.sh @@ -22,12 +22,16 @@ # 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 +# 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). # -# Sources are looked up in order: $MAGPIE_CONTAINER_GATEWAY_SRC, -# /.apache-magpie/tools/container-gateway/src (snapshot adopters), -# /tools/container-gateway/src (the framework repo itself). # Extra serve flags: $MAGPIE_CONTAINER_GATEWAY_ARGS (e.g. "--egress require"). # MAGPIE_CONTAINER_GATEWAY_DRY_RUN=1 prints the command instead of running it. @@ -47,8 +51,8 @@ root="$(cd "$root" 2>/dev/null && pwd -P)" || exit 0 src="" for candidate in "${MAGPIE_CONTAINER_GATEWAY_SRC:-}" \ - "$root/.apache-magpie/tools/container-gateway/src" \ - "$root/tools/container-gateway/src"; do + "$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 @@ -59,9 +63,9 @@ done if [[ $action == start ]]; then # shellcheck disable=SC2206 # word-splitting the extra args is the point extra=(${MAGPIE_CONTAINER_GATEWAY_ARGS:-}) - cmd=(python3 -m container_gateway serve --project "$root" --daemon "${extra[@]}") + cmd=(python3 -m container_gateway serve --project="$root" --daemon "${extra[@]}") else - cmd=(python3 -m container_gateway stop --project "$root") + cmd=(python3 -m container_gateway stop --project="$root") fi if [[ -n ${MAGPIE_CONTAINER_GATEWAY_DRY_RUN:-} ]]; then diff --git a/tools/agent-isolation/tests/test_container_gateway_hook.py b/tools/agent-isolation/tests/test_container_gateway_hook.py index 4cd165bf..95d78362 100644 --- a/tools/agent-isolation/tests/test_container_gateway_hook.py +++ b/tools/agent-isolation/tests/test_container_gateway_hook.py @@ -40,21 +40,22 @@ def test_start_uses_snapshot_sources(tmp_path: Path) -> None: subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) done = run("start", tmp_path) assert done.returncode == 0, done.stderr - assert done.stdout.strip() == f"PYTHONPATH={src.parent} python3 -m container_gateway serve --project {tmp_path} --daemon" + 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: 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"}) - assert done.stdout.strip().endswith(f"serve --project {tmp_path.resolve()} --daemon --egress require") + assert done.stdout.strip().endswith(f"serve --project={tmp_path.resolve()} --daemon --egress require") assert f"PYTHONPATH={alt}" in done.stdout def test_stop_command(tmp_path: Path) -> None: - (tmp_path / "tools" / "container-gateway" / "src" / "container_gateway").mkdir(parents=True) + src = tmp_path / ".apache-magpie" / "tools" / "container-gateway" / "src" / "container_gateway" + src.mkdir(parents=True) done = run("stop", tmp_path) - assert done.stdout.strip().endswith(f"stop --project {tmp_path.resolve()}") + assert done.stdout.strip().endswith(f"stop --project={tmp_path.resolve()}") def test_no_sources_is_silent_success(tmp_path: Path) -> None: @@ -62,11 +63,32 @@ def test_no_sources_is_silent_success(tmp_path: Path) -> None: assert done.returncode == 0 and done.stdout == "" -def test_cwd_from_payload_wins_over_pwd(tmp_path: Path) -> None: +def test_in_repo_sources_are_ignored(tmp_path: Path) -> None: + """In-repo sources are never trusted, even when present.""" + (tmp_path / "tools" / "container-gateway" / "src" / "container_gateway").mkdir(parents=True) + done = run("start", tmp_path) + 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() + # Create both user-scope and snapshot copies + user_src = tmp_path / ".claude" / "scripts" / "container-gateway" / "src" / "container_gateway" + user_src.mkdir(parents=True) + snap_src = tmp_path / ".apache-magpie" / "tools" / "container-gateway" / "src" / "container_gateway" + snap_src.mkdir(parents=True) + # Use a fake HOME pointing to tmp_path/.claude/.. + fake_home = tmp_path / ".fake-home" + fake_home.mkdir() + (fake_home / ".claude" / "scripts" / "container-gateway" / "src" / "container_gateway").mkdir(parents=True) proj = tmp_path / "proj" - (proj / "tools" / "container-gateway" / "src" / "container_gateway").mkdir(parents=True) - done = run("start", tmp_path, payload={"cwd": str(proj)}) - assert f"--project {proj.resolve()}" in done.stdout + proj.mkdir() + subprocess.run(["git", "init", "-q", str(proj)], check=True) + done = run("start", proj, {"HOME": str(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_bad_action_exits_zero_with_message(tmp_path: Path) -> None: From 46d3b4c8c60366790ef3d14bcd44ada90ff56020 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 18:52:37 +0200 Subject: [PATCH 25/45] test(agent-isolation): hermetic HOME and real precedence check for the container-gateway hook Generated-by: Claude Opus 5 --- .../tests/test_container_gateway_hook.py | 71 ++++++++++++++----- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/tools/agent-isolation/tests/test_container_gateway_hook.py b/tools/agent-isolation/tests/test_container_gateway_hook.py index 95d78362..d8911ebb 100644 --- a/tools/agent-isolation/tests/test_container_gateway_hook.py +++ b/tools/agent-isolation/tests/test_container_gateway_hook.py @@ -27,70 +27,103 @@ 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) -> subprocess.CompletedProcess[str]: - env = {**os.environ, "MAGPIE_CONTAINER_GATEWAY_DRY_RUN": "1", **(env_extra or {})} +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) + 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"}) - assert done.stdout.strip().endswith(f"serve --project={tmp_path.resolve()} --daemon --egress require") + 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) - assert done.stdout.strip().endswith(f"stop --project={tmp_path.resolve()}") + 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: - done = run("start", tmp_path) + 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) + 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() - # Create both user-scope and snapshot copies - user_src = tmp_path / ".claude" / "scripts" / "container-gateway" / "src" / "container_gateway" - user_src.mkdir(parents=True) - snap_src = tmp_path / ".apache-magpie" / "tools" / "container-gateway" / "src" / "container_gateway" - snap_src.mkdir(parents=True) - # Use a fake HOME pointing to tmp_path/.claude/.. - fake_home = tmp_path / ".fake-home" + fake_home = tmp_path / "home" fake_home.mkdir() - (fake_home / ".claude" / "scripts" / "container-gateway" / "src" / "container_gateway").mkdir(parents=True) 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": str(fake_home)}) + 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: - done = run("frobnicate", tmp_path) + 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 From 7bb33acd117a84aabc67426ca9e7a0ee2c37d9de Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 19:12:13 +0200 Subject: [PATCH 26/45] test(container-gateway): socket-length test uses a literal short path Generated-by: Claude Opus 5 --- tools/container-gateway/tests/test_daemon.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py index 3c1c07bb..0b0d68a7 100644 --- a/tools/container-gateway/tests/test_daemon.py +++ b/tools/container-gateway/tests/test_daemon.py @@ -121,7 +121,11 @@ async def stop(self) -> None: 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" - daemon.check_socket_path(tmp_path / "ok.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 From d036095ccc85e7b198daa9d9764187436c9ec69d Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 19:15:38 +0200 Subject: [PATCH 27/45] feat(sandbox): point CONTAINER_HOST / DOCKER_HOST at the container gateway Reference settings and the sandbox-lint baseline gain the gateway env vars; a new invariant rejects daemon sockets in allowUnixSockets. The per-project socket allow entries are absolute and live in local settings, documented in the setup guide. Generated-by: Claude Opus 5 --- .claude/settings.json | 4 ++ docs/setup/secure-agent-setup.md | 15 ++++++ tools/sandbox-lint/expected.json | 4 ++ .../sandbox-lint/src/sandbox_lint/__init__.py | 17 +++++++ tools/sandbox-lint/tests/test_validator.py | 49 +++++++++++++++++++ 5 files changed, 89 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index 6e621752..cccfaf15 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/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index 397250e0..5d2e4a87 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -456,6 +456,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 +516,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`, + // written by `/magpie-setup config`) add 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", diff --git a/tools/sandbox-lint/expected.json b/tools/sandbox-lint/expected.json index 6e621752..cccfaf15 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 ba05b037..a0076dc5 100644 --- a/tools/sandbox-lint/src/sandbox_lint/__init__.py +++ b/tools/sandbox-lint/src/sandbox_lint/__init__.py @@ -254,6 +254,23 @@ 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", []): + name = entry.rstrip("/").rsplit("/", 1)[-1] + parent = entry.rstrip("/").rsplit("/", 1)[0] if "/" in entry else "" + is_daemon = name in ("docker.sock", "podman.sock") or name.endswith("-api.sock") + if is_daemon and not parent.endswith(".apache-magpie-local/run"): + 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 diff --git a/tools/sandbox-lint/tests/test_validator.py b/tools/sandbox-lint/tests/test_validator.py index 373f3e56..8528a013 100644 --- a/tools/sandbox-lint/tests/test_validator.py +++ b/tools/sandbox-lint/tests/test_validator.py @@ -385,3 +385,52 @@ 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] From 89f8bbfa8017fe5c5fba7277f23b5a9a32660fa3 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 19:32:19 +0200 Subject: [PATCH 28/45] docs(sandbox): container gateway in the setup guide, catalog and RFCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the Docker / Podman catalog entry around the gateway and correct two errors: the podman API socket lives under $TMPDIR/podman, not ~/.local/share (that path is the ssh identity), and an empty "podman machine list" inside the sandbox is a read denial, not a missing machine. Add the socket-gateways row to RFC-AI-0004 Principle 2 and a cross-reference from RFC-AI-0003 § 4.4. Add the "Container gateway" setup-guide section and restore the catalog anchor from the hook's README row. Generated-by: Claude Opus 5 --- docs/rfcs/RFC-AI-0003.md | 6 + docs/rfcs/RFC-AI-0004.md | 1 + docs/setup/sandbox-troubleshooting.md | 216 ++++++-------------------- docs/setup/secure-agent-setup.md | 112 +++++++++++++ tools/agent-isolation/README.md | 2 +- 5 files changed, 167 insertions(+), 170 deletions(-) diff --git a/docs/rfcs/RFC-AI-0003.md b/docs/rfcs/RFC-AI-0003.md index dd4aff96..795fffbc 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 001c3c78..106a56ae 100644 --- a/docs/rfcs/RFC-AI-0004.md +++ b/docs/rfcs/RFC-AI-0004.md @@ -187,6 +187,7 @@ The reference implementation (see [`docs/setup/secure-agent-internals.md`](http | **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`. | | **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. | +| **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. | ### Five concrete consequences diff --git a/docs/setup/sandbox-troubleshooting.md b/docs/setup/sandbox-troubleshooting.md index f2e1056f..8eaa0b52 100644 --- a/docs/setup/sandbox-troubleshooting.md +++ b/docs/setup/sandbox-troubleshooting.md @@ -580,197 +580,75 @@ 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. --- diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index 5d2e4a87..e010fdc9 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) @@ -2310,6 +2316,112 @@ 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 +``` + +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 (written by `/magpie-setup config`, or by hand): + +```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 or its scratch tree 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 — diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md index 07c6d7fc..26a0895a 100644 --- a/tools/agent-isolation/README.md +++ b/tools/agent-isolation/README.md @@ -90,7 +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`](../../docs/setup/secure-agent-setup.md). | +| [`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. | From 8c72458c1020d6525e013b43a09fdc8231db7bb3 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 19:47:20 +0200 Subject: [PATCH 29/45] feat(setup): doctor, verify and install know the container gateway Probe 3 tests the CLI through the gateway socket and names which of the three wiring pieces is missing, including the new gateway-up-without-a- backend shape; verify check 12 audits the hook, the env block and the socket allow-list; the install skill gains step L; four eval fixtures cover the new probe shapes. Closes a docs gap where the install instructions copied only the hook script and left the gateway package it runs unsourced. Generated-by: Claude Opus 5 --- docs/mode-economics.md | 10 +- docs/setup/secure-agent-setup.md | 13 +++ .../skills/isolated-setup-doctor/SKILL.md | 95 ++++++++++++++----- .../skills/isolated-setup-install/SKILL.md | 59 ++++++++++++ .../skills/isolated-setup-update/SKILL.md | 8 ++ .../skills/isolated-setup-verify/SKILL.md | 51 +++++++++- tools/skill-evals/README.md | 2 +- .../setup-isolated-setup-doctor/README.md | 21 +++- .../case-10-gateway-pass/expected.json | 1 + .../fixtures/case-10-gateway-pass/report.md | 9 ++ .../case-11-gateway-not-running/expected.json | 1 + .../case-11-gateway-not-running/report.md | 9 ++ .../expected.json | 1 + .../case-12-gateway-socket-denied/report.md | 9 ++ .../expected.json | 1 + .../report.md | 9 ++ .../interpret-probes/fixtures/output-spec.md | 6 +- .../setup-isolated-setup-verify/README.md | 2 +- .../step-1-classify/fixtures/step-config.json | 2 +- 19 files changed, 270 insertions(+), 39 deletions(-) create mode 100644 tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-10-gateway-pass/expected.json create mode 100644 tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-10-gateway-pass/report.md create mode 100644 tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-11-gateway-not-running/expected.json create mode 100644 tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-11-gateway-not-running/report.md create mode 100644 tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-12-gateway-socket-denied/expected.json create mode 100644 tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-12-gateway-socket-denied/report.md create mode 100644 tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-13-gateway-no-podman-backend/expected.json create mode 100644 tools/skill-evals/evals/setup-isolated-setup-doctor/interpret-probes/fixtures/case-13-gateway-no-podman-backend/report.md diff --git a/docs/mode-economics.md b/docs/mode-economics.md index db204970..044f8498 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: `42d189314b6a297691e2f536ecd506ada2b4fc9e19416a6e3e47a71948876def`. | 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) | 6,563 | `856d95582da69e3b` | +| [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 9,543 | `ab1ebf4531c2741c` | +| [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 4,778 | `90f5b1418c16ea0a` | +| [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 6,774 | `58e4c0785717bb05` | | [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/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index e010fdc9..cae90842 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -2339,8 +2339,21 @@ This is the same *socket gateways* layer [RFC-AI-0004](../rfcs/RFC-AI-0004.md) P 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 diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md index 2b8838fc..b8ff976c 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,90 @@ 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 +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) + +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)" - ;; - esac + 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://}" + if [ ! -S "$sock" ]; then + echo "PROBE: ${rt}-runtime → ✗ (gateway socket missing at $sock — container gateway not running)" + continue + fi + if ! printf '%s' "$status_json" \ + | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if '$rt' in d.get('serving', []) else 1)" 2>/dev/null; then + 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 + fi + if "$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 "$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 + 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. + **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. | | `⊘ 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 47fd1841..7d3d05c9 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 529d583e..1a720a04 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 3823e234..fff6aaba 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/tools/skill-evals/README.md b/tools/skill-evals/README.md index 0345c3d8..1b4b65b5 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 29ee2037..1c6c9199 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 00000000..5ac3edf2 --- /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 00000000..de03798c --- /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 00000000..0115c324 --- /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 00000000..2a3fe6f9 --- /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 00000000..0115c324 --- /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 00000000..3796964e --- /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 00000000..0115c324 --- /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 00000000..8c5a117e --- /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 95693a6a..646d6168 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 372e268f..dc07bfe9 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 f020e40c..c33714ea 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" } From 5b6f5c52a7cd8b3eed1b91dd4228919ea01be830 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 19:51:16 +0200 Subject: [PATCH 30/45] fix(setup): check gateway serving state before the socket-file test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probe 3 checked `-S "$sock"` before consulting `status`'s `serving` list, but a backend absent from `serving` never gets a socket file in the first place — so the check order made the new "gateway running without a backend" shape unreachable and mis-reported it as "container gateway not running" instead. Verified live against a running gateway serving only docker: probing podman now correctly reports the missing- backend shape instead of the wrong one. Generated-by: Claude Opus 5 --- docs/mode-economics.md | 4 +- .../skills/isolated-setup-doctor/SKILL.md | 45 ++++++++++++++----- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/docs/mode-economics.md b/docs/mode-economics.md index 044f8498..d84fe192 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: `42d189314b6a297691e2f536ecd506ada2b4fc9e19416a6e3e47a71948876def`. +Measurement manifest SHA-256: `63eb4c30bdd410621e64fd40f4ed86e1e98da6adf6c4c236341fac327d42bc57`. | Skill file | Measured tokens | Source SHA-256 (first 16 characters) | |---|---:|---| @@ -159,7 +159,7 @@ Measurement manifest SHA-256: `42d189314b6a297691e2f536ecd506ada2b4fc9e19416a6e3 | [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,563 | `856d95582da69e3b` | +| [setup-isolated-setup-doctor](../skills/setup-isolated-setup-doctor/SKILL.md) | 6,768 | `e9a112a6485a3867` | | [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 9,543 | `ab1ebf4531c2741c` | | [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 4,778 | `90f5b1418c16ea0a` | | [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 6,774 | `58e4c0785717bb05` | diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md index b8ff976c..9ebb67eb 100644 --- a/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md @@ -235,6 +235,22 @@ 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 +} + for rt in podman docker; do if ! command -v "$rt" > /dev/null 2>&1; then echo "PROBE: ${rt}-runtime → ⊘ ($rt not on PATH)" @@ -246,19 +262,22 @@ for rt in podman docker; do 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 ! printf '%s' "$status_json" \ - | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if '$rt' in d.get('serving', []) else 1)" 2>/dev/null; then - 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 - fi if "$rt" info > /dev/null 2>"${TMPDIR:-/tmp}/$rt-probe.err"; then echo "PROBE: ${rt}-runtime → ✓ ($rt reaches the container gateway at $sock)" else @@ -282,7 +301,13 @@ 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. +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:** From 1ecda482a5511ef0837fb21eb74761d2af589626 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 20:18:33 +0200 Subject: [PATCH 31/45] fix(container-gateway,sandbox-lint): tighten stop's process check and the daemon-socket exemption container-gateway: `_looks_like_a_gateway_process` now matches an argv shape (python -m container_gateway, or a container-gateway executable) on only the first line of `ps` output, instead of a substring search that also matched an editor opened on container_gateway.py; `stop` on a project that was never served now prints a message instead of exiting 0 silently. sandbox-lint: `check_invariants` casefolds the daemon-socket name match (macOS is case-insensitive) and accepts an optional `project_root` to anchor the `.apache-magpie-local/run` exemption, closing the gap where a decoy path such as `/tmp/evil/.apache-magpie-local/run/podman.sock` was indistinguishable from a legitimate project-scoped socket under the old unanchored suffix match; the CLI infers `project_root` from `--settings` and passes it. The README's "How to use" section is corrected from `--directory` to `--project` (the former changes the process's working directory, which breaks the tool's own `.claude/settings.json` lookup), and documents the residual for a direct `check_invariants` call made without a `project_root`. Generated-by: Claude Opus 5 --- .../src/container_gateway/__main__.py | 28 +++++- tools/container-gateway/tests/test_daemon.py | 98 ++++++++++++++++++- tools/sandbox-lint/README.md | 30 +++++- .../sandbox-lint/src/sandbox_lint/__init__.py | 64 ++++++++++-- tools/sandbox-lint/tests/test_validator.py | 85 ++++++++++++++++ 5 files changed, 289 insertions(+), 16 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/__main__.py b/tools/container-gateway/src/container_gateway/__main__.py index e147a4e1..19513ed9 100644 --- a/tools/container-gateway/src/container_gateway/__main__.py +++ b/tools/container-gateway/src/container_gateway/__main__.py @@ -23,6 +23,7 @@ import asyncio import json import os +import re import signal import sys import time @@ -122,6 +123,17 @@ def cmd_serve(ns: argparse.Namespace) -> int: return asyncio.run(daemon.run(cfg)) +# Matches only the argv shapes `cmd_serve`/the hook actually invoke: a +# python interpreter 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*/)?python\d*(?:\.\d+)?\s+-m\s+container_gateway(?:\s|$)|^(?:\S*/)?container-gateway(?:\s|$)" +) + + def _looks_like_a_gateway_process(pid: int) -> bool: """Refuse to signal anything that does not look like this gateway. @@ -136,17 +148,27 @@ def _looks_like_a_gateway_process(pid: int) -> bool: before ever signalling it closes that last gap; an unreadable command line (``ps`` unavailable, the process already gone) refuses rather than guesses. + + 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 line is None: + if not line: return False - return "container_gateway" in line or "container-gateway" in line + 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 daemon.validate_run_dir(cfg.run_dir, cfg.project_root): - return 0 # never served, or the run directory itself no longer exists + # 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 diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py index 0b0d68a7..62469a89 100644 --- a/tools/container-gateway/tests/test_daemon.py +++ b/tools/container-gateway/tests/test_daemon.py @@ -425,6 +425,81 @@ def test_cli_stop_refuses_when_process_does_not_look_like_gateway( 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" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + our_pid = os.getpid() + calls: list[tuple[int, int]] = [] + terminated = False + + def fake_kill(pid: int, sig: int) -> None: + nonlocal terminated + calls.append((pid, sig)) + if sig == signal.SIGTERM: + terminated = True + os.close(fd) # 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 + + def test_cli_stop_refuses_when_ps_is_unavailable( tmp_path: Path, short_run_dir: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -595,6 +670,18 @@ def test_cli_stop_when_lock_never_held_is_a_noop(tmp_path: Path, short_run_dir: assert cli.cmd_stop(_ns(tmp_path, short_run_dir)) == 0 +def test_cli_stop_prints_a_message_when_no_run_directory_ever_existed( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A project that was never served must not look identical to "stopped successfully".""" + 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 run directory at {run_dir}: nothing to stop" in out + + # ------------------------- D3: unlink the pid file before closing its fd @@ -803,7 +890,13 @@ def test_cli_status_when_not_running(tmp_path: Path) -> None: assert "backends" not in out -def test_cli_stop_when_not_running_is_quiet(tmp_path: Path) -> None: +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, @@ -811,7 +904,8 @@ def test_cli_stop_when_not_running_is_quiet(tmp_path: Path) -> None: env={**os.environ, "PYTHONPATH": str(SRC)}, check=False, ) - assert done.returncode == 0 and done.stdout.strip() == "" + assert done.returncode == 0 + assert "nothing to stop" in done.stdout def test_cli_serve_help_lists_flags() -> None: diff --git a/tools/sandbox-lint/README.md b/tools/sandbox-lint/README.md index ee99fae8..a5440fe4 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/src/sandbox_lint/__init__.py b/tools/sandbox-lint/src/sandbox_lint/__init__.py index a0076dc5..5cdc46bf 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,22 @@ 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. + """ errors: list[str] = [] sandbox = settings.get("sandbox") @@ -262,10 +277,24 @@ def check_invariants(settings: dict[str, Any]) -> list[str]: # 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", []): - name = entry.rstrip("/").rsplit("/", 1)[-1] - parent = entry.rstrip("/").rsplit("/", 1)[0] if "/" in entry else "" - is_daemon = name in ("docker.sock", "podman.sock") or name.endswith("-api.sock") - if is_daemon and not parent.endswith(".apache-magpie-local/run"): + 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" @@ -406,6 +435,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", @@ -507,11 +554,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 8528a013..d16f6b52 100644 --- a/tools/sandbox-lint/tests/test_validator.py +++ b/tools/sandbox-lint/tests/test_validator.py @@ -434,3 +434,88 @@ def test_gateway_sockets_pass_the_invariant(baseline: dict[str, Any]) -> None: "/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 From 0ef8debc3da245569a7b7a25575337b8f51b460c Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 20:54:47 +0200 Subject: [PATCH 32/45] docs(container-gateway): flip spec status to experimental and close review gaps Amend the spec to match what shipped (allow-list rules, refused VolumesFrom, the relay's label checks and volume pre-create, the one-way module imports, the sandbox facts). Correct the sandbox-lint invocation in every doc that carried the --directory form, reorder the RFC-AI-0004 row, reconcile the canonical verification checklist, and bump the spec sync marker. Generated-by: Claude Opus 5 --- docs/adapters/codex.md | 2 +- docs/mode-economics.md | 4 +- docs/rfcs/RFC-AI-0004.md | 2 +- docs/setup/sandbox-troubleshooting.md | 2 + docs/setup/secure-agent-setup.md | 102 ++++++++++++------ .../skills/isolated-setup-doctor/SKILL.md | 28 ++++- tools/spec-loop/.last-sync | 2 +- tools/spec-loop/specs/codex-runtime.md | 2 +- tools/spec-loop/specs/container-gateway.md | 45 ++++++-- tools/spec-loop/specs/overview.md | 2 +- 10 files changed, 145 insertions(+), 46 deletions(-) diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index 59e757c4..72cd0f64 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/mode-economics.md b/docs/mode-economics.md index d84fe192..9071b5e2 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: `63eb4c30bdd410621e64fd40f4ed86e1e98da6adf6c4c236341fac327d42bc57`. +Measurement manifest SHA-256: `b309bce5174a571279f32d3278b2a51645e129d561df33d62de34144b10fc0f4`. | Skill file | Measured tokens | Source SHA-256 (first 16 characters) | |---|---:|---| @@ -159,7 +159,7 @@ Measurement manifest SHA-256: `63eb4c30bdd410621e64fd40f4ed86e1e98da6adf6c4c2363 | [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,768 | `e9a112a6485a3867` | +| [setup-isolated-setup-doctor](../skills/setup-isolated-setup-doctor/SKILL.md) | 7,046 | `882cf13fbc4404fa` | | [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 9,543 | `ab1ebf4531c2741c` | | [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 4,778 | `90f5b1418c16ea0a` | | [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 6,774 | `58e4c0785717bb05` | diff --git a/docs/rfcs/RFC-AI-0004.md b/docs/rfcs/RFC-AI-0004.md index 106a56ae..4473d6f9 100644 --- a/docs/rfcs/RFC-AI-0004.md +++ b/docs/rfcs/RFC-AI-0004.md @@ -185,9 +185,9 @@ 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. | -| **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. | ### Five concrete consequences diff --git a/docs/setup/sandbox-troubleshooting.md b/docs/setup/sandbox-troubleshooting.md index 8eaa0b52..1e2005d8 100644 --- a/docs/setup/sandbox-troubleshooting.md +++ b/docs/setup/sandbox-troubleshooting.md @@ -649,6 +649,8 @@ Adjust the request rather than widening the sandbox: a `403` from the gateway is 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 cae90842..5f0f3862 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -2935,10 +2935,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. @@ -2953,7 +2960,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 @@ -2965,38 +2984,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/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md index 9ebb67eb..67c9f786 100644 --- a/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md @@ -231,6 +231,12 @@ is missing, in the order a fresh install would hit them. **Command:** ```bash +# 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) @@ -251,6 +257,26 @@ else: " 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)" @@ -278,7 +304,7 @@ for rt in podman docker; do echo "PROBE: ${rt}-runtime → ✗ (gateway socket missing at $sock — container gateway not running)" continue fi - if "$rt" info > /dev/null 2>"${TMPDIR:-/tmp}/$rt-probe.err"; then + 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=$? diff --git a/tools/spec-loop/.last-sync b/tools/spec-loop/.last-sync index 062cee97..7452720a 100644 --- a/tools/spec-loop/.last-sync +++ b/tools/spec-loop/.last-sync @@ -1 +1 @@ -b7b27cab0b1b62e01a2b33807a2845cea5b41bc7 +492b582699b605cfc97a7bc7654009647fe0fb19 diff --git a/tools/spec-loop/specs/codex-runtime.md b/tools/spec-loop/specs/codex-runtime.md index 1bb0bf4f..cd922a38 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 index cbbfbcc2..d9725941 100644 --- a/tools/spec-loop/specs/container-gateway.md +++ b/tools/spec-loop/specs/container-gateway.md @@ -3,7 +3,7 @@ --- title: Container gateway (podman / docker inside the sandbox) -status: proposed +status: experimental kind: feature mode: infra source: > @@ -115,7 +115,11 @@ config` writes the absolute paths into the gitignored 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. Start-up order: +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 @@ -125,6 +129,12 @@ 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 | @@ -152,6 +162,15 @@ 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 / @@ -194,16 +213,28 @@ tree) and: | `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` | +| `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 (`bridge`, `podman`, `none`, `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`; every other recognised key (`apparmor`, `label`, `no-new-privileges`, `systempaths`) has its unsafe value (`unconfined`, `disable`, `false`, `unconfined`) 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 the project scratch tree; anything else denied. `tmpfs` allowed | -| `Mounts[type=volume]`, named volumes in `Binds`, `VolumesFrom` | the volume / container must carry the label | +| `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 @@ -335,7 +366,7 @@ See the frontmatter `acceptance:` list. Additionally: # 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 --directory tools/sandbox-lint --group dev sandbox-lint +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/ diff --git a/tools/spec-loop/specs/overview.md b/tools/spec-loop/specs/overview.md index 92a510a3..1ed0df2d 100644 --- a/tools/spec-loop/specs/overview.md +++ b/tools/spec-loop/specs/overview.md @@ -49,7 +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 (proposed) | [container-gateway.md](container-gateway.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) | From d3fcaeeffa081fb69528c88880f725de605d47a4 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 20:55:27 +0200 Subject: [PATCH 33/45] test(container-gateway): integration suite against a real backend Runs only with -m integration and a reachable podman or docker; skips cleanly everywhere else, including inside the sandbox, where binding a unix socket is refused. Generated-by: Claude Opus 5 --- .../tests/test_integration.py | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tools/container-gateway/tests/test_integration.py diff --git a/tools/container-gateway/tests/test_integration.py b/tools/container-gateway/tests/test_integration.py new file mode 100644 index 00000000..f7bdffe6 --- /dev/null +++ b/tools/container-gateway/tests/test_integration.py @@ -0,0 +1,154 @@ +# 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 time +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from container_gateway import backends + +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 + + +@pytest.fixture(scope="module") +def gateway(tmp_path_factory: pytest.TempPathFactory) -> 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 = tmp_path_factory.mktemp("proj") + (project / "data").mkdir() + env = {**os.environ, "PYTHONPATH": str(SRC)} + proc = subprocess.Popen( + [sys.executable, "-m", "container_gateway", "serve", "--project", str(project), "--egress", "off"], + env=env, + ) + sock = project / ".apache-magpie-local" / "run" / f"{b.kind}.sock" + for _ in range(50): + if sock.exists(): + break + time.sleep(0.1) + else: + proc.terminate() + pytest.fail("gateway did not come up") + yield project, cli + proc.terminate() + proc.wait(10) + + +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], tmp_path: Path) -> 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 = tmp_path / "other" + other.mkdir() + env = {**os.environ, "PYTHONPATH": str(SRC)} + proc = subprocess.Popen( + [sys.executable, "-m", "container_gateway", "serve", "--project", str(other), "--egress", "off"], + env=env, + ) + try: + time.sleep(1.5) + 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: + proc.terminate() + proc.wait(10) + finally: + _run(cli, project, "rm", "-f", "gw-it-sleeper") From f29ff41cae751ea5db5dc0cbcca8a46fccf85950 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 21:11:13 +0200 Subject: [PATCH 34/45] fix(container-gateway): short socket paths and robust teardown in the integration suite; spec matches the shipped policy F1: build integration-suite project roots with tempfile.mkdtemp() directly under $TMPDIR instead of tmp_path_factory.mktemp(), which nests under pytest-of-/pytest-// and can push the gateway socket path over the 104-byte sun_path limit on a normal macOS TMPDIR; assert the constructed socket path length at setup with a clear message instead of letting it surface as "gateway did not come up". F2: add a _stop() helper (terminate, wait, kill-then-wait on TimeoutExpired) and use it on every exit path of the gateway fixture, including the setup-failure branch, and for the second gateway in test_other_project_is_invisible; poll for the second gateway's socket instead of a fixed sleep(1.5) so a dead gateway cannot make the "not visible" assertion pass vacuously. F3: correct the NetworkMode allow-list row (the shipped set has no "podman" keyword), state what actually shipped for CONTAINER_HOST / DOCKER_HOST vs allowUnixSockets instead of the unresolved "if it turns out..." conditional, and switch the frontmatter source: line to past tense. F4 (five minors): cmd_stop now distinguishes a missing project root from a missing run directory; _GATEWAY_ARGV_RE accepts interpreter flags and a uv-run prefix before -m, and the docstring no longer claims the ps check is a security boundary (argv is attacker-settable); the doctor's Probe 3 names a hung ` info` call (rc 137/143) instead of printing an empty error; the spec's SecurityOpt row matches the code's actual per-key allow-lists; and check_invariants documents that its anchoring is a lexical (normpath) comparison, not symlink-resolved, and fails closed. Generated-by: Claude Opus 5 --- docs/mode-economics.md | 4 +- .../skills/isolated-setup-doctor/SKILL.md | 22 ++- .../src/container_gateway/__main__.py | 30 ++-- tools/container-gateway/tests/test_daemon.py | 51 ++++++- .../tests/test_integration.py | 137 +++++++++++++----- .../sandbox-lint/src/sandbox_lint/__init__.py | 10 ++ tools/spec-loop/specs/container-gateway.md | 66 +++++---- 7 files changed, 240 insertions(+), 80 deletions(-) diff --git a/docs/mode-economics.md b/docs/mode-economics.md index 9071b5e2..ba1e58b1 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: `b309bce5174a571279f32d3278b2a51645e129d561df33d62de34144b10fc0f4`. +Measurement manifest SHA-256: `4c4a1128c69b71a2eb608b5ed97b3d55b3046c5586566fbc5802041a4937bb58`. | Skill file | Measured tokens | Source SHA-256 (first 16 characters) | |---|---:|---| @@ -159,7 +159,7 @@ Measurement manifest SHA-256: `b309bce5174a571279f32d3278b2a51645e129d561df33d62 | [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) | 7,046 | `882cf13fbc4404fa` | +| [setup-isolated-setup-doctor](../skills/setup-isolated-setup-doctor/SKILL.md) | 7,226 | `659aaa576e392f32` | | [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 9,543 | `ab1ebf4531c2741c` | | [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 4,778 | `90f5b1418c16ea0a` | | [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 6,774 | `58e4c0785717bb05` | diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md index 67c9f786..11947bac 100644 --- a/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md @@ -309,12 +309,21 @@ for rt in podman docker; do else rc=$? err=$(head -1 "${TMPDIR:-/tmp}/$rt-probe.err") - 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)" ;; + 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 @@ -345,6 +354,7 @@ backend whose socket vanished mid-probe, not the primary check. | `✗ 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 diff --git a/tools/container-gateway/src/container_gateway/__main__.py b/tools/container-gateway/src/container_gateway/__main__.py index 19513ed9..f47be8a7 100644 --- a/tools/container-gateway/src/container_gateway/__main__.py +++ b/tools/container-gateway/src/container_gateway/__main__.py @@ -124,13 +124,16 @@ def cmd_serve(ns: argparse.Namespace) -> int: # Matches only the argv shapes `cmd_serve`/the hook actually invoke: a -# python interpreter 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. +# 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*/)?python\d*(?:\.\d+)?\s+-m\s+container_gateway(?:\s|$)|^(?:\S*/)?container-gateway(?:\s|$)" + r"^(?:\S*/)?(?:uv\s+run\s+)?(?:\S*/)?python\d*(?:\.\d+)?(?:\s+-\S+)*\s+-m\s+container_gateway(?:\s|$)" + r"|^(?:\S*/)?container-gateway(?:\s|$)" ) @@ -145,9 +148,12 @@ def _looks_like_a_gateway_process(pid: int) -> bool: ``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 closes that last gap; an unreadable - command line (``ps`` unavailable, the process already gone) refuses - rather than guesses. + 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 @@ -163,6 +169,12 @@ def _looks_like_a_gateway_process(pid: int) -> bool: 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 diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py index 62469a89..76b5df08 100644 --- a/tools/container-gateway/tests/test_daemon.py +++ b/tools/container-gateway/tests/test_daemon.py @@ -500,6 +500,40 @@ def fake_kill(pid: int, sig: int) -> None: 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" + fd = daemon.acquire_pid_lock(pid_file) + assert fd is not None + our_pid = os.getpid() + calls: list[tuple[int, int]] = [] + terminated = False + + def fake_kill(pid: int, sig: int) -> None: + nonlocal terminated + calls.append((pid, sig)) + if sig == signal.SIGTERM: + terminated = True + os.close(fd) # 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: @@ -670,15 +704,28 @@ def test_cli_stop_when_lock_never_held_is_a_noop(tmp_path: Path, short_run_dir: assert cli.cmd_stop(_ns(tmp_path, short_run_dir)) == 0 -def test_cli_stop_prints_a_message_when_no_run_directory_ever_existed( +def test_cli_stop_prints_a_message_when_project_root_does_not_exist( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """A project that was never served must not look identical to "stopped successfully".""" + """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 diff --git a/tools/container-gateway/tests/test_integration.py b/tools/container-gateway/tests/test_integration.py index f7bdffe6..0a407a19 100644 --- a/tools/container-gateway/tests/test_integration.py +++ b/tools/container-gateway/tests/test_integration.py @@ -25,13 +25,14 @@ 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 +from container_gateway import backends, daemon pytestmark = pytest.mark.integration SRC = Path(__file__).resolve().parents[1] / "src" @@ -62,30 +63,84 @@ def _backend() -> backends.Backend | None: 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(tmp_path_factory: pytest.TempPathFactory) -> Iterator[tuple[Path, str]]: +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 = tmp_path_factory.mktemp("proj") - (project / "data").mkdir() - env = {**os.environ, "PYTHONPATH": str(SRC)} - proc = subprocess.Popen( - [sys.executable, "-m", "container_gateway", "serve", "--project", str(project), "--egress", "off"], - env=env, - ) - sock = project / ".apache-magpie-local" / "run" / f"{b.kind}.sock" - for _ in range(50): - if sock.exists(): - break - time.sleep(0.1) - else: - proc.terminate() - pytest.fail("gateway did not come up") - yield project, cli - proc.terminate() - proc.wait(10) + 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]: @@ -128,27 +183,39 @@ def test_privileged_is_refused(gateway: tuple[Path, str]) -> None: assert "container-gateway: privileged" in done.stderr -def test_other_project_is_invisible(gateway: tuple[Path, str], tmp_path: Path) -> None: +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 = tmp_path / "other" - other.mkdir() - env = {**os.environ, "PYTHONPATH": str(SRC)} - proc = subprocess.Popen( - [sys.executable, "-m", "container_gateway", "serve", "--project", str(other), "--egress", "off"], - env=env, - ) + other = _short_project_dir() try: - time.sleep(1.5) - 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 + 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: - proc.terminate() - proc.wait(10) + shutil.rmtree(other, ignore_errors=True) finally: _run(cli, project, "rm", "-f", "gw-it-sleeper") diff --git a/tools/sandbox-lint/src/sandbox_lint/__init__.py b/tools/sandbox-lint/src/sandbox_lint/__init__.py index 5cdc46bf..6cd95c7c 100644 --- a/tools/sandbox-lint/src/sandbox_lint/__init__.py +++ b/tools/sandbox-lint/src/sandbox_lint/__init__.py @@ -206,6 +206,16 @@ def check_invariants(settings: dict[str, Any], project_root: Path | None = None) 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] = [] diff --git a/tools/spec-loop/specs/container-gateway.md b/tools/spec-loop/specs/container-gateway.md index d9725941..2406504e 100644 --- a/tools/spec-loop/specs/container-gateway.md +++ b/tools/spec-loop/specs/container-gateway.md @@ -9,8 +9,8 @@ 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). To be - implemented in tools/container-gateway/, a SessionStart/SessionEnd + 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 @@ -106,13 +106,18 @@ CLIs speak. Three properties fall out of the policy: 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 -so the committed reference settings can name them with the -project-relative prefix the sandbox's path syntax supports; if -`allowUnixSockets` turns out not to honour that prefix, `/magpie-setup -config` writes the absolute paths into the gitignored -`.claude/settings.local.json` instead. The macOS limit of 104 bytes on -a socket path is checked at start and reported. +(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, and +`/magpie-setup config` writes the two sockets' **absolute** paths into +the gitignored, per-project `.claude/settings.local.json` instead — +see [Container gateway](../../../docs/setup/secure-agent-setup.md#container-gateway) +in the setup guide. 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 @@ -214,8 +219,8 @@ tree) and: | `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 (`bridge`, `podman`, `none`, `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`; every other recognised key (`apparmor`, `label`, `no-new-privileges`, `systempaths`) has its unsafe value (`unconfined`, `disable`, `false`, `unconfined`) denied; an unrecognised key (including `unmask`, `proc-opts`) is denied outright | +| `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 the project scratch tree; anything else denied. `tmpfs` allowed | @@ -293,14 +298,6 @@ Reference settings (committed, project-agnostic): "CONTAINER_HOST": "unix://./.apache-magpie-local/run/podman.sock", "DOCKER_HOST": "unix://./.apache-magpie-local/run/docker.sock" }, - "sandbox": { - "network": { - "allowUnixSockets": [ - "./.apache-magpie-local/run/podman.sock", - "./.apache-magpie-local/run/docker.sock" - ] - } - }, "hooks": { "SessionStart": [{ "hooks": [{ "type": "command", "command": "~/.claude/scripts/container-gateway-hook.sh start" }] }], @@ -310,13 +307,30 @@ Reference settings (committed, project-agnostic): } ``` -`CONTAINER_HOST` / `DOCKER_HOST` are resolved by the CLIs relative to -the cwd only if the CLI does so; if either CLI rejects a relative unix -path, the hook exports the absolute form through the same env block in -`.claude/settings.local.json` written by `/magpie-setup config`. The -implementation plan starts with a spike that settles both questions -(relative `allowUnixSockets` entries; relative `unix://` URLs in the -two CLIs) before any settings text is written. +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 — written into the gitignored +`.claude/settings.local.json` by `/magpie-setup config`, never into the +committed baseline: + +```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 From e469810d7be27cebdce2d50207ef661bcbc8cbb2 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 21:39:21 +0200 Subject: [PATCH 35/45] fix(container-gateway): make the create body an allow-list The create policy was a deny-list over an unbounded JSON body, so every field neither CLI documents and nobody thought of was forwarded. Probes against the shipped code confirmed five of them: libpod `rootfs` makes a host directory the container's root filesystem, `overlay_volumes` mounts host paths, `env_host` exports the host environment, `log_configuration` writes a host path, and `secret_env` / `secrets` reach the daemon's own secret store. The posture is now the other way round: a create body may carry only the fields the gateway has learned, in either shape, and anything else is refused as `unknown-field`. The fields above keep reasons of their own so the message is useful, and they 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. Two compatibility fixes fall out of running real client bodies through the new table: `default` is the docker CLI's sentinel for the default bridge, sent as `EndpointsConfig: {"default": {}}` on every `docker run`, not a network the relay can inspect. Treating it as a named network refused every create against a real daemon. `Networks` (capital N) is podman's own spelling of the per-network map; the canonical-spelling table knew only `networks`, so every `podman run` was refused as ambiguous-field and the named network in it was never label-checked. Both spellings are now canonical, and both are read. Generated-by: Claude Opus 5 --- .../src/container_gateway/policy.py | 233 ++++++++-- .../src/container_gateway/policy_shape.py | 422 +++++++++++++++++- .../tests/test_policy_create.py | 295 +++++++++++- 3 files changed, 904 insertions(+), 46 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/policy.py b/tools/container-gateway/src/container_gateway/policy.py index aa4f17fc..e246f64b 100644 --- a/tools/container-gateway/src/container_gateway/policy.py +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -46,18 +46,28 @@ 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", @@ -90,6 +100,10 @@ _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 @@ -110,12 +124,19 @@ # 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"}) -_ENDPOINT_KEY_ALLOWED_KEYWORDS = frozenset({"bridge", "podman"}) +# `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 -# deliberately absent — see `_ENDPOINT_KEY_ALLOWED_KEYWORDS` above. -_BUILTIN_NETWORK_NAMES = frozenset({"bridge", "podman", "host", "none"}) +# *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. @@ -137,6 +158,7 @@ "CapDrop", "cap_drop", "SecurityOpt", + "security_opt", "selinux_opts", "Devices", "DeviceRequests", @@ -150,7 +172,7 @@ "volumes_from", ) _MOUNT_LIST_HOST_FIELDS = ("Mounts", "mounts", "portmappings", "volumes") -_DICT_OR_NONE_HOST_FIELDS = ("Sysctls", "sysctl") +_DICT_OR_NONE_HOST_FIELDS = ("Sysctls", "sysctl", "LogConfig", "log_configuration") _NAMESPACE_HOST_FIELDS = ( "PidMode", "IpcMode", @@ -169,7 +191,7 @@ # (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") +_DICT_OR_NONE_BODY_FIELDS = ("NetworkingConfig", "networks", "Networks") _STR_LIST_BODY_FIELDS = ("Env",) _STR_DICT_BODY_FIELDS = ("env", "Labels", "labels") @@ -322,8 +344,73 @@ def _security_opt_denied(opt: str) -> bool: 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"}) + + +def _log_config_deny(host: dict[str, Any]) -> Deny | None: + """Compat ``LogConfig``: the empty one every ``docker run`` sends, nothing more. + + 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. + """ + 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") + if log_config.get("Config"): + return Deny("log-configuration: log-driver options are 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 []: + 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") @@ -501,15 +588,8 @@ def named_networks(body: dict[str, Any], libpod: bool) -> list[str]: if network_mode is not None: candidates.append(network_mode) - networks = body.get("networks") - if isinstance(networks, dict): - candidates.extend(str(k) for k in networks) - - networking_config = body.get("NetworkingConfig") - if isinstance(networking_config, dict): - endpoints_config = networking_config.get("EndpointsConfig") - if isinstance(endpoints_config, dict): - candidates.extend(str(k) for k in endpoints_config) + for endpoint_map in _endpoint_maps(body): + candidates.extend(str(k) for k in endpoint_map) seen: set[str] = set() result: list[str] = [] @@ -547,6 +627,19 @@ def _check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> 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"): @@ -583,30 +676,28 @@ def _check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> if net_deny is not None: return net_deny - # Read both the libpod and compat endpoint-map shapes 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`. - networks = body.get("networks") - if isinstance(networks, dict): - for network_key in networks: + # 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 - networking_config = body.get("NetworkingConfig") - if isinstance(networking_config, dict): - endpoints_config = networking_config.get("EndpointsConfig") - if isinstance(endpoints_config, dict): - for network_key in endpoints_config: - 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"): @@ -641,6 +732,79 @@ def _check_create(body: dict[str, Any], ctx: PolicyContext, *, libpod: bool) -> 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. @@ -661,6 +825,11 @@ def apply_create_rewrites(body: dict[str, Any], ctx: PolicyContext, *, libpod: b } 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 diff --git a/tools/container-gateway/src/container_gateway/policy_shape.py b/tools/container-gateway/src/container_gateway/policy_shape.py index 4dd4a34a..453f4a83 100644 --- a/tools/container-gateway/src/container_gateway/policy_shape.py +++ b/tools/container-gateway/src/container_gateway/policy_shape.py @@ -142,6 +142,11 @@ def message(self) -> str: "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", } ) @@ -176,7 +181,14 @@ def _spelling_violation_in(obj: dict[str, Any], known: frozenset[str]) -> Deny | 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 it (an aliased field the policy would not recognise). + 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: @@ -188,14 +200,27 @@ def _spelling_violation_in(obj: dict[str, Any], known: frozenset[str]) -> Deny | a, b = sorted(keys)[:2] return Deny(f"ambiguous-field: {a} and {b} name the same field") - known_by_casefold = {k.casefold(): k for k in known} + 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(): - canonical = known_by_casefold.get(cf) - if canonical is not None and keys[0] != canonical: + 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. @@ -232,11 +257,12 @@ def canonical_spelling_violation(body: dict[str, Any], libpod: bool) -> Deny | N violation = _spelling_violation_in(namespace_obj, NAMESPACE_OBJECT_KEYS) if violation is not None: return violation - networks = body.get("networks") - if isinstance(networks, dict): - violation = _spelling_violation_in(networks, frozenset()) - 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) @@ -290,3 +316,381 @@ def resource_create_spelling_violation(body: dict[str, Any], libpod: bool) -> De """ 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", + # 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. +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``. 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) or denied_fields.get(key) + 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/tests/test_policy_create.py b/tools/container-gateway/tests/test_policy_create.py index a662070b..a767beec 100644 --- a/tools/container-gateway/tests/test_policy_create.py +++ b/tools/container-gateway/tests/test_policy_create.py @@ -437,13 +437,19 @@ def test_network_name_grammar_rejects_trailing_newline(ctx: PolicyContext) -> No assert isinstance(d2, Deny) and d2.reason.startswith("network") -def test_default_named_network_reaches_label_check(ctx: PolicyContext) -> None: - # A user-created network literally named "default" is not the built-in - # default network; it must reach the relay's label check like any other - # named network, not be silently treated as always-reachable. +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) == ["default"] + 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: @@ -486,3 +492,282 @@ def test_named_networks_reads_both_endpoint_shapes_regardless_of_libpod_flag(ctx 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 From 232a59136033495788f46df3070fafcbfe65df4b Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 21:41:48 +0200 Subject: [PATCH 36/45] fix(container-gateway): policy the build query, the exec body and the act-by-name query Three surfaces reached the daemon without a policy of their own. The build endpoint had none beyond label injection: `POST /build?networkmode=host&volume=/:/host` forwarded verbatim, so `RUN` executed with the host mounted, on the host network, and with no proxy injection. The query is now an allow-list in the same shape as the create body, with the host-reaching parameters refused by name (including podman's own spellings, and the `nsoptions` entry a `--network host` build carries), and the egress proxy merged into `buildargs` so a build's network is bound the same way a container's is. Exec bodies were buffered and never checked, so `POST /containers//exec {"Privileged": true}` was an allow. Exec and update bodies are now allow-listed too, with the same reason slugs the create rules use. Query strings on act-by-name routes were unexamined: `checkpoint?export=/Users/me/x.tar` wrote a host path daemon-side. Checkpoint, restore, generate and play are refused outright, and `export` / `import` are refused as query parameters on every route. Also: route the compat `POST /commit?container=` spelling, which was refused as unknown, taking the container from the query and label-checking it like any other act-by-name call; and drop the inert assertion in `routes.route`. Generated-by: Claude Opus 5 --- .../src/container_gateway/decisions.py | 291 +++++++++++++++++- .../src/container_gateway/relay.py | 18 +- .../src/container_gateway/routes.py | 7 +- .../tests/test_policy_build.py | 197 ++++++++++++ .../tests/test_policy_labels.py | 111 ++++++- 5 files changed, 604 insertions(+), 20 deletions(-) create mode 100644 tools/container-gateway/tests/test_policy_build.py diff --git a/tools/container-gateway/src/container_gateway/decisions.py b/tools/container-gateway/src/container_gateway/decisions.py index 645456a4..5c69ab9c 100644 --- a/tools/container-gateway/src/container_gateway/decisions.py +++ b/tools/container-gateway/src/container_gateway/decisions.py @@ -44,12 +44,16 @@ from typing import Any from urllib.parse import quote -from .labels import merge_filters, with_label +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 @@ -57,6 +61,8 @@ __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"} @@ -106,6 +112,11 @@ class Allow: 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 @@ -114,6 +125,139 @@ class Allow: # 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", + "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"}) + def _first(query: dict[str, list[str]], key: str) -> str | None: """The first value of a (possibly absent, possibly empty) query parameter.""" @@ -140,6 +284,115 @@ def _path_is_malformed(path: str) -> bool: 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_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"): + # buildah's `--output` and buildkit's `--output` both write to + # a filesystem path when the value names a destination. + for value in values: + if "dest=" in value.casefold(): + return Deny(f"denied-build-parameter: writing build output to {value} is refused") + 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) @@ -158,6 +411,11 @@ def _decide(req: Request, ctx: PolicyContext) -> Allow | Deny: 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: @@ -179,15 +437,24 @@ def _decide(req: Request, ctx: PolicyContext) -> Allow | Deny: 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: - raw_labels = _first(req.query, "labels") - try: - labels = _json.loads(raw_labels) if raw_labels else {} - except _json.JSONDecodeError as exc: - return Deny(f"malformed: build labels is not valid JSON: {exc}") - 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=(",", ":"))] + 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: @@ -203,6 +470,12 @@ def _decide(req: Request, ctx: PolicyContext) -> Allow | Deny: 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: diff --git a/tools/container-gateway/src/container_gateway/relay.py b/tools/container-gateway/src/container_gateway/relay.py index 15891b83..7f7f335c 100644 --- a/tools/container-gateway/src/container_gateway/relay.py +++ b/tools/container-gateway/src/container_gateway/relay.py @@ -374,11 +374,19 @@ async def _forward( 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")) - 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 + 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) diff --git a/tools/container-gateway/src/container_gateway/routes.py b/tools/container-gateway/src/container_gateway/routes.py index a4bf9b0a..3979f658 100644 --- a/tools/container-gateway/src/container_gateway/routes.py +++ b/tools/container-gateway/src/container_gateway/routes.py @@ -139,6 +139,12 @@ def route(method: str, path: str) -> Route: 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"}.get( @@ -178,7 +184,6 @@ def route(method: str, path: str) -> Route: elif verb == "push": return Route(Family.DENIED, "push", name, libpod, version) else: - assert verb is not None action = verb return Route(family, action, name, libpod, version) 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 00000000..7772331f --- /dev/null +++ b/tools/container-gateway/tests/test_policy_build.py @@ -0,0 +1,197 @@ +# 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 diff --git a/tools/container-gateway/tests/test_policy_labels.py b/tools/container-gateway/tests/test_policy_labels.py index 4b93a98c..e1ff86fa 100644 --- a/tools/container-gateway/tests/test_policy_labels.py +++ b/tools/container-gateway/tests/test_policy_labels.py @@ -151,7 +151,6 @@ def test_resource_create_non_dict_body_is_denied(ctx: PolicyContext) -> None: ("/v1.45/containers/json", {"filters": ["notjson"]}), ("/v1.45/containers/json", {"filters": ["[1,2]"]}), ("/v1.45/build", {"labels": ["notjson"]}), - ("/v1.45/build", {"labels": ["[1,2]"]}), ("/v1.45/build", {"labels": ["5"]}), ], ) @@ -182,7 +181,13 @@ def test_container_create_non_dict_body_is_denied(ctx: PolicyContext) -> None: assert isinstance(d, Deny) and d.reason.startswith("malformed") -@pytest.mark.parametrize("verb", sorted(_VERBS - {"push"})) +# 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")], @@ -190,7 +195,7 @@ def test_container_create_non_dict_body_is_denied(ctx: PolicyContext) -> None: 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}"), ctx) + 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 @@ -198,8 +203,6 @@ def test_every_named_verb_route_carries_a_label_check( @pytest.mark.parametrize( ("method", "path"), [ - ("POST", "/v1.45/containers/web1/checkpoint"), - ("POST", "/v1.45/containers/web1/restore"), ("POST", "/v5.2.0/libpod/pods/p1/init"), ("GET", "/v1.45/containers/web1/get"), ("POST", "/v1.45/networks/n1/exists"), @@ -216,3 +219,101 @@ def test_no_named_route_is_fail_open_except_image_reads(ctx: PolicyContext, meth 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") From 0f200c5ab263a9d11ee40bf5c8a5daf621657604 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 21:44:06 +0200 Subject: [PATCH 37/45] fix(container-gateway): keep the hook's trust model and the bind roots narrow The hook's trust model covered where the gateway's code comes from but not two variables a repository can set through project settings. `PYTHONPATH` was extended rather than replaced, so an inherited entry could shadow a stdlib module the package imports and run repository code inside the gateway process; it is now replaced outright. `MAGPIE_CONTAINER_GATEWAY_ARGS` was spliced into the serve command unvalidated, so `--extra-bind-root /` widened the bind roots to the whole filesystem; its tokens are now allow-listed against the serve flags that are safe to take from the environment, and one bad token drops the whole variable with a line on stderr. `$TMPDIR` is no longer a bind root. On macOS it is per-user, not per-project, so every project on the machine shared one root and could bind-mount another project's scratch tree, including the agent's own. The project root and whatever `--extra-bind-root` names are the only roots left. Generated-by: Claude Opus 5 --- .../agent-isolation/container-gateway-hook.sh | 43 +++++++++++- .../tests/test_container_gateway_hook.py | 66 +++++++++++++++++++ .../src/container_gateway/daemon.py | 16 ++--- tools/container-gateway/tests/test_daemon.py | 33 ++++++++++ 4 files changed, 148 insertions(+), 10 deletions(-) diff --git a/tools/agent-isolation/container-gateway-hook.sh b/tools/agent-isolation/container-gateway-hook.sh index 7e2d9c4a..3c2f1b23 100755 --- a/tools/agent-isolation/container-gateway-hook.sh +++ b/tools/agent-isolation/container-gateway-hook.sh @@ -32,7 +32,19 @@ # (development override), $HOME/.claude/scripts/container-gateway/src (operator # install), /.apache-magpie/tools/container-gateway/src (pinned snapshot). # -# Extra serve flags: $MAGPIE_CONTAINER_GATEWAY_ARGS (e.g. "--egress require"). +# 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 @@ -60,9 +72,35 @@ for candidate in "${MAGPIE_CONTAINER_GATEWAY_SRC:-}" \ 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") @@ -72,5 +110,6 @@ if [[ -n ${MAGPIE_CONTAINER_GATEWAY_DRY_RUN:-} ]]; then printf 'PYTHONPATH=%s %s\n' "$src" "${cmd[*]}" exit 0 fi -PYTHONPATH="$src${PYTHONPATH:+:$PYTHONPATH}" "${cmd[@]}" >/dev/null 2>&1 || true +# 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 index d8911ebb..7ea45ce6 100644 --- a/tools/agent-isolation/tests/test_container_gateway_hook.py +++ b/tools/agent-isolation/tests/test_container_gateway_hook.py @@ -24,6 +24,8 @@ import subprocess from pathlib import Path +import pytest + SCRIPT = Path(__file__).parent.parent / "container-gateway-hook.sh" @@ -127,3 +129,67 @@ def test_bad_action_exits_zero_with_message(tmp_path: Path) -> None: 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/src/container_gateway/daemon.py b/tools/container-gateway/src/container_gateway/daemon.py index 052843e2..ba3f8e5a 100644 --- a/tools/container-gateway/src/container_gateway/daemon.py +++ b/tools/container-gateway/src/container_gateway/daemon.py @@ -403,16 +403,16 @@ async def probe_egress(host: str, port: int) -> bool: def build_context(cfg: Config, backend: _backends.Backend, proxy_env: dict[str, str] | None) -> PolicyContext: """The policy context for one backend's relay. - The scratch root comes from ``TMPDIR`` only when it is actually set -- - never a hardcoded ``/tmp`` fallback, which would let every project on - the machine bind-mount out of the same shared, world-writable - directory. An unset ``TMPDIR`` means the project root and any - ``--extra-bind-root`` entries are the only allowed bind-mount roots. + 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()] - tmpdir = os.environ.get("TMPDIR") - if tmpdir: - roots.append(Path(tmpdir).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 diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py index 76b5df08..46575744 100644 --- a/tools/container-gateway/tests/test_daemon.py +++ b/tools/container-gateway/tests/test_daemon.py @@ -978,3 +978,36 @@ def test_cli_serve_help_lists_flags() -> None: "--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 From cf2b3720cc7cab34da70569e8c6294f5b60b1017 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 21:46:32 +0200 Subject: [PATCH 38/45] docs(container-gateway): record the allow-list posture, the limits, and who writes the socket entries The spec, the tool contract and the how-to all described a deny-list policy and a `$TMPDIR` bind root, neither of which is what ships now. They also promised that `/magpie-setup config` writes the two gateway sockets into `.claude/settings.local.json`, which it does not: the operator adds the block by hand, or takes the settings diff `setup-isolated-setup-install` Step L proposes. Automating it is now a recorded gap rather than a documented feature. `tool.md` and `README.md` gain a *Limits and residual risks* section covering what the gateway does not cover: an unknown field is refused, so a new daemon feature is unavailable until the gateway learns it; a bind source is checked on the host at decision time and re-resolved by the daemon at mount time, so a symlink swapped in that window is not caught; images are shared across projects; `/info`, `/version` and `/_ping` return host-level daemon facts; container egress is a friction layer, not a wall; and backend discovery happens at start. Generated-by: Claude Opus 5 --- docs/setup/secure-agent-setup.md | 12 ++-- tools/container-gateway/README.md | 29 ++++++++- tools/container-gateway/tool.md | 34 +++++++++- tools/spec-loop/specs/container-gateway.md | 76 ++++++++++++++++++---- 4 files changed, 132 insertions(+), 19 deletions(-) diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index 5f0f3862..ef54f145 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -523,9 +523,9 @@ below, annotated. "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`, - // written by `/magpie-setup config`) add the container gateway's - // own sockets here as absolute paths, so a sandboxed podman / - // docker CLI can connect(2) to them: + // 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 @@ -2381,7 +2381,9 @@ The framework's own `.claude/settings.json` already carries the `env` half of th } ``` -`allowUnixSockets` entries need an absolute path, which is per-machine, so they belong in the gitignored `.claude/settings.local.json` instead (written by `/magpie-setup config`, or by hand): +`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) @@ -2421,7 +2423,7 @@ podman run --rm -v "$HOME/.ssh:/x" alpine true # expected: 403 container-gateway `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 or its scratch tree is exactly what the policy refuses, and the `403` message is the gateway working as intended. +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 diff --git a/tools/container-gateway/README.md b/tools/container-gateway/README.md index ae98da2a..466fb242 100644 --- a/tools/container-gateway/README.md +++ b/tools/container-gateway/README.md @@ -14,6 +14,7 @@ - [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) @@ -66,6 +67,11 @@ Persist these per-machine in `.claude/settings.local.json`'s `env` block, and al 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 | @@ -78,13 +84,18 @@ The create-time rules below apply to containers and pods (the same fields under | `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 the project scratch tree; anything else denied. `tmpfs` allowed | +| `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 | | `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 | +`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` naming a filesystem destination 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 @@ -115,3 +126,19 @@ The gateway keeps the agent off the daemon socket and off resources outside its 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. +- **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. diff --git a/tools/container-gateway/tool.md b/tools/container-gateway/tool.md index 319f79fb..64db9c93 100644 --- a/tools/container-gateway/tool.md +++ b/tools/container-gateway/tool.md @@ -11,6 +11,7 @@ - [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) @@ -35,7 +36,10 @@ 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. + 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 @@ -107,6 +111,34 @@ gateways together as the *socket gateways* row. 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. + ## Declared egress surfaces None. The gateway's only connections are local unix sockets: the two it diff --git a/tools/spec-loop/specs/container-gateway.md b/tools/spec-loop/specs/container-gateway.md index 2406504e..32fa6299 100644 --- a/tools/spec-loop/specs/container-gateway.md +++ b/tools/spec-loop/specs/container-gateway.md @@ -27,8 +27,12 @@ acceptance: - 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 the project scratch tree is refused with HTTP 403 and a + 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. @@ -112,11 +116,12 @@ 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, and -`/magpie-setup config` writes the two sockets' **absolute** paths into -the gitignored, per-project `.claude/settings.local.json` instead — -see [Container gateway](../../../docs/setup/secure-agent-setup.md#container-gateway) -in the setup guide. The macOS limit of 104 bytes on a socket path is +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 @@ -210,6 +215,41 @@ tree) and: 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): @@ -223,7 +263,7 @@ tree) and: | `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 the project scratch tree; anything else denied. `tmpfs` allowed | +| `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` | @@ -314,9 +354,9 @@ 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 — written into the gitignored -`.claude/settings.local.json` by `/magpie-setup config`, never into the -committed baseline: +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 @@ -388,8 +428,10 @@ PYTHONPATH=tools/skill-evals/src python3 -m skill_evals.runner \ - Unit tests: one table-driven test module per policy family (`test_policy_create.py`, `test_policy_labels.py`, - `test_policy_paths.py`, `test_policy_images.py`) over request dicts, - covering compat and libpod shapes for every row in the tables above. + `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 @@ -404,6 +446,16 @@ PYTHONPATH=tools/skill-evals/src python3 -m skill_evals.runner \ ## 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 From e39052f5ad8c34cc900243d55d119656e2c72d21 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 22:04:29 +0200 Subject: [PATCH 39/45] fix(container-gateway): close the volume-driver and case bypasses, stop refusing common invocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A volume mount carrying a driver configuration is refused whatever the driver: the stock `local` driver with `type=none,device=/,o=bind` is a host-root bind under another name, and an anonymous volume carrying it has no name for the relay to label-check. Covers compat `VolumeOptions.DriverConfig`, libpod's `volume-opt=` mount options, and the same options on libpod's top-level `volumes[]` list. The denied-create-field table is keyed casefolded and looked up only that way, so `LINKS` / `CONTAINERIDFILE` / `CGROUP` / `volumedriver` now reach their reasons instead of walking into the allow-list; Go's decoder binds all of them to the field the table names. The build `output` / `outputs` check parses the JSON and comma forms instead of grepping for `dest=`, and refuses a path-shaped bare value (podman puts the image name there on every `podman build -t`). Three over-denials go the other way: libpod `r_limits` (every `podman run --ulimit`) and the camelCase device-limit fields join the allow-list, `buildid` joins the build query, and a `LogConfig.Config` carrying rotation options is allowed on `json-file` / `local` / unset. The option map is itself an allow-list, so `path` — which podman's compat endpoint maps onto the `log_configuration.path` this policy refuses by name — stays denied. `POST /libpod/exec//remove` is routed as `exec_remove` and label-checked through its owning container, so `podman exec` no longer ends in a 403 and a leaked exec instance. Generated-by: Claude Opus 5 --- tools/container-gateway/README.md | 5 +- .../src/container_gateway/decisions.py | 69 ++++++++- .../src/container_gateway/policy.py | 94 +++++++++++- .../src/container_gateway/policy_shape.py | 35 +++-- .../src/container_gateway/routes.py | 14 +- tools/container-gateway/tests/fakebackend.py | 2 + .../tests/test_policy_build.py | 48 ++++++ .../tests/test_policy_create.py | 144 ++++++++++++++++++ .../tests/test_policy_labels.py | 10 ++ tools/container-gateway/tests/test_relay.py | 14 ++ tools/container-gateway/tests/test_routes.py | 9 ++ tools/spec-loop/specs/container-gateway.md | 2 +- 12 files changed, 423 insertions(+), 23 deletions(-) diff --git a/tools/container-gateway/README.md b/tools/container-gateway/README.md index 466fb242..0f32ddd3 100644 --- a/tools/container-gateway/README.md +++ b/tools/container-gateway/README.md @@ -85,11 +85,12 @@ The create-time rules below apply to containers and pods (the same fields under | `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 | +| `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` naming a filesystem destination are all refused, as is any parameter the gateway has not learned. +`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. diff --git a/tools/container-gateway/src/container_gateway/decisions.py b/tools/container-gateway/src/container_gateway/decisions.py index 5c69ab9c..de8c0666 100644 --- a/tools/container-gateway/src/container_gateway/decisions.py +++ b/tools/container-gateway/src/container_gateway/decisions.py @@ -155,6 +155,7 @@ class Allow: "allplatforms", "annotations", "buildargs", + "buildid", "buildkit", "cachefrom", "cacheto", @@ -258,6 +259,18 @@ class Allow: # host-joined namespace, and any namespace joined by path, is refused. _NSOPTION_HOST_ALLOWED = frozenset({"user"}) +# The only build outputs that stay inside the daemon. Everything else -- +# buildkit's `local` / `tar` / `oci` exporters, buildah's `-o ` -- is +# a write to a host path the daemon performs on the client's behalf. +_BUILD_OUTPUT_SAFE_TYPES = frozenset({"image", "registry"}) + +# 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.""" @@ -309,6 +322,55 @@ def _nsoptions_deny(raw: str) -> Deny | None: return None +def _build_output_deny(value: str) -> Deny | None: + """Refuse a build ``output`` / ``outputs`` that names anywhere but an image. + + 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 allowed only when they ask + exclusively for ``type=image`` / ``type=registry`` -- another exporter, + any attribute, a value that does not parse, all name or can name a + destination. 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 + keys = {str(k).strip().casefold() for k in entry} + if keys - {"type", "attrs"} or entry.get("Attrs") or entry.get("attrs"): + return refused + entry_type = str(entry.get("Type") or entry.get("type") or "").strip().casefold() + if entry_type not in _BUILD_OUTPUT_SAFE_TYPES: + return refused + return None + if "=" not in stripped: + if stripped == "-" or stripped.startswith(_BUILD_OUTPUT_PATH_PREFIXES): + return refused + return None + for directive in stripped.split(","): + item = directive.strip() + if not item: + continue + name, sep, attr_value = item.partition("=") + if not sep or name.strip().casefold() != "type": + return refused + if attr_value.strip().casefold() not in _BUILD_OUTPUT_SAFE_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(): @@ -333,11 +395,10 @@ def _build_query_deny(query: dict[str, list[str]]) -> Deny | None: return denial continue if casefolded in ("output", "outputs"): - # buildah's `--output` and buildkit's `--output` both write to - # a filesystem path when the value names a destination. for value in values: - if "dest=" in value.casefold(): - return Deny(f"denied-build-parameter: writing build output to {value} is refused") + 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") diff --git a/tools/container-gateway/src/container_gateway/policy.py b/tools/container-gateway/src/container_gateway/policy.py index e246f64b..798b039c 100644 --- a/tools/container-gateway/src/container_gateway/policy.py +++ b/tools/container-gateway/src/container_gateway/policy.py @@ -371,15 +371,46 @@ def _endpoint_maps(body: dict[str, Any]) -> list[dict[str, Any]]: # 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``: the empty one every ``docker run`` sends, nothing more. + """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. + 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): @@ -387,8 +418,16 @@ def _log_config_deny(host: dict[str, Any]) -> Deny | 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") - if log_config.get("Config"): + 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 @@ -429,6 +468,47 @@ def _security_opt_deny(host: dict[str, Any]) -> Deny | None: 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: @@ -441,6 +521,8 @@ def _mount_type_deny( 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 @@ -466,6 +548,12 @@ def _mounts_deny( 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 diff --git a/tools/container-gateway/src/container_gateway/policy_shape.py b/tools/container-gateway/src/container_gateway/policy_shape.py index 453f4a83..d56d6e99 100644 --- a/tools/container-gateway/src/container_gateway/policy_shape.py +++ b/tools/container-gateway/src/container_gateway/policy_shape.py @@ -566,6 +566,16 @@ def resource_create_spelling_violation(body: dict[str, Any], libpod: bool) -> De "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", @@ -588,6 +598,12 @@ def resource_create_spelling_violation(body: dict[str, Any], libpod: bool) -> De # 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", @@ -601,10 +617,10 @@ def resource_create_spelling_violation(body: dict[str, Any], libpod: bool) -> De "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", + "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( @@ -678,17 +694,18 @@ def allow_list_violation( 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``. 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. + 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) or denied_fields.get(key) + reason = denied_fields.get(casefolded) if reason is not None and value: return Deny(reason) if casefolded not in allowed: diff --git a/tools/container-gateway/src/container_gateway/routes.py b/tools/container-gateway/src/container_gateway/routes.py index 3979f658..90770759 100644 --- a/tools/container-gateway/src/container_gateway/routes.py +++ b/tools/container-gateway/src/container_gateway/routes.py @@ -147,9 +147,15 @@ def route(method: str, path: str) -> Route: 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"}.get( - exec_verb, "unknown" - ) + 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 = { @@ -266,7 +272,7 @@ def name_span(method: str, path: str) -> tuple[int, int] | None: "exists", ) } - | {(Family.EXEC, a) for a in ("exec_start", "exec_inspect", "exec_resize")} + | {(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")} diff --git a/tools/container-gateway/tests/fakebackend.py b/tools/container-gateway/tests/fakebackend.py index e8b8701b..1ec0c4bd 100644 --- a/tools/container-gateway/tests/fakebackend.py +++ b/tools/container-gateway/tests/fakebackend.py @@ -141,6 +141,8 @@ async def _respond( 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" diff --git a/tools/container-gateway/tests/test_policy_build.py b/tools/container-gateway/tests/test_policy_build.py index 7772331f..1dd186bf 100644 --- a/tools/container-gateway/tests/test_policy_build.py +++ b/tools/container-gateway/tests/test_policy_build.py @@ -195,3 +195,51 @@ def test_malformed_build_values_deny_instead_of_raising( ) -> 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"]']}, + ], +) +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"]}, + ], +) +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 index a767beec..7adfc006 100644 --- a/tools/container-gateway/tests/test_policy_create.py +++ b/tools/container-gateway/tests/test_policy_create.py @@ -771,3 +771,147 @@ def test_httpproxy_is_turned_off_when_the_gateway_injects_its_own(ctx: PolicyCon # 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_labels.py b/tools/container-gateway/tests/test_policy_labels.py index e1ff86fa..402c749f 100644 --- a/tools/container-gateway/tests/test_policy_labels.py +++ b/tools/container-gateway/tests/test_policy_labels.py @@ -317,3 +317,13 @@ def test_path_spelling_of_commit_still_label_checks_the_path(ctx: PolicyContext) 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 index 8d7dea58..a71cefbc 100644 --- a/tools/container-gateway/tests/test_relay.py +++ b/tools/container-gateway/tests/test_relay.py @@ -714,3 +714,17 @@ async def connect_and_go_silent() -> tuple[asyncio.StreamReader, asyncio.StreamW 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 index 28e644e8..f4dcce53 100644 --- a/tools/container-gateway/tests/test_routes.py +++ b/tools/container-gateway/tests/test_routes.py @@ -118,3 +118,12 @@ def test_name_span_matches_the_route_name(method: str, path: str, span: tuple[in 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/spec-loop/specs/container-gateway.md b/tools/spec-loop/specs/container-gateway.md index 32fa6299..6f749327 100644 --- a/tools/spec-loop/specs/container-gateway.md +++ b/tools/spec-loop/specs/container-gateway.md @@ -184,7 +184,7 @@ 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, attach, archive get / put, commit, export, +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 From 7be4ef4cca53d8226ee02b6c9f254a51865ab0bf Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 22:14:52 +0200 Subject: [PATCH 40/45] fix(container-gateway): judge a build output by its destination, not its attributes Generated-by: Claude Opus 5 --- tools/container-gateway/README.md | 1 + .../src/container_gateway/decisions.py | 64 ++++++++++++++----- .../tests/test_policy_build.py | 17 +++++ tools/container-gateway/tool.md | 4 ++ 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/tools/container-gateway/README.md b/tools/container-gateway/README.md index 0f32ddd3..2727d74a 100644 --- a/tools/container-gateway/README.md +++ b/tools/container-gateway/README.md @@ -143,3 +143,4 @@ Known and accepted, in the order you are likely to meet them: 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/src/container_gateway/decisions.py b/tools/container-gateway/src/container_gateway/decisions.py index de8c0666..8a8e4ec7 100644 --- a/tools/container-gateway/src/container_gateway/decisions.py +++ b/tools/container-gateway/src/container_gateway/decisions.py @@ -259,10 +259,17 @@ class Allow: # host-joined namespace, and any namespace joined by path, is refused. _NSOPTION_HOST_ALLOWED = frozenset({"user"}) -# The only build outputs that stay inside the daemon. Everything else -- -# buildkit's `local` / `tar` / `oci` exporters, buildah's `-o ` -- is -# a write to a host path the daemon performs on the client's behalf. -_BUILD_OUTPUT_SAFE_TYPES = frozenset({"image", "registry"}) +# 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 @@ -323,16 +330,21 @@ def _nsoptions_deny(raw: str) -> Deny | None: def _build_output_deny(value: str) -> Deny | None: - """Refuse a build ``output`` / ``outputs`` that names anywhere but an image. + """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 allowed only when they ask - exclusively for ``type=image`` / ``type=registry`` -- another exporter, - any attribute, a value that does not parse, all name or can name a - destination. A bare value is podman's image name on every ``podman - build -t x`` and is refused only when it is path-shaped (see + /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): @@ -348,26 +360,44 @@ def _build_output_deny(value: str) -> Deny | None: for entry in entries: if not isinstance(entry, dict): return refused - keys = {str(k).strip().casefold() for k in entry} - if keys - {"type", "attrs"} or entry.get("Attrs") or entry.get("attrs"): - return refused entry_type = str(entry.get("Type") or entry.get("type") or "").strip().casefold() - if entry_type not in _BUILD_OUTPUT_SAFE_TYPES: + 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("=") - if not sep or name.strip().casefold() != "type": - return refused - if attr_value.strip().casefold() not in _BUILD_OUTPUT_SAFE_TYPES: + 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 diff --git a/tools/container-gateway/tests/test_policy_build.py b/tools/container-gateway/tests/test_policy_build.py index 1dd186bf..4603e450 100644 --- a/tools/container-gateway/tests/test_policy_build.py +++ b/tools/container-gateway/tests/test_policy_build.py @@ -219,6 +219,15 @@ def test_malformed_build_values_deny_instead_of_raising( # 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: @@ -238,6 +247,14 @@ def test_denied_build_outputs(ctx: PolicyContext, query: dict[str, list[str]]) - {"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: diff --git a/tools/container-gateway/tool.md b/tools/container-gateway/tool.md index 64db9c93..c6b88a94 100644 --- a/tools/container-gateway/tool.md +++ b/tools/container-gateway/tool.md @@ -138,6 +138,10 @@ does not cover, and what the design accepts: - **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 From 2061ba55ae305a84b1e2cf76d2a5cfbcbfcb5592 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 22:30:16 +0200 Subject: [PATCH 41/45] chore(container-gateway): regenerate the derived tables after the rebase The token table and the vendor-neutrality score are generated; both now count the tools main added alongside the gateway. Generated-by: Claude Opus 5 --- docs/mode-economics.md | 10 +++++----- docs/vendor-neutrality.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/mode-economics.md b/docs/mode-economics.md index ba1e58b1..37af9070 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: `4c4a1128c69b71a2eb608b5ed97b3d55b3046c5586566fbc5802041a4937bb58`. +Measurement manifest SHA-256: `5ab15771b5022fff4f3fc54bba6ba3302719af5c333aa7651121cb1b9380d0db`. | Skill file | Measured tokens | Source SHA-256 (first 16 characters) | |---|---:|---| @@ -159,10 +159,10 @@ Measurement manifest SHA-256: `4c4a1128c69b71a2eb608b5ed97b3d55b3046c5586566fbc5 | [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) | 7,226 | `659aaa576e392f32` | -| [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 9,543 | `ab1ebf4531c2741c` | -| [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 4,778 | `90f5b1418c16ea0a` | -| [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 6,774 | `58e4c0785717bb05` | +| [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/vendor-neutrality.md b/docs/vendor-neutrality.md index 59ad6deb..5c8316d1 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -591,7 +591,7 @@ 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 | |---|---|---|---| From 33f4a3c624bba3321a92607d898b05f72b1338f6 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 23:03:41 +0200 Subject: [PATCH 42/45] fix(container-gateway): anchor the run-directory checks at what the gateway owns The run-directory guards refused a symlink anywhere in the ancestor chain, so on macOS `/tmp` -- a symlink to `/private/tmp` -- made any `--run-dir` beneath it unservable, and 23 tests failed with "the run directory's parent (/tmp) is a symlink; refusing". The host's own layout is not the threat model. The trust anchor is now the resolved `--project` value, or a custom `--run-dir`'s own resolved parent, and a symlink above that anchor is followed. Every component the gateway itself creates below the anchor is still walked one at a time with `lstat`: a symlink, a foreign owner or a group- or world-writable mode there is refused, and the `O_NOFOLLOW` opens on the pid file and the log are untouched. Also: a wedged test now fails instead of hanging. `faulthandler_timeout = 60` dumps every thread's stack, each scenario runs under a hard `asyncio.wait_for` bound, and every `subprocess.run` in the daemon tests carries a timeout. And the ten CodeQL alerts on this branch: the best-effort `write_eof` swallow gets the comment that says why, three tests bind their listener outside the `try` whose `finally` closes it, and the pid-lock and log-fd tests close what they open on every path. Generated-by: Claude Opus 5 --- tools/container-gateway/README.md | 3 + tools/container-gateway/pyproject.toml | 4 + .../src/container_gateway/daemon.py | 137 +++++--- .../src/container_gateway/http.py | 7 + tools/container-gateway/tests/test_daemon.py | 324 +++++++++++++----- tools/container-gateway/tests/test_relay.py | 19 +- 6 files changed, 363 insertions(+), 131 deletions(-) diff --git a/tools/container-gateway/README.md b/tools/container-gateway/README.md index 2727d74a..0a2717a4 100644 --- a/tools/container-gateway/README.md +++ b/tools/container-gateway/README.md @@ -134,6 +134,9 @@ 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.** diff --git a/tools/container-gateway/pyproject.toml b/tools/container-gateway/pyproject.toml index 6ef0b6b4..152e34c3 100644 --- a/tools/container-gateway/pyproject.toml +++ b/tools/container-gateway/pyproject.toml @@ -50,6 +50,10 @@ 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"] diff --git a/tools/container-gateway/src/container_gateway/daemon.py b/tools/container-gateway/src/container_gateway/daemon.py index ba3f8e5a..806e58bb 100644 --- a/tools/container-gateway/src/container_gateway/daemon.py +++ b/tools/container-gateway/src/container_gateway/daemon.py @@ -107,22 +107,6 @@ def _refuse_if_symlink(path: Path, label: str) -> None: _refuse(f"{label} ({path}) is a symlink; refusing") -def _refuse_if_parent_missing_or_symlink(path: Path, label: str) -> None: - """An ancestor this module never creates on its own: it must already exist. - - Used for a custom ``--run-dir``'s parent, which may sit anywhere - outside the project tree -- "create it for the operator" would be - presumptuous, and letting a missing parent surface as a bare - ``FileNotFoundError`` out of a later ``mkdir()`` is not an error - message worth shipping. - """ - st = _lstat_or_none(path) - if st is None: - _refuse(f"{label} ({path}) does not exist") - if 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(): @@ -174,20 +158,90 @@ def _ensure_owned_private_dir(path: Path, label: str) -> None: _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* is a - legitimate thing the operator pointed ``--project`` at, and it must - already exist (this function creates directories below it, never the - root itself). Everything strictly below it is walked top-down with + ``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 component before creating or - trusting the next one: first ``.apache-magpie-local``, then ``run``, - in the default layout. A custom ``--run-dir`` outside the project - tree must have an existing, non-symlinked parent, then gets the same - ownership/mode check on itself. + 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 @@ -200,15 +254,11 @@ def check_run_dir(run_dir: Path, project_root: Path) -> None: independent of whatever this function saw a moment earlier. """ resolved_root = _resolved_existing_project_root(project_root) - default_run_dir = resolved_root / ".apache-magpie-local" / "run" - if run_dir == default_run_dir: - _ensure_owned_private_dir( - resolved_root / ".apache-magpie-local", "the project's .apache-magpie-local directory" - ) - _ensure_owned_private_dir(run_dir, "the run directory") - else: - _refuse_if_parent_missing_or_symlink(run_dir.parent, "the run directory's parent") - _ensure_owned_private_dir(run_dir, "the run directory") + 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: @@ -226,19 +276,12 @@ def validate_run_dir(run_dir: Path, project_root: Path) -> bool: 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 - default_run_dir = resolved_root / ".apache-magpie-local" / "run" - if run_dir == default_run_dir: - if not _owned_private_dir_status( - resolved_root / ".apache-magpie-local", "the project's .apache-magpie-local directory" - ): - return False - else: - st = _lstat_or_none(run_dir.parent) - if st is None: - return False - if stat.S_ISLNK(st.st_mode): - _refuse(f"the run directory's parent ({run_dir.parent}) is a symlink; refusing") - return _owned_private_dir_status(run_dir, "the run directory") + 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: diff --git a/tools/container-gateway/src/container_gateway/http.py b/tools/container-gateway/src/container_gateway/http.py index c1e4adf4..cc1fa39d 100644 --- a/tools/container-gateway/src/container_gateway/http.py +++ b/tools/container-gateway/src/container_gateway/http.py @@ -330,6 +330,13 @@ def _try_write_eof(writer: asyncio.StreamWriter) -> None: 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 diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py index 46575744..28676252 100644 --- a/tools/container-gateway/tests/test_daemon.py +++ b/tools/container-gateway/tests/test_daemon.py @@ -33,6 +33,7 @@ import argparse import asyncio +import contextlib import json import os import shutil @@ -56,10 +57,37 @@ _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]) -> _T: - """Drive a coroutine to completion without pytest-asyncio.""" - return asyncio.run(coro) + +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. + """ + try: + return await asyncio.start_server(lambda r, w: None, host="127.0.0.1", port=0) + except PermissionError: + pytest.skip("sandbox denies TCP bind; runs in CI") def _ns(project: Path, run_dir: Path, **extra: Any) -> argparse.Namespace: @@ -69,6 +97,40 @@ def _ns(project: Path, run_dir: Path, **extra: Any) -> argparse.Namespace: 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. @@ -189,6 +251,92 @@ def test_check_run_dir_refuses_symlinked_custom_run_dir(tmp_path: Path) -> None: 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 @@ -266,9 +414,17 @@ def test_acquire_pid_lock_refuses_symlink(tmp_path: Path) -> None: target.write_text("") link = tmp_path / "container-gateway.pid" link.symlink_to(target) - with pytest.raises(SystemExit) as exc: - daemon.acquire_pid_lock(link) - assert exc.value.code == 2 + # `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: @@ -291,9 +447,16 @@ def test_open_log_fd_refuses_symlink(tmp_path: Path) -> None: target.write_text("") link = tmp_path / "container-gateway.log" link.symlink_to(target) - with pytest.raises(SystemExit) as exc: - cli._open_log_fd(link) - assert exc.value.code == 2 + # 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 @@ -303,6 +466,7 @@ def test_acquire_pid_lock_does_not_blank_a_live_daemons_pid_file(tmp_path: Path) 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) @@ -312,6 +476,8 @@ def test_acquire_pid_lock_does_not_blank_a_live_daemons_pid_file(tmp_path: Path) 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) @@ -475,29 +641,29 @@ def test_cli_stop_signals_when_process_is_the_console_script( ) -> None: """The installed ``container-gateway`` console script is also recognised, not just ``python -m``.""" pid_file = short_run_dir / "container-gateway.pid" - fd = daemon.acquire_pid_lock(pid_file) - assert fd is not None our_pid = os.getpid() calls: list[tuple[int, int]] = [] terminated = False - def fake_kill(pid: int, sig: int) -> None: - nonlocal terminated - calls.append((pid, sig)) - if sig == signal.SIGTERM: - terminated = True - os.close(fd) # 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 + 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( @@ -512,26 +678,26 @@ def test_cli_stop_signals_a_python_dash_m_invocation_with_extra_argv( ) -> 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" - fd = daemon.acquire_pid_lock(pid_file) - assert fd is not None our_pid = os.getpid() calls: list[tuple[int, int]] = [] terminated = False - def fake_kill(pid: int, sig: int) -> None: - nonlocal terminated - calls.append((pid, sig)) - if sig == signal.SIGTERM: - terminated = True - os.close(fd) # 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 + 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( @@ -675,29 +841,29 @@ 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" - fd = daemon.acquire_pid_lock(pid_file) - assert fd is not None our_pid = os.getpid() calls: list[tuple[int, int]] = [] terminated = False - def fake_kill(pid: int, sig: int) -> None: - nonlocal terminated - calls.append((pid, sig)) - if sig == signal.SIGTERM: - terminated = True - os.close(fd) # 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 + 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: @@ -757,7 +923,7 @@ def recording_close(fd: int) -> None: ) async def scenario() -> None: - rc = await daemon.run(cfg, discover_fn=lambda *a, **k: [], platform="Darwin") + rc = await asyncio.wait_for(daemon.run(cfg, discover_fn=lambda *a, **k: [], platform="Darwin"), 5) assert rc == 0 run(scenario()) @@ -806,7 +972,7 @@ async def fake_serve_unix(path: Path, handler: Any) -> _FakeServer: async def scenario() -> None: with pytest.raises(OSError, match="simulated bind failure"): - await daemon.run(cfg, discover_fn=lambda *a, **k: found, platform="Darwin") + await asyncio.wait_for(daemon.run(cfg, discover_fn=lambda *a, **k: found, platform="Darwin"), 5) run(scenario()) assert closed == [True] @@ -819,10 +985,7 @@ async def scenario() -> None: def test_probe_egress_true_when_something_listens() -> None: async def scenario() -> None: - try: - server = await asyncio.start_server(lambda r, w: None, host="127.0.0.1", port=0) - except PermissionError: - pytest.skip("sandbox denies TCP bind; runs in CI") + server = await _tcp_listener_or_skip() try: port = server.sockets[0].getsockname()[1] assert await daemon.probe_egress("host.containers.internal", port) is True @@ -835,10 +998,7 @@ async def scenario() -> None: def test_probe_egress_false_when_nothing_listens() -> None: async def scenario() -> None: - try: - server = await asyncio.start_server(lambda r, w: None, host="127.0.0.1", port=0) - except PermissionError: - pytest.skip("sandbox denies TCP bind; runs in CI") + server = await _tcp_listener_or_skip() port = server.sockets[0].getsockname()[1] server.close() await server.wait_closed() @@ -864,7 +1024,7 @@ async def scenario() -> None: "INFO", short_run_dir / "pid", ) - rc = await daemon.run(cfg, discover_fn=lambda *a, **k: [], platform="Darwin") + 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() @@ -875,7 +1035,7 @@ def test_run_serves_both_sockets_from_podman_only_and_idles_out(tmp_path: Path, async def scenario() -> None: backend = _RealSocketBackend(short_run_dir / "d.sock") try: - await backend.start() + await asyncio.wait_for(backend.start(), 5) except PermissionError: pytest.skip("sandbox denies unix bind; runs in CI") try: @@ -902,19 +1062,22 @@ async def scenario() -> None: raise exc try: for name in ("podman.sock", "docker.sock"): - r, w = await asyncio.open_unix_connection(str(short_run_dir / name)) + # Every await here is bounded: a gateway that accepts + # the connection and then never answers must fail the + # test, not wedge the run. + r, w = await asyncio.wait_for(asyncio.open_unix_connection(str(short_run_dir / name)), 5) w.write(b"GET /_ping HTTP/1.1\r\nHost: x\r\n\r\n") - await w.drain() + await asyncio.wait_for(w.drain(), 5) assert b"200 OK" in await asyncio.wait_for(r.read(), 5) w.close() except PermissionError: pytest.skip("sandbox denies unix bind; runs in CI") assert daemon.read_pid(cfg.pid_file) == os.getpid() - rc = await asyncio.wait_for(task, 10) # idle timeout fires + rc = await asyncio.wait_for(task, 5) # idle timeout fires after 1.5s assert rc == 0 assert not cfg.pid_file.exists() finally: - await backend.stop() + await asyncio.wait_for(backend.stop(), 5) run(scenario()) @@ -929,6 +1092,7 @@ def test_cli_status_when_not_running(tmp_path: Path) -> None: text=True, env={**os.environ, "PYTHONPATH": str(SRC)}, check=False, + timeout=SUBPROCESS_TIMEOUT, ) assert done.returncode == 3 out = json.loads(done.stdout) @@ -950,6 +1114,7 @@ def test_cli_stop_when_never_served_reports_no_run_directory(tmp_path: Path) -> text=True, env={**os.environ, "PYTHONPATH": str(SRC)}, check=False, + timeout=SUBPROCESS_TIMEOUT, ) assert done.returncode == 0 assert "nothing to stop" in done.stdout @@ -962,6 +1127,7 @@ def test_cli_serve_help_lists_flags() -> None: text=True, env={**os.environ, "PYTHONPATH": str(SRC)}, check=False, + timeout=SUBPROCESS_TIMEOUT, ) for flag in ( "--project", diff --git a/tools/container-gateway/tests/test_relay.py b/tools/container-gateway/tests/test_relay.py index a71cefbc..68001f28 100644 --- a/tools/container-gateway/tests/test_relay.py +++ b/tools/container-gateway/tests/test_relay.py @@ -30,7 +30,7 @@ import asyncio import contextlib import json -from collections.abc import Coroutine +from collections.abc import Awaitable, Callable, Coroutine from pathlib import Path from typing import Any, TypeVar @@ -51,6 +51,16 @@ def run(coro: Coroutine[Any, Any, _T]) -> _T: 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") + + def stack(root: Path) -> tuple[FakeBackend, Relay]: """A fake daemon holding one owned and one foreign resource of each kind.""" backend = FakeBackend() @@ -129,10 +139,9 @@ async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> writer.close() sock = tmp_path / "gw.sock" - try: - server = await serve_unix(sock, handler) - except PermissionError: - pytest.skip("sandbox denies unix bind; runs in CI") + # 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: From 9a2ab3b897135a5f5e1f4023eb3807c4d19556a3 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 23:11:42 +0200 Subject: [PATCH 43/45] fix(container-gateway): shut the daemon down promptly with connections still open Since Python 3.12.1 `asyncio.Server.wait_closed()` returns only once every accepted connection's handler has finished. The relay's handler sits reading the next request for as long as a keep-alive client keeps its connection open, so the shutdown in `daemon.run()` waited forever and the gateway never exited while any client was connected, neither on its idle timeout nor on SIGTERM. `container-gateway stop` then waited out its five seconds and the session hook left a daemon behind. The daemon now tracks the handler task of every accepted connection, and shutdown closes the listening sockets, cancels those handlers, and waits for each server with a bound. An in-flight request loses its connection at that moment, which is the right outcome for a gateway that must exit when the session ends. The daemon tests gain the same bound in the fake backend's teardown, close their own client connections before measuring the daemon's exit, and pin the behaviour with two new tests: the handler cancellation against a real `asyncio.Server`, and an idle exit with a client deliberately left connected. Generated-by: Claude Opus 5 --- .../src/container_gateway/daemon.py | 74 +++++- tools/container-gateway/tests/test_daemon.py | 220 ++++++++++++++---- 2 files changed, 249 insertions(+), 45 deletions(-) diff --git a/tools/container-gateway/src/container_gateway/daemon.py b/tools/container-gateway/src/container_gateway/daemon.py index 806e58bb..345c1ca2 100644 --- a/tools/container-gateway/src/container_gateway/daemon.py +++ b/tools/container-gateway/src/container_gateway/daemon.py @@ -44,7 +44,7 @@ from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import NoReturn +from typing import Any, NoReturn from . import backends as _backends from .labels import project_slug @@ -54,6 +54,11 @@ 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 @@ -477,6 +482,57 @@ async def handler(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None: 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, *, @@ -508,6 +564,7 @@ async def run( servers: list[asyncio.AbstractServer] = [] bound_sockets: list[Path] = [] activity = _Activity() + handlers = _Handlers() signal_handlers_installed: list[signal.Signals] = [] loop = asyncio.get_running_loop() try: @@ -547,7 +604,7 @@ async def run( # 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, activity.wrap(relay)) + 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) @@ -568,7 +625,18 @@ async def run( loop.remove_signal_handler(sig) for s in servers: s.close() - await s.wait_closed() + # 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. diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py index 28676252..5350cf33 100644 --- a/tools/container-gateway/tests/test_daemon.py +++ b/tools/container-gateway/tests/test_daemon.py @@ -41,7 +41,7 @@ import subprocess import sys import tempfile -from collections.abc import Coroutine, Iterator +from collections.abc import AsyncIterator, Coroutine, Iterator from pathlib import Path from typing import Any, TypeVar @@ -172,9 +172,102 @@ 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() - await self._server.wait_closed() + 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") + 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") + 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 @@ -1033,51 +1126,94 @@ async def scenario() -> None: def test_run_serves_both_sockets_from_podman_only_and_idles_out(tmp_path: Path, short_run_dir: Path) -> None: async def scenario() -> None: - backend = _RealSocketBackend(short_run_dir / "d.sock") + 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: - await asyncio.wait_for(backend.start(), 5) + server = await asyncio.start_server(handlers.track(blocked), host="127.0.0.1", port=0) except PermissionError: - pytest.skip("sandbox denies unix bind; runs in CI") + pytest.skip("sandbox denies TCP bind; runs in CI") try: - cfg = daemon.Config( - tmp_path, - short_run_dir, - ("podman", "docker"), - "off", - 8899, - None, - (), - 1.5, - "INFO", - short_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 + port = server.sockets[0].getsockname()[1] + _, w = await asyncio.wait_for(asyncio.open_connection("127.0.0.1", port), 5) try: - for name in ("podman.sock", "docker.sock"): - # Every await here is bounded: a gateway that accepts - # the connection and then never answers must fail the - # test, not wedge the run. - r, w = await asyncio.wait_for(asyncio.open_unix_connection(str(short_run_dir / name)), 5) - w.write(b"GET /_ping HTTP/1.1\r\nHost: x\r\n\r\n") - await asyncio.wait_for(w.drain(), 5) - assert b"200 OK" in await asyncio.wait_for(r.read(), 5) - w.close() - except PermissionError: - pytest.skip("sandbox denies unix bind; runs in CI") - assert daemon.read_pid(cfg.pid_file) == os.getpid() - rc = await asyncio.wait_for(task, 5) # idle timeout fires after 1.5s - assert rc == 0 - assert not cfg.pid_file.exists() + 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: - await asyncio.wait_for(backend.stop(), 5) + 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()) From e80ec2522276f872e859f447b98398a296281efd Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 20 Sep 2026 23:42:47 +0200 Subject: [PATCH 44/45] fix(container-gateway): stop the TCP listener teardown blocking on CI The loopback listener the probe_egress tests bind left its accepted connection open, so Server.wait_closed() never returned once the probe had connected -- on Python >= 3.12.1 it waits for every accepted connection, not just the listening socket. Close the accepted side in the connection callback and bound every listener teardown. Generated-by: Claude Opus 5 --- tools/container-gateway/tests/test_daemon.py | 31 ++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py index 5350cf33..185fba1b 100644 --- a/tools/container-gateway/tests/test_daemon.py +++ b/tools/container-gateway/tests/test_daemon.py @@ -83,13 +83,36 @@ async def _tcp_listener_or_skip() -> asyncio.AbstractServer: 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(lambda r, w: None, host="127.0.0.1", port=0) + 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") +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} @@ -1083,8 +1106,7 @@ async def scenario() -> None: port = server.sockets[0].getsockname()[1] assert await daemon.probe_egress("host.containers.internal", port) is True finally: - server.close() - await server.wait_closed() + await _close_server(server) run(scenario()) @@ -1093,8 +1115,7 @@ 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] - server.close() - await server.wait_closed() + await _close_server(server) assert await daemon.probe_egress("host.containers.internal", port) is False run(scenario()) From d99be928d6e8bdc1380039782828cbd9d90ba204 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Mon, 21 Sep 2026 00:03:16 +0200 Subject: [PATCH 45/45] fix(container-gateway): close the last five CodeQL alerts in the tests Every one is the same shape: pytest.skip() inside an except block. It raises, but a static analyser cannot know that, so it reads the code after the try as reachable with nothing assigned. An explicit bare re-raise terminates the branch. Generated-by: Claude Opus 5 --- tools/container-gateway/tests/test_daemon.py | 16 ++++++++++++++++ tools/container-gateway/tests/test_relay.py | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/tools/container-gateway/tests/test_daemon.py b/tools/container-gateway/tests/test_daemon.py index 185fba1b..ed53cbe9 100644 --- a/tools/container-gateway/tests/test_daemon.py +++ b/tools/container-gateway/tests/test_daemon.py @@ -99,6 +99,10 @@ def _close_immediately(reader: asyncio.StreamReader, writer: asyncio.StreamWrite 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: @@ -225,6 +229,10 @@ async def _gateway( 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, @@ -272,6 +280,10 @@ async def _ping(sock_path: Path) -> tuple[asyncio.StreamReader, asyncio.StreamWr 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 @@ -1190,6 +1202,10 @@ async def blocked(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> 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) diff --git a/tools/container-gateway/tests/test_relay.py b/tools/container-gateway/tests/test_relay.py index 68001f28..2c88c248 100644 --- a/tools/container-gateway/tests/test_relay.py +++ b/tools/container-gateway/tests/test_relay.py @@ -59,6 +59,10 @@ async def _serve_unix_or_skip( 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]: