Podman

Running sockguard in front of a Podman socket — the Docker-compat and native libpod API surfaces, which request_body.* keys govern which endpoint, the uninspected play/kube blind-write surface, ownership/visibility coverage, and current limitations.

Podman's podman system service exposes a single socket that speaks two API families at once: a Docker-compatible surface at /vN.NN/... and its own native surface at /libpod/.... Sockguard proxies both from one instance — point upstream.socket at the Podman socket instead of Docker's, and every existing rule, preset, and inspector that targets the Docker-compat paths keeps working unchanged. The /libpod/... surface gets its own, structurally separate set of inspectors described below, except native build, which shares the existing request_body.build policy because both endpoints carry the same build-context and query controls.

upstream:
  socket: /run/podman/podman.sock

Two API Surfaces, One Proxy

SurfacePath shapeInspectorsNotes
Docker-compat/vN.NN/containers/create, /vN.NN/images/json, ...The existing Docker inspectors (request_body.container_create, request_body.exec, request_body.volume, etc.) apply unchangedPodman implements enough of the Engine API that most Docker clients — Compose, Portainer, drydock, Portwing — work against it without modification
libpod-native/vN.NN/libpod/containers/create, /vN.NN/libpod/pods/json, ...A parallel set of libpod_*-prefixed inspectors, plus request_body.exec shared with the Docker-compat exec pathsPodman's own CLI (podman create, podman pod create, podman-py) and any client written against libpod's own OpenAPI spec use this surface

Routing between the two is structural, not a body-shape guess: every libpod matcher requires an exact /libpod/ path prefix, and Docker-compat matchers are untouched by the libpod additions — zero diffs to the existing Docker conformance tests was an explicit acceptance gate for this work. That matters because the two surfaces use incompatible top-level body shapes for the same conceptual operation. POST /containers/create's HostConfig.Privileged/NetworkMode/PidMode nested object has no relation to POST /libpod/containers/create's top-level privileged/netns/pidns fields (Podman's SpecGenerator shape). A Docker-shaped decoder reading a libpod body sees all-zero values everywhere and would read as "safe" — exactly the fail-open failure mode sockguard avoids by keeping routing path-exclusive and never falling back from one inspector to the other.

A client can use either surface interchangeably against the same Podman daemon — for example, podman create over libpod and a Compose client over Docker-compat, at the same time, through the same sockguard instance — and each request is gated by the inspector for the surface it actually arrived on.

Which request_body.* Key Governs Which Endpoint

libpod endpointConfig keyNotes
POST /libpod/containers/createrequest_body.libpod_container_createPodman's native SpecGenerator create body. Field names mirror container_create where the semantics map across (privileged, host netns/pidns/ipcns/userns, bind-mount/device allowlists, capabilities, seccomp/AppArmor, image_trust), plus two libpod-only gates: allow_systemd_mode and allow_custom_id_mappings.
POST /libpod/pods/createrequest_body.libpod_pod_createPods have no Docker-compat equivalent. Covers pod-level host network (allow_host_network), shared PID namespace (allow_shared_pid_namespace), and the infra image's registry (allowed_infra_image_registries).
POST /libpod/buildrequest_body.build, shared with Docker POST /buildCovers primary remote contexts, URL/image additionalbuildcontexts, networkmode=host, and Dockerfiles containing RUN, read from Containerfile and Dockerfile or from every file a JSON-array dockerfile names. Host/local/multipart and resource-usage host-file controls require the global blind-write acknowledgment. Direct and version-prefixed requests use the same tar/gzip bounds and body-sensitive startup validation as Docker's classic builder.
POST /libpod/containers/{name}/exec, POST /libpod/exec/{id}/startrequest_body.exec — shared with Docker-compatlibpod exec bodies use Docker's own field names on the wire, so there is deliberately no separate libpod_exec block. One allowed_commands/allow_privileged/allow_root_user/env-var policy governs exec on both surfaces, so nothing can be configured for one path family and forgotten for the other.
PUT /libpod/containers/{name}/archiverequest_body.container_archive — shared with Docker-compatPodman routes this and Docker's PUT /containers/{name}/archive to a single compat.Archive handler, so one matcher covers both spellings. The inspector requires one nonempty, unambiguous path, matches Podman's case-insensitive field name, refuses relative targets when allowed_paths is configured because Podman resolves them against the container WorkingDir, and refuses a nonempty or ambiguous rename because Podman applies that transformation after reading the tar headers. allowed_paths, allow_setid, allow_device_nodes and allow_escaping_links otherwise apply identically on either path.
POST /libpod/containers/{name}/updaterequest_body.container_update — shared with Docker-compatThe same allow_* gates as the Docker-compat update, read against libpod's own shape rather than Docker's. The body is UpdateEntities, whose embedded OCI specs.LinuxResources flattens to root-level memory/cpu/pids/blockIO/devices/unified instead of Docker's PascalCase, there is no HostConfig/Resources nesting, and the restart policy arrives in the restartPolicy/restartRetries query string rather than the body — so allow_restart_policy is enforced against the query here, with the key folded and every repeated value checked, because Podman decodes it with gorilla/schema. devices rides allow_all_devices; the OCI resource keys, the per-device IO throttles (DeviceReadBPs and friends) and r_limits ride allow_resource_updates; health_on_failure, no_healthcheck and health_startup_retries ride allow_restart_policy, since all three configure what the daemon does to the container on its own initiative. allow_privileged and allow_capabilities are inert on this path: UpdateEntities has no field for either, so a container cannot be made privileged through it. health_cmd and health_startup_cmd, Env/UnsetEnv, and health_log_destination are refused outright. Health timing and log-retention fields can create high-frequency checks or infinite daemon logs, so they require the global insecure_allow_body_blind_writes: true acknowledgment; the outright denials stay in force even with that acknowledgment. The opt-in require_memory_limit, require_cpu_limit, require_cpu_limit_hard, and require_pids_limit ratchets also cover this native path after ownership, applying Podman's partial lowercase resource overlay and explicit-zero clears to the inspected current state. An UpdateEntities root key sockguard does not recognize is allowed, not denied. Podman discards a body field its own build does not define, so deny-on-unknown would refuse working requests on the first Podman minor that adds a field — including fields Podman itself ignores — while buying nothing, since a key this build cannot name is a key it cannot gate either. The trade is that somebody has to notice when the upstream body grows, so the field set the gates were reviewed against is pinned in app/testdata/podman-api/update-entities-root-fields.json: a test fails when that snapshot and the gate lists disagree in either direction, a monthly CI job re-derives the snapshot from Podman's own source and fails when upstream has grown a field, and at runtime an unrecognized root key is named in a debug-level log line.
DELETE /libpod/containers/{name}request_body.container_remove, shared with Docker-compatPodman serves this and Docker's DELETE /containers/{name} from one compat.RemoveContainer handler, which reads a different set of flags on each. allow_force gates force on both. On this path the handler takes the volumes flag from volumes, not v, so allow_remove_volumes gates volumes, and also v, which the route's API docs list and Podman 5.8.6 ignores. depend is behind allow_remove_volumes too: it removes every container that depends on the one named, and when that one is a pod's infra container or a kube service container it removes the pod, whose containers' anonymous volumes Podman deletes whatever volumes says. depend never stops a running workload container, since the dependents are removed with the request's own force. A pod's infra container can be stopped, but only once every workload container in the pod is already stopped. With allow_remove_volumes off, that means podman --remote rm --all (which always sends depend) and the cleanup podman-remote sends after run --rm (volumes=true) are refused. For run --rm, Podman's own exit cleanup still removes the container and its volumes server-side, so the visible effect is an error line from the client. Turn on allow_remove_volumes for clients that use them. timeout and ignore aren't gated, and link isn't read here. Each flag is read once under its exact lowercase spelling, the same as on the compat path.
DELETE /libpod/pods/{name}request_body.container_removeRemoving a pod removes every container in it, and once they're gone Podman deletes the anonymous volumes of all of them. No parameter keeps the volumes, so every pod removal needs allow_remove_volumes, the same step depend needs on the container route. Without force, Podman refuses the removal if a workload container is running or paused, though the stopped ones are still removed. force stops the running ones, so it needs allow_force as well. With allow_remove_volumes off, that means podman --remote pod rm is refused. Turn on allow_remove_volumes for clients that remove pods, and allow_force for ones that send pod rm --force. timeout isn't gated. force is read once under its exact lowercase spelling, and podman-remote sends force=false on every removal. Owner isolation checks the pod the path names.
DELETE /libpod/play/kube, DELETE /libpod/kube/playrequest_body.container_removeKube down, which Podman serves from one handler under both spellings. It stops every pod the YAML in the request body names and force-removes them whatever the query says, so it stops their running containers and deletes their anonymous volumes. Every request needs both allow_force and allow_remove_volumes. Its force parameter also deletes the named volumes the YAML lists (PersistentVolumeClaims, and the volumes behind ConfigMap and Secret mounts), which is covered once allow_remove_volumes is open. It deletes the secrets the YAML names whatever force says, and no remove gate covers secrets. Sockguard doesn't parse the YAML, so opening both gates lets the client delete any pod or secret it can name, and with force any named volume that isn't in use. For the same reason owner isolation refuses kube down outright; see Ownership and Visibility Coverage below.
POST /libpod/volumes/createrequest_body.libpod_volumeReuses the volume config shape, decoded against libpod's wire format (driver options live under Options, not DriverOpts).
POST /libpod/networks/createrequest_body.libpod_networkReuses the network config shape, decoded against libpod's wire format (snake_case fields, no swarm/ingress/attachable/config-only concepts — libpod predates and is independent of Docker's swarm mode).
POST /libpod/networks/{name}/connectrequest_body.libpod_network — same allow_endpoint_config/endpoint_config gates as networkPodman's libpod.Connect handler decodes entities.NetworkConnectOptions, which is container plus an embedded, untagged PerNetworkOptions — so the endpoint fields are top-level and snake_case (static_ips, static_mac, aliases, options) and Docker's {"EndpointConfig":{...}} never appears. Sockguard reads that shape and runs it through the same gate as POST /networks/{id}/connect: static_ips is governed by allow_static_addressing, static_mac by allow_mac_pinning, aliases by allow_aliases, and options (the libpod analog of DriverOpts) is fail-closed under anything but allow_endpoint_config: true. static_mac is decoded exactly like Podman's HardwareAddr.UnmarshalJSON: a conventional MAC string, a base64 string, and a JSON byte array such as [82,84,0,28,46,70] all set the same policy field, while malformed or out-of-range forms fail closed. interface_name is not gated — it names an interface inside the container's own netns and has no Docker analog. allow_link_local_ips and allow_gw_priority have no libpod analog and never fire here.
POST /libpod/networks/{name}/disconnectrequest_body.libpod_network.allow_disconnect_forceThe one libpod network route Podman registers straight onto the Docker-compat handler (compat.Disconnect), so the body really is Docker's {"Container","Force"} and the same gate applies unchanged.
POST /libpod/networks/{name}/updaterequest_body.libpod_network.allow_dns_serversNetavark-only, with no Docker analog at any Engine API version: it rewrites an existing network's DNS resolvers, so every container already attached starts resolving names through whatever the caller supplied. Default deny. Both directions are gated — adding a resolver redirects, removing one can redirect by omission — and a body that changes no resolver passes. The same knob gates network_dns_servers on POST /libpod/networks/create, so it governs the whole per-network resolver surface rather than half of it. Note the two spellings are different on the wire: update decodes entities.NetworkUpdateOptions (adddnsservers/removednsservers, run together), create decodes types.Network (network_dns_servers, snake_case). The snake_case add_dns_servers belongs to an internal type Podman never parses from a request.
POST /libpod/images/pullrequest_body.image_pull — shared with Docker-compatPodman's native image pull, the counterpart of Docker's POST /images/create. One registry allowlist (allowed_registries/allow_official/allow_all_registries) governs both surfaces, so nothing can be configured for one API family and forgotten for the other. The reference arrives in reference (not Docker's fromImage/tag), Podman reads the key in any case and keeps the last value, so Sockguard refuses a request that repeats reference or spells it in another case. A docker:// transport prefix is accepted and stripped before matching the host. A pull carrying no usable reference is denied unless allow_all_registries: true. allow_imports does not apply here; libpod imports are a separate route (POST /libpod/images/import).
POST /libpod/images/loadrequest_body.image_load — shared with Docker-compatPodman's native image load. Verified against Podman v5.8.1: the route takes no query parameters and the whole request body is the archive. Docker manifest.json RepoTags and byte-exact OCI index.json effective names are checked against the same registry allowlist; io.containerd.image.name takes precedence over org.opencontainers.image.ref.name, matching Podman. Bare names are treated as localhost/..., and SHA-256, SHA-384, and SHA-512 OCI graphs are inspected. Sockguard mirrors Podman's permissive OCI metadata reader: advisory oci-layout, top-level schema-version, and descriptor media-type omissions do not disguise a loadable OCI image. If both OCI and Docker controls are present, both reference sets must be inspectable and pass policy because Podman can reject malformed config or layer content after metadata inspection and then fall back from OCI to Docker. An index.json that names no single image is the exception: Podman's oci-archive transport gives up before it reads any name and falls back to the Docker archive. A multi-manifest index is still checked for the names its annotations carry, because a containerd-store dockerd imports each descriptor and records them on the Docker-compat route; a missing or undecodable index carries none, so those loads are judged on manifest.json alone. Canonically duplicate controls, malformed mixed-format controls, and outer archive symlink or hardlink entries fail closed. allow_untagged applies only to genuinely untagged entries. Not to be confused with POST /libpod/local/images/load, below.
POST /libpod/images/importrequest_body.image_pull.allow_imports — shared with Docker-compatThe libpod counterpart of the import that rides on Docker's POST /images/create?fromSrc=, gated by the same allow_imports flag, which is false by default. Every request to the path is an import: with an effective URL query parameter the daemon fetches the tarball from an address the caller chose, and without one it reads the tarball from the request body. Body-form imports are spooled through a 512 MiB cap and, under enforce, return 413 Payload Too Large before Podman sees an oversized stream. Under warn or audit the oversized stream is logged and forwarded whole, so Podman sees it. URL imports retain the coarse flag and do not read or spool a request body.
POST /libpod/secrets/createrequest_body.libpod_secretReuses the secret config shape. libpod reads driver and driveropts from the query string, not a JSON body, so this inspector never reads r.Body — only the query-param path is exercised. driver=file, which podman-remote sends on every create because it's the containers.conf default, passes like an empty driver. Any driveropts needs allow_custom_drivers, because Podman hands them to the default driver too, and the file driver's path option is the directory on the daemon host the secret data is written to.

See the Configuration Reference for the complete field-by-field mapping and environment-variable names for every key above.

The Uninspected Blind-Write Surface: play/kube and Manifests

POST /libpod/play/kube (and its identically-handled /libpod/kube/play alias), POST /libpod/kube/apply, and POST/PUT /libpod/manifests/* have no request-body inspector in this release — full Kubernetes-YAML/PodSpec modeling is out of scope for this release. Admitting any of them requires the same insecure_allow_body_blind_writes: true acknowledgment that covers uninspected exec, exactly like every other body-bearing write sockguard cannot yet constrain.

POST /libpod/build has partial, fail-closed coverage instead of being a wholly blind endpoint. An allow rule is checked for primary remote contexts, every current or legacy additionalbuildcontexts value, host networking, and Dockerfile RUN instructions before Podman sees it. URL/image additional contexts use request_body.build.allow_remote_context. Host volume controls (volume, volumes, and transientRunMounts), localpath: additional contexts, multipart local contexts, and rusage output to the daemon-host path named by rusagelogfile require insecure_allow_body_blind_writes: true because no narrower policy models those host-facing inputs. Malformed, empty, unsupported, and duplicate additional-context definitions stay denied even when all acknowledgments are enabled.

The RUN check reads the files Podman builds, not only Dockerfile. With no dockerfile parameter, Podman's libpod route builds Containerfile when the context has one and Dockerfile otherwise, so sockguard inspects both and refuses the build if either carries RUN. A context whose unused Dockerfile has a RUN is therefore refused too. The compat POST /build route only ever builds Dockerfile on Podman. podman-remote sends its -f files as a JSON array in dockerfile, and every file in it is inspected. While RUN is restricted, a dockerfile value Podman would read from outside the uploaded context is refused: an http:// or https:// URL, an absolute path (Podman reads it from the daemon host when the host has that file), a path that climbs out with ../, and a name ending in .in, which Podman runs through the C preprocessor before parsing it. podman-remote sends an absolute path in three cases, and each is refused while RUN is restricted: a Containerfile outside the build context, -f - (it writes stdin to a temporary file outside the context), and a relative -f run from a directory reached through a symlink, such as a project under /tmp or /var/folders on macOS. Keep the Containerfile inside the context, write a stdin Containerfile into the context first, and either drop -f or cd "$(pwd -P)" before building.

Podman serves the Docker-compatible POST /build and native POST /libpod/build from one handler, so the compat path honors every one of those controls too. All of them are therefore checked on both paths. Docker's own POST /build reads none of these query parameters, so a Docker client is never affected; the check only matters on a Podman upstream, where sending volume, additionalbuildcontexts, or rusagelogfile to /build used to reach the daemon unexamined.

Treat play/kube with more caution than a typical blind write. A single request can carry a multi-container, multi-pod Kubernetes manifest — one allowed call can provision an arbitrary number of privileged containers from a document sockguard never parses. That is a materially larger blast radius than the single-container blind writes the acknowledgment flag otherwise covers, and no shipped preset (including podman-readonly.yaml below) ever allows these paths.

The Daemon-Host Surface: the local API and image SCP

Three libpod routes have no Docker analog and, unlike every other write sockguard inspects, take their input from somewhere the request never travels. There is nothing on the wire to read, so each is refused rather than inspected.

POST /libpod/local/build and POST /libpod/local/images/load are Podman's "local API": the build context (localcontextdir) and the image archive (path) are absolute paths on the daemon host, both required, and Podman v5.8.1 validates them only as absolute-and-exists — there is no sandbox root. A client that can call either one reads the daemon's filesystem through a directory it names itself. request_body.build's RUN-instruction scan and request_body.image_load's RepoTags check both work on the request body, so on these two routes they have nothing to read and would pass a request through as though it had been inspected. Both are therefore denied unless insecure_allow_body_blind_writes: true, at the proxy and again at config-validation time, and allow_run_instructions does not substitute for it. POST /libpod/build is unaffected: it still ships its context as a tar and is still inspected.

POST /libpod/images/scp/{name} copies an image between hosts over SSH. The source is the path segment, the destination is the destination query parameter, and there is no request body at all. It runs in both directions and each one crosses a boundary sockguard otherwise holds: outbound it moves a local image to a host the caller names, which is an image push without a registry to allowlist, and inbound it materializes a local image from a remote one, which request_body.image_pull's registry allowlist never sees. An unrecognized connection name is not refused — Podman turns it into a literal ssh://<name> — so the destination really is arbitrary. It is listed in both acknowledgment catalogs, so opening it needs insecure_allow_body_blind_writes: true and insecure_allow_read_exfiltration: true, one per direction. There is no narrower policy for it; see Known Limitations.

POST /libpod/containers/{name}/restore belongs in the blind-write category too. With ?import=1 Podman reads the whole request body as a CRIU checkpoint archive and creates a container from it, so one allowed call provisions a container whose spec — privileged, mounts, capabilities, devices — travels inside a gzipped tar as spec.dump and never passes through containers/create on either surface. The ?pod parameter also joins the restored container to a pod. Nothing in Sockguard reads that archive, so an allow rule for the path requires insecure_allow_body_blind_writes: true, exactly like play/kube.

Read-Exfiltration Additions

insecure_allow_read_exfiltration is extended with libpod's equivalent of every Docker-compat exfiltration surface, plus the libpod-only entries below:

  • GET /libpod/containers/*/archive, GET /libpod/containers/*/export, GET /libpod/containers/*/logs, GET /libpod/containers/*/top, POST /libpod/containers/*/attach
  • GET /libpod/pods/*/top
  • GET /libpod/images/export, GET /libpod/images/*/get, POST /libpod/images/*/push
  • POST /libpod/manifests/*/registry/* and the deprecated backward-compat POST /libpod/manifests/*/push — writes at the API layer, but they push local manifest content to a caller-selected registry, the same exfiltration shape as an image push
  • POST /libpod/images/scp/* — transfers a local image to another host over SSH, to a destination the caller picks. It is also in the blind-write catalog because the same call can pull an image in from a remote host; see the section above
  • GET /libpod/generate/kube — despite the "generate" name, Podman registers this as a GET that dumps an existing pod's or container's definition to YAML, which can include environment variables and other resource data. It is a read/export surface, not a write, so it lives in this catalog rather than the blind-write one above.
  • POST /libpod/containers/*/checkpoint — a POST, but with ?export=1 the response body is a tar.gz of the container's CRIU checkpoint: the process memory dump plus root-filesystem changes, so every secret the container held in memory. Its handler reads only the query string and never a request body, so there is nothing for a request-body inspector to check and it is gated here instead.
  • POST /libpod/containers/*/mount — mounts the container's root filesystem on the daemon host and returns that path. It reads neither a query nor a body. The response does not carry file contents, so this is not an equivalent of GET .../archive; it is gated on the disclosure of the storage driver's layout and the container-id-to-path mapping.
  • GET /libpod/containers/showmounted — returns that mapping for every mounted container in one response, keyed by container ID. Two behaviours, and which one you get depends entirely on what else is configured. With owner isolation or a visibility policy active, the proxy answers 403 before Podman is queried, because the response carries neither labels nor names to scope safely. With neither policy active, an allow rule reaching this path forwards Podman's map unredacted: every mounted container's ID paired with its host mountpoint. The path is in the read-exfiltration catalog, so that rule fails startup validation unless you also set insecure_allow_read_exfiltration: true; the acknowledgment is what makes the unredacted forward an explicit choice rather than an accident.

A broad GET /libpod/**-shaped allow rule catches every path above and fails startup validation unless you explicitly opt in. Narrow, explicit allowlists that omit every path above avoid needing the acknowledgment. The podman-readonly.yaml preset below intentionally retains container top for process monitoring, so it carries the acknowledgment while leaving native pod top and every other exfiltration-gated route denied.

Ownership and Visibility Coverage

Owner-label isolation and read-side visibility cover the libpod surface the same way they cover Docker-compat:

  • POST /libpod/containers/create, POST /libpod/pods/create, POST /libpod/networks/create, and POST /libpod/volumes/create get the configured owner label injected into their (lowercase-keyed) labels field. POST /libpod/volumes/create is the one exception — Podman's VolumeCreateOptions carries no JSON tag on its labels field, so it serializes as capitalized Labels and is stamped accordingly.
  • Podman reads query keys in any letter case and keeps the last value of a repeated key, where dockerd reads the exact key and keeps the first. A policy parameter sent twice or in another case (?Driver=custom on secret create, ?Force=1 or ?force=0&force=1 on container remove) would be checked as one value and executed as another, so Sockguard refuses it with a 403. A parameter whose value is always validated (rusage, additionalbuildcontexts) is refused in another spelling whatever the gates are, and the others only while the check that reads them is enabled. This holds on every upstream flavor and covers the secret driver, container remove force, v and link (force, volumes, v and depend on the libpod route), the build controls, image create fromImage and fromSrc, libpod pull reference, archive path and rename, commit container, and, under owner isolation, secret create replace, ignore and name.
  • POST /libpod/secrets/create has no JSON body at all (driver/labels are query parameters), so it reuses the existing query-param stamping path. Under owner isolation, a create with replace or ignore set is also checked against the secret it names. replace deletes an existing secret and stores the request's data under its name, and ignore hands back the existing secret's ID, so the named secret has to be absent or the caller's own. Another owner's secret, or an unlabeled one, is a 403. See Security for the details.
  • Pods have no Docker-compat equivalent, so they get a dedicated KindLibpodPod resource kind with its own list/inspect owner-filtering and visibility coverage.
  • Every filter-capable libpod list and every path-targeted read for containers, images, pods, networks, volumes, and secrets is owner-filtered and visibility-filtered, so switching a client from Docker-compat to a native spelling is not a way around the same scope. Path-targeted write actions are owner-checked too, and so is the prune family: POST /libpod/containers/prune, /images/prune, /networks/prune and /volumes/prune each document a filters parameter that takes label and read it through util.PrepareFilters, so the owner selector is injected exactly as it is on their Docker-compat twins and a prune removes only the caller's resources. POST /libpod/pods/prune is the one prune route that accepts no filters at all, and it is refused instead (below). Native image lists and per-image reads apply the policy's name and image patterns as well as its label selectors. Query-targeted batch image operations are the exception recorded under Known Limitations. Read coverage follows the routes Podman actually serves rather than the /json inspect alone: /exists, /top, /healthcheck, /archive, /tree, /changes, volume /export, and the bare GET /libpod/networks/{id} spelling Podman registers on the same InspectNetwork handler as /libpod/networks/{id}/json. Native image SCP follows Podman's source grammar. A bare POST /libpod/images/scp/{image} source and the special user@localhost::{image} form name a local image, so owner isolation inspects the exact image portion before forwarding. An owned image passes; an unowned image follows allow_unowned_images; a foreign image is denied with a 403; and a local inspect that returns not found is denied with a 404 and the reason libpod owner policy could not resolve image, without the request reaching Podman. That is the status a visibility policy already returns for a hidden image, so the two layers cannot be told apart by their answers. A connection::{image} source belongs to another daemon and cannot be classified by a local inspect, so enforce mode denies it with reason_code=owner_policy_denied_access and reason libpod owner policy denied access to remote image source. Warn and audit modes forward it as would_deny with the same code and reason. A malformed local-user source fails closed with that reason code and a 403 instead of issuing an empty inspect: the source could not be parsed, which is a different failure from a source that parsed and did not resolve. With no owner configured, all SCP forms pass through untouched. Route classification follows Podman's encoded-path router, so a percent-encoded slash remains inside the SCP source before the source is decoded once for inspection. Podman's earlier per-image POST routes remain distinct: /libpod/images/scp/push, /tag, and /untag check the image named exactly scp, while a longer action path checks the image whose name begins with scp/; only an actual SCP route strips that segment. Because this is the routed path rather than the cleaned one, a trailing slash survives into rule matching and counts as an empty final segment. Podman routes POST /libpod/images/scp/alpine/ as an SCP of the image alpine/, not of alpine, so on the routed view a rule spelling one segment (/libpod/images/scp/*) does not cover it and a rule spelling two (/libpod/images/scp/*/*) does. The cleaned view (/libpod/images/scp/alpine) is evaluated first and has to allow on its own, so a two-segment rule by itself still denies the request. Write /libpod/images/scp/** to cover the whole route regardless of shape.
  • A native retag is checked on both of its images. POST /libpod/images/{name}/tag runs on the same handler as the Docker-compatible route, reads repo and tag, and moves the reference they name off whatever image holds it, so owner isolation inspects that reference after the source image. A reference nothing holds is allowed, the caller's own is allowed, another owner's is denied with a 403, and an unlabeled one follows allow_unowned_images. Podman stores a repo that names no registry under localhost/ on this route without looking anything up, so repo=team/app is checked as localhost/team/app, the name the tag will actually land on. A registry-qualified repo is checked as written. On the Docker-compatible route of a Podman upstream the same short repo is checked under both names, as written and under localhost/, because a daemon setting decides which one that route writes; see Known Limitations. The request shapes refused outright are the ones listed for the Docker-compatible route under Owner Label Isolation. POST /libpod/images/{name}/untag takes the same parameters and is authorized on its source image alone: Podman removes a name only from the image the path resolves, so repo and tag cannot reach another image.
  • The other native routes that name an image are checked on that name. POST /libpod/commit (repo and tag), POST /libpod/build (every t), POST /libpod/images/import (reference), POST /libpod/images/pull (reference) and POST /libpod/images/load (the names in the archive) each write a reference Podman moves off whatever image held it, so owner isolation authorizes it like a retag target. Build, import and load tag without a lookup, so a name with no registry is checked under localhost/. A commit and a pull resolve the name against local images first, so they are checked as written and under localhost/. A build tag that starts with an image transport (containers-storage:app, oci-archive:out, dir:5000/out) is refused, because Podman's builder writes such an output to that transport instead of to the name it spells, and so is a build with a manifest parameter. A pull with allTags, or of a bare name with no tag, is refused. See Owner Label Isolation.
  • Query-selected native image batches are checked too. Sockguard decodes every repeated references value on GET /libpod/images/export and every repeated images value on DELETE /libpod/images/remove, then resolves all of them before forwarding the original request. Ownership requires every member to carry the caller's owner label even when allow_unowned_images permits unlabeled images on ordinary per-image requests. A visibility-only policy applies its label and name/image-pattern axes to every native export member. Percent-encoded slashes, tags, and digests are decoded once. A comma is not a list separator in Podman's real handlers, so clients must repeat the query key for multiple images. Podman's case-insensitive decoder also accepts References, Images, and Unicode-equivalent spellings; Sockguard combines every spelling in arrival order before checking, deduplicates exact decoded identifiers for lookup, and refuses more than 256 selected values before deduplication. Native batch removal also requires noprune=true; force=true and lookupManifest=true are refused because they can act on containers, parent images, or a manifest object outside the named-image preflight. Different case spellings and repetitions are evaluated conservatively using Podman's scalar decoder semantics. In enforce mode a foreign, unlabeled, hidden, missing, or effect-expanding member prevents the daemon from seeing any part of the batch; each of those is a verdict, so a warn or audit profile records the would-be denial and forwards the batch instead. A lookup failure is not a verdict and is refused in every mode, as is a malformed or oversized selector. Visibility-only export also treats a missing member as hidden instead of passing it through to Podman.
  • Docker-compatible image export is refused when ownership or visibility is active and a name is supplied, under both GET /images/get?names=… and GET /images/{name}/get. Moby registers both on the same handler, and its containerd image store can export a full multi-platform index for one tag, digest, or ID. A requested platform can also differ from the default platform returned by an ordinary image inspect. Sockguard cannot prove every exported platform's owner through that inspect shape, so it fails closed instead of treating the selector as one image. Podman's compatibility decoder also accepts case-folded spellings such as Names, so Sockguard folds that key; this is only more conservative on dockerd, which requires exact lowercase names. An omitted names list is still forwarded, and the two daemons disagree about what it means: Podman answers 400 no images to download, while Moby's getImagesGet has no empty-list check at all and answers 200 with an empty application/x-tar archive. Sockguard does not substitute an error for either. The refusal itself follows the profile's rollout mode on both layers: 403 under enforce, a would_deny record and a forwarded request under warn and audit.
  • Per-image deletion is refused under ownership on both API families. Docker-compatible DELETE /images/{name} can prune uninspected parents and, on Moby's containerd store, remove an entire index or caller-selected platforms. Native DELETE /libpod/images/{name} has no noprune control, always permits recursive dangling-parent cleanup, and can use force or lookupManifest to expand or retarget the action. Native batch removal with explicit safe controls is the supported owner-scoped form, and it is a Podman answer only: dockerd serves no /libpod/images/remove, so on a Docker upstream owner isolation leaves owner-filtered POST /images/prune as the only image removal it admits and no way to delete a named image.
  • POST /libpod/commit is owner-checked through its query, not its path. Podman registers the compat POST /commit and the native POST /libpod/commit on compat.CommitContainer and libpod.CommitContainer respectively, and neither names a container in the path: both read the container query parameter. Owner isolation reads the same parameter, authorizes that container with the usual verdicts, and stamps the owner label into the commit body's config so the new image is owned rather than landing unlabeled. A request with no container parameter, with the parameter repeated or spelled in two cases, or carrying a changes value with a LABEL instruction is denied outright: the first names nothing to check, the second would be checked against one container and executed against another because Moby reads the first value while Podman reads the last, and the third would overwrite the stamp, since both engines apply change instructions on top of the body config. Image labels set in the body config are untouched.
  • Response redaction covers every libpod read whose Docker-compat counterpart is redacted, so the same switch is not a way around redaction either. That is a separate layer from the two above, and it needed its own libpod field names rather than the compat ones: redact_container_env, redact_mount_paths and redact_network_topology apply to GET /libpod/containers/{id}/json, redact_container_env and redact_mount_paths to GET /libpod/images/{name}/json, redact_mount_paths to GET /libpod/volumes/json and GET /libpod/volumes/{name}/json, redact_network_topology to GET /libpod/networks/json, GET /libpod/networks/{id}/json and its bare GET /libpod/networks/{id} alias, and redact_sensitive_data to GET /libpod/secrets/json and GET /libpod/secrets/{name}/json. See Response Redaction on the libpod Surface for which field names differ and what is deliberately left alone.
  • Native POST /libpod/networks/{name}/connect and disconnect authorize both the network named by the URL and the body Container reference. A foreign network or container is denied, and an unresolved body container fails closed before Podman receives the request. Docker-compatible network membership routes use the same two-resource ownership check.
  • GET /libpod/events is owner-filtered but is only partly expressible as a visibility policy, and that asymmetry is deliberate. Podman evaluates several values under one event filter key disjunctively, so a label= value injected beside a client-supplied one ORs with it instead of narrowing. Owner isolation by itself replaces the label key outright and so leaves exactly one value, for which disjunctive and conjunctive evaluation are the same thing. A visibility policy's selectors are ANDed by definition and the endpoint has no second filter key to hold the extra one, so sockguard injects a single selector where the policy has one and refuses the endpoint with a 403 and visibility_libpod_events_unscopeable where it has more. Owner isolation plus even one visibility selector is also an inexpressible conjunction, so that combination is refused with owner_visibility_podman_events_unscopeable rather than dropping either constraint. Reporting a subset was rejected for the same reason an emptied /libpod/system/df was: a client watching an event stream cannot tell a quiet host from a filter that stopped applying. The refusals are independent of rollout mode, and a deployment with no visibility policy is unaffected.
  • GET /libpod/system/df is one fixed-path read that cannot be filtered, and it is refused with a 403 instead whenever owner isolation or a visibility policy is active. Podman's native disk-usage report is not the Docker-compat body under another path: its image, container and volume entries carry no labels at all, so no owner label and no visibility selector has a field to match. Refusing keeps it from being a way around isolation the way filtering does for scopeable libpod reads; see Security for the full field list and why an emptied report was the wrong answer. The Docker-compat GET /system/df that Podman also serves is filtered normally.
  • GET /libpod/containers/showmounted is refused the same way and for the same reason. Podman answers it with a bare map of container ID to the daemon host's mount path for that container, built from every container on the host, so one body is both a host-filesystem disclosure and a cross-owner enumeration. It carries no labels, has no Docker-compat counterpart, and accepts no query parameters, so redacting the paths would still hand over the enumeration and there is no field left to decide an entry by. It is not in podman-readonly.yaml, and it is in the read-exfiltration catalog, so a rule reaching it, including a broad one such as GET /libpod/containers/*, fails startup validation unless insecure_allow_read_exfiltration: true is set. With that acknowledgment in place and neither an owner nor a visibility policy configured, the proxy forwards Podman's unredacted container-ID-to-host-mountpoint map. The acknowledgment gates the rule; the isolation layers are what turn the read into a 403.
  • GET /libpod/containers/stats and GET /libpod/pods/stats are refused the same way, new in v2.1. Both are collection endpoints rather than per-resource ones: a request that names nothing reports every running container or pod on the host, by design rather than by omission — Podman's own ValidatePodStatsOptions sets all when nothing is given, "if nothing's specified get all running pods". Neither accepts a filters parameter, so a label filter has nothing to attach to, and their records carry container and pod IDs and names but no labels, so neither an owner label nor a visibility selector has a field to match. Both layers answer 403 before the upstream is contacted, audited as owner_libpod_container_stats_unscopeable and owner_libpod_pod_stats_unscopeable (and their visibility_ counterparts), regardless of rollout mode. podman-readonly.yaml allowed GET /libpod/pods/stats before v2.1 and no longer does; see Known Limitations below for what that costs a deployment that runs neither isolation layer.
  • GET /libpod/secrets/json is refused under owner isolation or a visibility policy, new in v2.1, and it is the one refusal that is not about a missing field. Both layers used to inject a label filter into it the way they do for every other libpod list. Podman evaluates this endpoint's filters with utils.IfPassesSecretsFilter, whose switch accepts name and id and returns invalid filter for anything else, and compat.ListSecrets turns that into a 500, so the injected label did not narrow the list, it broke every request. Dropping the injection alone would have forwarded the whole host's secret inventory instead, so the path is refused, audited as owner_libpod_secret_list_unscopeable and visibility_libpod_secret_list_unscopeable. The items do carry Spec.Labels, so unlike the stats reads a response-side filter could scope this one; none exists today, which is what would move it back off this list. GET /libpod/secrets/{name}/json names one secret and is unaffected. The Docker-compat GET /secrets is refused the same way when upstream.flavor resolves to Podman, because Podman serves it from that same compat.ListSecrets handler and it hit the identical 500; it is audited as owner_podman_secret_list_unscopeable and visibility_podman_secret_list_unscopeable, and on a Docker upstream it is untouched.
  • GET /libpod/manifests/{name}/exists and GET /libpod/manifests/{name}/json are refused whenever owner isolation or a visibility policy is active. Manifest lists carry no owner or policy labels and neither endpoint accepts a filter. The /json route also falls back to fetching the caller-named reference from a registry when no local list exists, so treating local lookup failure as safe would expose remote content. Owner refusals use owner_libpod_manifest_exists_unscopeable and owner_libpod_manifest_json_unscopeable; visibility refusals use visibility_libpod_manifest_exists_unscopeable and visibility_libpod_manifest_json_unscopeable. These are hard 403 refusals in enforce, warn, and audit modes because forwarding cannot produce a scoped response. With neither isolation policy configured, the reads pass through unchanged.
  • POST /libpod/pods/prune is refused under owner isolation, one of two writes in that position (kube down, below, is the other). PodPruneHelper at Podman v5.8.1 calls runtime.PrunePods(r.Context()) with no options and turns the result into a report, so the endpoint documents no parameters, names no resource, and reports what it removed only after removing it. Forwarding it under owner isolation would delete every prunable pod on the host regardless of owner, so the refusal is a 403 audited as owner_libpod_pod_prune_unscopeable, independent of rollout mode: warn mode buys a measurement of what enforcement would cost, and there is no measurement to take once another owner's pods are gone. Remove pods one at a time through DELETE /libpod/pods/{name}, which names a pod the ownership layer checks, or run host-wide pod pruning without owner isolation. A visibility policy does not refuse it, because neither of its axes decides a write.
  • Kube down, DELETE /libpod/play/kube and its DELETE /libpod/kube/play alias, is refused under owner isolation too. Podman 5.8.6 serves both from one handler whose only parameter is force, and what it removes is named in the request body: the pods, secrets and volumes of a Kubernetes YAML that sockguard doesn't parse. A client could name another owner's pod, secret or volume there and have it removed, so the refusal is a 403 audited as owner_libpod_kube_down_unscopeable, independent of rollout mode like the pod prune one. Remove pods one at a time through DELETE /libpod/pods/{name} instead. Without owner isolation, kube down is gated by request_body.container_remove; see the table above. POST on the same paths is kube play, which this doesn't refuse.
  • Version normalization follows Podman's VersionedPath route grammar, not only release-shaped vN.N.N strings. Prerelease and four-component strings that Podman accepts are normalized before rules, ownership, and visibility evaluate the path, so a broad catch-all rule does not bypass the isolation checks by changing only the version segment.
  • Resource names that happen to equal an API action word remain protected. Owner matching reserves keywords only for the exact method and collection path, while GET and HEAD visibility treats write-only keywords as normal resource identifiers.
  • libpod network inspect responses are unwrapped from Podman's occasional single-element-array envelope (GET /libpod/networks/{id}/json sometimes returns [{...}] instead of a bare object) before label extraction.
  • Cross-owner pod-membership checks apply on both sides of the relationship: POST /libpod/pods/create requests that join another owner's namespaces are denied, and POST /libpod/containers/create requests that target another owner's pod (SpecGenerator.pod) are denied — the same container:<ref> namespace-sharing checks Docker-compat create already applies, extended to libpod's uniform {"nsmode":"container","value":"<ref>"} namespace object and pod/infra_image fields.

Set upstream.flavor to the engine that is actually behind the socket, or leave it on auto. Owner-isolation checks branch on it and a wrong value does not fail startup: on a Podman upstream configured as docker, an image retag gets the single-name target check instead of the two-name one that also covers localhost/{repo}:{tag}.

Two endpoints are the exception, and they need upstream.flavor: podman set (or left on auto, which probes for it) to be handled correctly. Podman registers GET /events and GET /libpod/events on a single handler, so the Docker-compat spelling carries Podman's filter semantics, and Podman evaluates several values under one filter key disjunctively where dockerd ANDs them. Sockguard therefore writes a visibility policy's single visible_resource_labels selector as the sole label filter value, replacing whatever the client sent, and refuses the request with a 403 and reason code visibility_podman_events_unscopeable when the policy carries two or more selectors, because two injected values would stream every event matching either one. Owner isolation plus even one visibility selector has the same problem: the owner label and visibility label are two constraints under that disjunctive key, so Sockguard refuses the request with 403 and reason code owner_visibility_podman_events_unscopeable rather than forwarding an owner-only or OR-widened stream. A patterns-only policy is forwarded untouched, as it is on Docker. On a Docker upstream nothing about /events changes. See Security for the operator-facing version of this.

The Docker-compat GET /secrets is the other one. Podman serves it from compat.ListSecrets, the same handler behind GET /libpod/secrets/json, whose filter grammar accepts only name and id and answers 500 for any other key, so the label filter owner isolation and a visibility policy inject into every other list broke the endpoint outright rather than narrowing it. On a Podman upstream a configured owner or a visibility policy carrying selectors now gets 403 and owner_podman_secret_list_unscopeable or visibility_podman_secret_list_unscopeable before the daemon is contacted; a patterns-only visibility policy injects nothing there and is forwarded untouched, and a Docker upstream keeps the conjunctive injection unchanged.

Denial reasons from the libpod-only inspectors carry a libpod prefix: libpod container create denied: …, libpod network connect denied: …, libpod image pull denied: …. The inspectors shared with the Docker-compat surface do not, because one policy and one code path serves both spellings. POST /libpod/build reports build denied: …, libpod exec create and start report exec denied: …, and PUT /libpod/containers/*/archive reports container archive denied: …. So a reason grep is not a reliable way to separate libpod-originated denials; split them by normalized_path instead.

Response Redaction on the libpod Surface

The response.redact_* options are a different layer from ownership and visibility: they rewrite fields inside a body the caller is already entitled to see. Four of them default to true, so this applies to a deployment that configured nothing.

Reusing a Docker-compat redactor on a libpod path is a decision made per endpoint, never the default, because Podman's bodies agree with Docker's far less often than the route names suggest. Where the shapes match, the same handler runs. Where they don't, the libpod field names are pinned separately:

libpod readOptionWhat is redacted
GET /libpod/containers/{id}/jsonredact_container_env, redact_mount_paths, redact_network_topologyConfig.Env; Mounts[].Source, HostConfig.Binds, the daemon storage paths LogPath and every value under GraphDriver.Data, and the native host paths Rootfs, ResolvConfPath, HostnamePath, HostsPath, StaticDir, OCIConfigPath, ConmonPidFile, and PidFile; HostConfig.NetworkMode, the NetworkSettings address block and each Networks entry — the remaining fields use Docker's json tags, plus libpod's own AdditionalMACAddresses list, which has no compat counterpart
GET /libpod/images/{name}/jsonredact_container_env, redact_mount_pathsConfig.Env and every value under GraphDriver.Data. *libimage.ImageData's Config field is *ociv1.ImageConfig, whose Env carries the identical json tag Docker's compat handler uses, and its GraphDriver field is the same {Name, Data} shape container inspect redacts, so the same handler runs on both routes. There is no Mounts, HostConfig, or NetworkSettings on this shape, so redact_network_topology has nothing to do here. The list route GET /libpod/images/json is unaffected — ImageSummary has neither field
GET /libpod/volumes/json, GET /libpod/volumes/{name}/jsonredact_mount_pathsMountpoint. The list body is a bare array, not Docker's {"Volumes": [...]} envelope
GET /libpod/networks/json, GET /libpod/networks/{id}/json, GET /libpod/networks/{id}redact_network_topologyPodman v4 and later: subnets, routes, network_dns_servers, network_interface, and inspect's containers map. Podman v2.2.1-v3.4.4: the raw CNI plugin's host bridge or macvlan master; plugin-level and IPAM-nested dns.nameservers, dns.domain, and dns.search; plus IPAM routes, ranges, static addresses, and backward-compatible flat subnet, gateway, rangeStart, and rangeEnd. List's top-level and per-plugin Bytes fields are removed because they are base64 copies of the complete raw conflist/plugin and would retain the same topology
GET /libpod/secrets/json, GET /libpod/secrets/{name}/jsonredact_sensitive_dataSecretData, the plaintext Podman returns for ?showsecret=true. It is a top-level field, not the Spec.Data Docker uses — Podman's Spec has no Data at all. The redactor still covers the list route, but owner isolation and visibility refuse that route before a response exists to rewrite; the redaction is what a deployment running neither layer gets

Three things are deliberately left alone. Modern ipam_options and legacy plugins[].ipam.type name the allocator, not an address; the former maps onto Docker's IPAM.Driver and IPAM.Options, which redact_network_topology also keeps. Legacy plugins[].dns.options and plugins[].ipam.dns.options control resolver behavior rather than naming internal resolvers or domains, so they are the only DNS subfields retained; nameservers and search are emptied, domain is redacted, and unknown DNS keys are removed at either nesting level. This matches the modern contract's selective treatment of network_dns_servers. And on container inspect, HostConfig.Mounts, MaskedPaths and ReadonlyPaths simply do not exist on the libpod shape — Podman folds every mount into Binds — so those rewrites have nothing to act on rather than being skipped.

Network inspect is reached on two paths and returns two envelopes, and both halves of that are easy to miss. Podman registers GET /libpod/networks/{id} against the same handler as GET /libpod/networks/{id}/json but documents only the suffixed one, so redaction covers both spellings. And through v3.0.0 the handler wrote its whole report slice, making the response [{...}]; from v3.1.0 onward it writes a bare object. Sockguard redacts either and re-emits the shape it was given, so the client's decoder sees what its own daemon sends.

The object inside those envelopes also changed. Podman v2.2.1 through v3.4.4 returns its raw lowercase CNI conflist on inspect. Its list report embeds libcni.NetworkConfigList, so that route instead exposes capitalized Plugins entries and base64 Bytes copies at both the list and plugin level. Podman v4.0 switches both routes to the modern network type. Sockguard detects the fields on the wire rather than trusting the requested version prefix, so an intermediary or compatibility server cannot make the wrong schema bypass redaction. A malformed plugin, interface name, IPAM topology field, range, or Bytes field fails closed.

Version-Prefix Handling

Sockguard strips the Docker API version prefix (/v1.45/) before matching rules, same as it always has. Podman's own clients send their full daemon semver as that prefix — three parts (/v5.0.0/, /v4.9.3/), not Docker's one-or-two-part form (/v1.45/), so the path normalizer doesn't count dot-separated groups at all. It takes /v, one digit, then any run of alphanumerics, . and - up to the next /, which is Podman's own VersionedPath class and covers prerelease and dev builds (/v5.8.1-dev/, /v5.8.1-rc1/) alongside Docker's /v1.45/. /v5.0.0/libpod/containers/json, /v5.8.1-dev/libpod/containers/json, and /libpod/containers/json normalize to the identical rule-matching path. This is transparent — you never configure or reference the version prefix yourself — but it is worth knowing if you are reading raw access logs: the path field carries the client's original version-prefixed request, while normalized_path carries the post-strip form rules actually matched against.

Rootful vs. Rootless

Podman runs two distinct deployment shapes, and sockguard works the same way against either — only upstream.socket changes:

  • Rootful — sudo podman system service on a well-known root-owned socket, conventionally /run/podman/podman.sock. This is the shape most sockguard deployments run behind: a root-owned socket bind-mounted into sockguard's container, mirroring how the Docker suite points at /var/run/docker.sock.
  • Rootless — a per-user podman system service on the invoking user's $XDG_RUNTIME_DIR, conventionally $XDG_RUNTIME_DIR/podman/podman.sock (commonly /run/user/<uid>/podman/podman.sock). Requires a usable newuidmap/newgidmap and /etc/subuid//etc/subgid range for the invoking user.

Sockguard does not auto-detect which mode a given socket is running in, and does not need to — the wire protocol is identical either way. One caveat worth documenting rather than silently living with: the practical blast radius of a gate like allow_privileged or allow_host_userns differs between the two modes even though sockguard enforces the identical default (deny) against both. A "privileged" rootless container is still confined by the outer rootless user namespace; the same flag on a rootful daemon grants genuine host-level privilege. Sockguard's policy surface treats both requests identically by design — it has no way to know which mode the socket it's proxying is running in, and the safer assumption is the rootful one — so don't read an allow_privileged: true rootless deployment as equivalently risky to the same setting against a rootful one, or vice versa; the setting name is the same, the ceiling it grants is not.

Starting Point: podman-readonly.yaml

The podman-readonly.yaml preset ships a read-only monitoring posture covering both surfaces in one file — list/inspect/stats/top/changes for containers, list/inspect for pods (libpod only), images, networks and volumes, per-secret inspect, plus health/version/info/events. It retains top for process monitoring, so both the Docker-compatible and native libpod routes require insecure_allow_read_exfiltration: true: Docker-compatible caller-selected ps_args control the daemon host's ps output, and both routes return process command lines that bypass response redaction. Native GET /libpod/pods/{id}/top carries the same global guard but stays denied by this preset's narrow pod rules. That acknowledgment is global rather than per-rule, so anything you add to this preset later that the exfiltration catalog covers, container logs being the obvious one, is admitted without a fresh startup refusal to warn you. Every other exfiltration-gated endpoint is excluded: archive, export, logs, attach, image get, and image push on both surfaces, plus the libpod-only exfiltration surfaces alongside them — mounted-container inventory (showmounted), generate/kube, and manifest pushes, none of which have a Docker-compat counterpart. The preset allows no writes at all, so it never needs insecure_allow_body_blind_writes. See Presets for how to mount a preset as your config file.

Two rules left the preset in v2.1: GET /libpod/pods/stats and GET /libpod/secrets/json, both of which the isolation layers now refuse (above). A preset that advertises owner isolation cannot also serve a read that has no owner-scoped form, and either rule could only ever have ended in a 403 for anyone running a layer. The secrets one was worse than inert before that: the label filter both layers injected made Podman answer 500, so the preset's own secret list did not work under isolation at all. Add either rule back to your own config if you run single-tenant with neither isolation layer on.

Known Limitations

Deferred past this release (see the roadmap for where these land):

  • Full body modeling for play/kube and kube/apply — these stay behind the blind-write acknowledgment described above rather than getting a real inspector. (generate/kube is not part of this list: it is a GET read-exfiltration endpoint and remains gated by insecure_allow_read_exfiltration, as described earlier.)

  • Narrower policy for POST /libpod/local/build, POST /libpod/local/images/load, and POST /libpod/images/scp/* — all three name their input outside the request (a daemon-host path, or an SSH destination), so there is no body for an inspector to model and they stay behind the acknowledgments described above. Constraining them would mean policy over host paths and SSH destinations, which is a different kind of rule than anything request_body.* expresses today; no shipped preset allows any of them.

  • Body modeling for POST /libpod/containers/*/restore — the CRIU checkpoint archive it accepts under ?import=1 is a gzipped tar carrying the container's OCI spec, and inspecting it means unpacking that archive and running spec.dump through the create gates. It stays behind the blind-write acknowledgment instead.

  • Query-parameter policy for POST /libpod/containers/*/checkpoint and POST /libpod/containers/*/mount — both are gated wholesale by insecure_allow_read_exfiltration rather than by a narrower control, such as admitting a checkpoint only when export is unset. Neither reads a request body, so a request-body inspector is not the shape of that fix.

  • Healthcheck command updates on POST /libpod/containers/*/update are refused outright rather than checked against request_body.exec.allowed_commands. Routing them through the exec allowlist is the better long-run answer; refusing is the fail-closed placeholder until it exists.

  • Manifest-list write inspection — expected to land alongside future image-trust work, since manifest lists are a registry-content concern.

  • Full read-side coverage of the broader /libpod/generate/* family beyond the generate/kube exfiltration-gate entry above (e.g. generate/systemd).

  • Rootful/rootless auto-detection — documented above as a semantic caveat instead of an enforced distinction.

  • Range-overlap analysis for idmappings — allow_custom_id_mappings is a blunt allow/deny gate for this release, not a range-aware check.

  • Response redaction for GET /libpod/pods/{id}/json. Pods have no Docker-compat equivalent, so there is no existing handler to route the path to, and InspectPodData is not a renamed container body: it spells its bind-mount array as lowercase mounts and carries the infra container's network configuration under InfraConfig. Under redact_mount_paths or redact_network_topology, a pod inspect still returns host bind-mount sources and the infra container's addresses. Owner isolation and visibility policy do cover the path, so a caller only sees its own pods; what is missing is the field-level rewrite inside a pod it is entitled to inspect.

  • Response redaction for GET /libpod/info. Every field the /info redactors rewrite is a Docker Engine field Podman does not have: there is no Swarm object (Podman has no swarm) and none of the Containerd/FirewallBackend/DiscoveredDevices/NRI keys redact_host_topology covers. libpod/define.Info is a different document with lowercase keys, so the body is passed through rather than run through a rewrite that would match nothing.

  • Response redaction for GET /libpod/containers/json. There is nothing in the body to redact: ListContainer.Mounts is a list of container-side destination paths, not host sources, and the shape has no NetworkSettings and no HostConfig at all. It is listed here because the Docker-compat GET /containers/json is redacted, and the asymmetry is a property of the two bodies rather than a gap.

  • Response redaction for GET /libpod/system/df. Podman's native disk-usage report has no field for any redactor to act on and no label for any owner or visibility selector to match, so it is refused with a 403 under isolation rather than filtered — see the bullet in Ownership and Visibility Coverage above. It is named here so the response-redaction picture is complete: this is the one libpod read where the answer is a refusal instead of a rewrite, and a deployment with neither owner isolation nor a visibility policy still gets the unredacted report.

  • Response redaction for GET /libpod/containers/showmounted. The handler walks runtime.GetAllContainers() and answers with a bare {"<container id>": "<host mountpoint>"} map, so it discloses host storage paths and enumerates every container on the host in one body. Redacting the values alone would leave the enumeration, which makes it the same problem GET /libpod/system/df has and takes the same answer: a refusal decided by the ownership and visibility layers rather than a rewrite in the response filter. The refusal is wired as of v2.1; the response-side rewrite is not, and is not planned, because a redacted map is still an enumeration. Both layers refuse it, so what is listed here is the residual case, a deployment running neither: 403 before Podman is queried with either isolation layer active, and Podman's unredacted map forwarded with neither. The endpoint is not in podman-readonly.yaml and no default rule admits it. A rule that does (GET /libpod/** reaches it) needs insecure_allow_read_exfiltration: true to pass startup validation, because the path is in the read-exfiltration catalog.

  • Neither spelling of the secret list has a scoped form under either isolation layer. GET /libpod/secrets/json and, on a Podman upstream, the Docker-compat GET /secrets are one handler. Podman's secret filter grammar accepts only name and id and answers 500 for any other key, so the label filter that scopes every other list cannot be pushed upstream, and both layers refuse the path. The gap is narrower than the stats one: the report items carry Spec.Labels, so a response-side filter over that field could scope this list the way GET /system/df is scoped, and building it is what would lift the refusal. Building it for one spelling alone is what is ruled out, not the filter itself: it would leave one handler answering 403 on its native path and a filtered list on its compat path off the same policy. Until then, GET /libpod/secrets/{name}/json is the scoped read, since it names one secret and both layers resolve it normally, and a deployment that wants the whole list has to run without owner isolation and without a visibility policy. The rule left podman-readonly.yaml in v2.1 for that reason.

  • Tecnativa-style env-var compatibility for the libpod surface — the existing CONTAINERS=1/POST=0-style compat env vars only ever generate rules against literal Docker-compat paths (/containers/**, and similar), never /libpod/..., so a Tecnativa-compatible env-var configuration gives you no libpod coverage at all today. A libpod-specific compat layer is tracked as its own follow-up rather than folded in here.

  • Podman remote/SSH transport (podman --remote over SSH rather than a local/proxied socket) is out of scope.

  • AllowedRuntimes has no libpod equivalent — request_body.container_create.allowed_runtimes gates the Docker-compat HostConfig.Runtime field, but libpod_container_create has no matching hook today.

  • There is no owner-scoped form of the collection stats reads to offer you. GET /libpod/containers/stats and GET /libpod/pods/stats are refused outright under owner isolation or a visibility policy, so a single-tenant deployment that legitimately wants host-wide stats has to run without either layer, or allow the paths and accept that they answer 403 while a layer is on. Filtering them was not an option: neither takes a filters parameter and neither report carries labels, so there is nothing to attach a filter to and nothing to decide an entry by. Correlating them against the label-filtered list endpoints would work, but that is the same multi-request mechanism refused for /libpod/system/df.

    Per-container stats survive: GET /libpod/containers/{name}/stats is a different route, {name} is an identifier both layers already check, and it stays allowed. Podman's own API docs mark it "DEPRECATED. This endpoint will be removed with the next major release. Please use /libpod/containers/stats instead", so the only scopeable spelling is the one upstream intends to delete — worth knowing before you build a dashboard on it. Pods have no equivalent at all: Podman registers no per-pod stats route, so pod stats is all-or-nothing.

    The refusal is a plain 403 rather than a truncated stream, which matters because the two endpoints have different default shapes. GET /libpod/containers/stats streams unless the caller sends stream=false (one NDJSON record per five-second tick); GET /libpod/pods/stats returns a single JSON body unless the caller asks for a stream. Sockguard answers before the upstream is contacted either way, so a client written for the streaming shape gets a status line and a normal error body rather than a half-read record.

  • Batch ownership enforcement cannot turn Podman's collection-wide removal into an owner-filtered operation. DELETE /libpod/images/remove with no non-empty images values makes Podman's image engine enumerate and remove a collection, including with all=false; all=true is collection-wide when images are omitted for the same reason. Owner isolation refuses the empty shape. With at least one image, Sockguard still requires noprune=true and refuses force=true or lookupManifest=true; those controls can remove unnamed parents, containers, or a manifest object that the named-image preflight did not authorize. Podman's native per-image and batch exports resolve their named references individually and remain supported after ownership or visibility checks. Docker-compatible export cannot get the same treatment. Both export routes reach the same Moby handler, and one tag, digest, or ID may be a multi-platform index in its containerd image store, so every named compatibility export is refused while either isolation layer is active. Per-image deletion is also unavailable under ownership because neither API family exposes a form whose recursive and platform/manifest effects can all be authorized. Use native batch removal with the safe controls above. These checks do not make the daemon action transactional. A tag can be rebound after its preflight inspect, and Podman can still partially apply an authorized multi-image delete if a later daemon-side operation fails.

  • The Docker-compatible retag route has one reading of its target on dockerd and two on Podman. On dockerd a name means one reference, and Sockguard checks it as the client spelled it. On Podman that is exact only for a registry-qualified repo. Where a repo that names no registry lands depends on compat_api_enforce_docker_hub in the daemon's containers.conf, which Sockguard cannot see. With the default true, Podman looks the name up locally, a registries.conf alias first and localhost/ next, and tags whichever name that lookup found, or the Docker Hub name when nothing holds it. The inspect of the short name goes through the same lookup, so it answers with the image holding the name the tag moves. With the option off, Podman looks nothing up and stores the name under localhost/, the way the native route does, while the inspect of the short name still resolves the alias first.

    So when upstream.flavor resolves to Podman, set or detected, Sockguard inspects a short repo under both names, {repo}:{tag} and localhost/{repo}:{tag}, and forwards the retag only when both pass: each is held by nothing, by the caller's own image, or by an unlabeled one with allow_unowned_images on. That holds whichever way the option is set. It costs one more inspect on such a retag, and one refusal the default setting would not need on its own: a caller whose short name resolves through an alias to its own image is denied while another owner holds localhost/{repo}:{tag}, although Podman would have moved the aliased name. Spell the registry in repo (docker.io/library/nginx, localhost/nginx) to name one reference and get one check. A short repo Podman cannot store under localhost/ is refused with a 403 before anything is inspected: one whose first component has an upper-case letter, and one over 245 characters. A registry-qualified repo, and every retag on a dockerd upstream, is inspected once.

    Podman also decides whether a request is a compat one from the URL instead of the route: the third /-separated piece is libpod on the native API, and on an unversioned /images/{name}/tag it is the image name. An unversioned retag of an image named libpod, or of one whose name starts with libpod/, is therefore a native request to Podman. It skips the lookup and stores a short repo under localhost/ whatever the option says. Sockguard refuses that path with a 403 on every upstream, whatever upstream.flavor resolved to. The versioned path (/v1.41/images/libpod/tag), which the Docker CLI and SDKs always send, is a compat request to Podman too and is unaffected.

    A reference held only by a manifest list with no local instance for the host platform inspects as not found, so it is treated as a reference nothing holds.

  • A pull of a name with no registry is exact on Podman only without a platform. Podman pulls a short name under the name a local image already holds, alias first, and the inspect of the name as written resolves the same way, so it answers for the image the pull replaces. Naming a platform (platform on the compat route, Arch, OS or Variant on the native one) makes Podman skip that lookup and resolve the name through registries.conf, which Sockguard cannot read. So when upstream.flavor resolves to Podman, or to nothing Sockguard recognizes, such a pull is refused with a 403. Spell the registry in the name to pull for a platform. A dockerd upstream has one reading of a name and is not narrowed.

  • Not every field of a libpod network body has a gate. POST /libpod/networks/create passes dns_enabled, internal, ipv6_enabled and routes through uninspected, and POST /libpod/networks/{name}/connect passes interface_name. These are modeled as deliberate omissions rather than oversights — each either has no Docker analog for the shared gate to reuse or carries no host-side privilege — but "the libpod network write surface is inspected" means every endpoint is routed at an inspector, not that every field in it is gated.