feat(container-gateway): run podman and docker from inside the sandbox through a policy proxy - #1298
Merged
Merged
Conversation
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
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
Generated-by: Claude Opus 5
Generated-by: Claude Opus 5
Generated-by: Claude Opus 5
Generated-by: Claude Opus 5
…gs, mount types, allow-lists Generated-by: Claude Opus 5
…ape guards Generated-by: Claude Opus 5
…endpoint maps Generated-by: Claude Opus 5
…bel check, both-shape endpoint maps Generated-by: Claude Opus 5
Generated-by: Claude Opus 5
…urce create, malformed queries, path normalisation, one-way imports Generated-by: Claude Opus 5
Generated-by: Claude Opus 5
Generated-by: Claude Opus 5
…, validate chunks, drain pipe Generated-by: Claude Opus 5
…ecks start line Generated-by: Claude Opus 5
…jack Generated-by: Claude Opus 5
…ails closed; 100-continue and hop-by-hop headers Generated-by: Claude Opus 5
…nterim responses; refuse unrewritable targets Generated-by: Claude Opus 5
Generated-by: Claude Opus 5
…ree — pid validation, symlink refusal, flock single-instance Generated-by: Claude Opus 5
…e pid file intact; verify the stop target Generated-by: Claude Opus 5
…er gateway Generated-by: Claude Opus 5
… pinned sources Generated-by: Claude Opus 5
…e container-gateway hook Generated-by: Claude Opus 5
Generated-by: Claude Opus 5
…teway 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
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
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
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
… 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
…eview 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
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
… 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-<user>/pytest-<n>/<test>/ 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 `<rt> 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
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
… 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/<id>/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=<id>` 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
…s 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
…nd 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
…op refusing common invocations 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/<id>/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
…its attributes Generated-by: Claude Opus 5
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
Member
Author
|
The last security layer added - container gateway. It allows to safely (and without prompts) run containers inside your agentic CLI. It will keep containers separated "per-project" so that they are not reachable from inside the sandbox. |
…ateway 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
…s 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
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
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
21 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
A container gateway: a per-project policy proxy in front of the podman / docker daemon socket, so shell commands run by a sandboxed agent can drive containers without the daemon being reachable.
Today an adopter has two options, and both hand over the daemon. Excluding
dockerfrom the sandbox (upstream's advice) or allowing the daemon socket insandbox.network.allowUnixSocketsboth grant what upstream's own documentation calls "access to the host system through the Docker socket". On macOS the default Podman machine mounts/Usersread-write, so that is a bind mount away from~/.ssh.The gateway runs outside the sandbox, listens on two unix sockets inside the project tree, and forwards the Docker-compatible API after enforcing policy. It gives three properties:
rootfs, overlay volumes, volume-driver configurations, bind mounts outside the project) are denied by name. The build query, exec bodies and act-by-name queries are policed the same way.Both backends are optional; the gateway serves the docker CLI from a podman backend when Docker is absent, and exits quietly when neither exists.
What is in the branch
Tool
tools/container-gateway/(stdlib only): routing, policy, the HTTP relay with streaming and hijack support, backend discovery, the daemon and its CLI. ASessionStart/SessionEndhook intools/agent-isolation/. Reference settings plus a sandbox-lint invariant that rejects a daemon socket in the allow-list. Documentation in the setup guide and the troubleshooting catalog, a row in RFC-AI-0004 and a cross-reference in RFC-AI-0003. Doctor probe, verify check and install step, with eval fixtures. 581 unit tests and an integration suite that runs against a real daemon.How it was built and reviewed
Spec first, then fifteen planned tasks, each implemented and reviewed independently; the security-bearing parts took several fix rounds each. A whole-branch review then found four bypasses that per-component review could not see, all of the same shape: the policy enumerated what was forbidden rather than what was allowed. That is why the create body is now an allow-list.
An integration run against a live daemon found a fifth defect that no test against fakes had caught, and the fix round found a sixth.
Limits
tool.mdand the README carry a Limits and residual risks section. The ones worth repeating: bind sources are 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 by design; container egress that bypasses proxy variables is not intercepted; backend discovery happens at gateway start.Follow-ups
Automating the per-project socket entries in
/magpie-setup config(documented as manual for now), and exercising the libpod volume-label path against podman.Generative AI disclosure
Written with Claude Code (Opus 5) under human direction: spec, plan, per-task implementation and review, and the final review passes. Every commit was reviewed by a separate agent and by the author before landing.
🤖 Generated with Claude Code