* feat(sandbox,gateway): route sandbox egress through corporate HTTP proxy - Chain sandbox egress through a corporate HTTP proxy so outbound traffic from within the sandbox respects the host proxy settings - Forward sandbox proxy environment variables to the generated Podman config so the proxy is applied consistently to Podman-managed workloads Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): make corporate proxy routing operator-owned The operator-configured corporate egress proxy was injected under the conventional HTTPS_PROXY/HTTP_PROXY/NO_PROXY names as defaults beneath sandbox spec/template environment, so a sandbox creator could redirect egress at an arbitrary proxy or disable proxying with NO_PROXY=*. Route the boundary through reserved, supervisor-only variables (OPENSHELL_UPSTREAM_HTTPS_PROXY/HTTP_PROXY/NO_PROXY) written in the Podman driver's required-variable tier. Any sandbox-supplied value under a reserved name is stripped before the operator value is applied, so the supervisor never observes a reserved proxy variable the operator did not set. The supervisor now reads only the reserved names and ignores the conventional proxy variables the sandbox controls. Add the reserved proxy variables to the supervisor-only child-environment denylist so the corporate proxy URL and any embedded credentials are not inherited by the sandbox workload, which reaches egress through the local policy proxy and never needs them. Signed-off-by: Philippe Martin <phmartin@redhat.com> * feat(sandbox,podman): deliver corporate proxy credentials via secret file Proxy credentials were embedded inline in the proxy URL, so they were stored in gateway.toml and exposed in container metadata via 'podman inspect'. Reject inline 'user:pass@' credentials in https_proxy/http_proxy at startup (parsed with the url crate rather than hand-splitting), and add a proxy_auth_file option pointing at a 'user:pass' file. The driver stages that file as a per-sandbox root-only Podman secret, mounts it at a fixed path, and exports only the path in the reserved OPENSHELL_UPSTREAM_PROXY_AUTH_FILE variable, so the credential never appears in config, environment, or container metadata. Reading the file fails closed on a missing, empty, or control-character-bearing value. The supervisor reads the credential from the mounted file and builds the Proxy-Authorization: Basic header, rejecting control characters, and no longer derives credentials from URL userinfo. The auth-file path is added to the child-environment strip list, and the generated gateway.toml is written owner-only (mode 0600). Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): fail closed on invalid upstream proxy configuration The reserved OPENSHELL_UPSTREAM_* variables are an operator-owned egress boundary, but the supervisor treated present-but-invalid values as unset: an unsupported or malformed proxy URL was ignored with a warning, an unreadable auth file proceeded without credentials, and a malformed credential silently became unauthenticated. Any of these could quietly downgrade the corporate proxy boundary to direct dialing or unauthenticated proxy access. Make every configured-but-invalid proxy or auth setting fatal to supervisor proxy startup, emit an OCSF ConfigStateChange failure event before refusing, and share URL validation semantics between the Podman driver and the supervisor through a single validator in openshell-core (parse_upstream_proxy_url), so a value accepted at sandbox-create time can never be rejected in-container or vice versa. Inline user:pass@ URL credentials are now fatal in the supervisor too (previously warn-and-strip), matching the driver. Unset or empty variables still mean no proxy; only present-but-invalid values fail. Error paths never include credential content. Addresses the fail-closed review item on #2245. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): finish the fail-closed upstream proxy credential contract The upstream proxy URL already had a single shared validator, but the credential did not: the Podman driver rejected only CR/LF/NUL while the supervisor rejected every control character, so a credential accepted at sandbox-create time (e.g. one containing a tab) could still be rejected in-container. Present-but-whitespace reserved OPENSHELL_UPSTREAM_* values were also silently treated as unset, quietly downgrading the operator's egress boundary to direct dialing. Add parse_upstream_proxy_credential to openshell-core as the single source of truth for the documented user:pass credential form (non-empty user, no control characters, trimmed) and use it in both the Podman driver's secret staging and the supervisor's Proxy-Authorization header construction. Error variants carry no payload so credential content can never leak into messages. Make a present-but-empty reserved variable fatal to supervisor proxy startup instead of meaning "unset"; only fully unset variables disable the proxy. The driver correspondingly rejects an empty no_proxy at config time so it can never inject a value the supervisor refuses. Addresses the remaining fail-closed credential/config review item on Signed-off-by: Philippe Martin <phmartin@redhat.com> #2245. * fix(sandbox,podman): close remaining fail-open upstream proxy config paths Two configuration paths could still silently run without the proxy boundary the operator believed was in effect. A no_proxy bypass list configured without any https_proxy/http_proxy was accepted by both the driver and the supervisor and simply meant "dial everything directly". Reject it on both sides, exactly like the existing proxy_auth_file-without-proxy rule: an operator who wrote a bypass list assumed proxying was active, so accepting it hides a fail-open state. The gateway.sh dev script guarded proxy settings with [[ -n "${VAR:-}" ]], which conflates unset with explicitly-empty and dropped the latter before the gateway's validation could see it. Use ${VAR+x} instead so a set-but-empty variable is written into gateway.toml and rejected at startup by validate_proxy_config rather than silently discarded. Addresses the remaining fail-open configuration review item on #2245. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): reject upstream proxy URLs with path, query, or fragment parse_upstream_proxy_url accepted URLs like http://proxy.corp.com:8080/some/path and silently discarded everything after host:port, a lenience inherited from the original supervisor parser. A forward proxy is addressed by host:port only, so extra components indicate a misconfiguration (for example a pasted endpoint URL) and silently truncating them violates the present-but-invalid-is-fatal contract enforced everywhere else in this configuration surface. Reject a path, query, or fragment in the shared validator with a new UnexpectedComponent error. A bare trailing slash remains accepted because the url crate normalizes an absent http path to "/", making the two indistinguishable. Both the Podman driver (gateway startup) and the supervisor (sandbox startup) inherit the rule through the shared parser, keeping their semantics identical by construction. Addresses the proxy URL component review item on #2245. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): remove plain-HTTP upstream proxy support The http_proxy path tunneled plain-HTTP requests through the corporate proxy with CONNECT to port 80 and then sent origin-form requests down the tunnel. Conventional enterprise forward proxies expect plain HTTP as absolute-form requests sent directly over the proxy connection, and commonly refuse CONNECT to port 80, so the setting looked supported but failed against typical deployments. Tunneling also blinds the proxy to the one protocol it could inspect. Narrow the feature to TLS (CONNECT) egress only, which is the conventional and already-correct case: plain-HTTP requests now always dial the destination directly, and only client CONNECT tunnels chain through the corporate proxy. Remove the http_proxy config field, the --sandbox-http-proxy / OPENSHELL_SANDBOX_HTTP_PROXY driver surface, the reserved OPENSHELL_UPSTREAM_HTTP_PROXY variable, and the UpstreamScheme plumbing. The feature never shipped, so this is a clean removal; a stray http_proxy key in gateway.toml still fails loudly through the config's deny_unknown_fields. Removing the plain-HTTP proxy branch also removes its host-gateway special case; the architecture doc now documents the real host-gateway behavior (add driver-injected host aliases to the reserved NO_PROXY list) instead of an invariant the HTTPS path never implemented. Plain-HTTP forwarding through a corporate proxy can return later as absolute-form forwarding behind its own design review. Addresses the plain-HTTP forwarding review item on #2245. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): escape generated TOML and require explicit proxy URL form Escape backslashes, quotes, and control characters when gateway.sh writes proxy values into gateway.toml, so a hostile or unusual environment value cannot corrupt the config or inject extra keys. Restrict the upstream proxy URL grammar to the documented http://host:port form: a scheme-less value is no longer normalized to http:// and a missing port is no longer silently defaulted to 80. Docs, README, and CLI help now state the explicit-form requirement consistently. Also fix a test-only call of handle_tcp_connection that was missing the upstream_proxy argument added in an earlier commit. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): preserve tunneled bytes read with the CONNECT response The CONNECT handshake reads the corporate proxy's response in chunks, so the read that completes the header block can also contain the first tunneled payload bytes. Those bytes were discarded, silently corrupting the start of the tunnel for server-speaks-first destinations or proxies that coalesce writes. connect_via now returns a PrefixedStream that replays any bytes received past the response terminator before reading from the socket again; writes pass through unchanged. Direct dials wrap the stream with an empty prefix so downstream relay and TLS paths keep a single stream type, and tls_connect_upstream is generalized to any AsyncRead + AsyncWrite stream. Adds regression coverage for a combined response/payload read and for prefix replay ordering. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): gate cleartext proxy Basic auth behind an explicit opt-in Proxy-Authorization: Basic is base64 over the plain-TCP connection to the http:// corporate proxy, so anyone on the network path between the sandbox host and the proxy can recover the credential. Sending it is now an explicit operator decision instead of an implicit side effect of configuring proxy_auth_file. Add a proxy_auth_allow_insecure driver setting (CLI --sandbox-proxy-auth-allow-insecure, env OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE), delivered to the supervisor as the reserved OPENSHELL_UPSTREAM_PROXY_AUTH_ALLOW_INSECURE variable. Fail-closed pairing on both sides: an auth file without the acknowledgement is rejected at gateway startup and at supervisor startup, as is the acknowledgement without an auth file or any value other than 'true'. gateway.sh writes the key only as a TOML boolean; a non-boolean value is emitted as a quoted string so the gateway rejects it at startup instead of risking injection. Documents the exposure prominently in the gateway config reference, the driver README, and the sandbox architecture doc. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): honor port qualifiers and resolved addresses in NO_PROXY Two divergences from the documented NO_PROXY contract: - A port-qualified entry (internal.corp:8443) was silently stripped to its hostname and bypassed the proxy for every port, excluding traffic the operator never listed. Entries now keep the optional :port qualifier (also on IP and CIDR entries) and only apply to that destination port; a trailing qualifier that is not a valid port stays part of the pattern instead of widening the entry. - IP and CIDR entries only matched IP-literal hosts, so a bypass like 10.0.0.0/8 never applied to hostnames resolving into that range. NO_PROXY evaluation now sees the validated resolved addresses: an IP/CIDR entry matching through resolution authorizes a direct dial of only the addresses it contains, so a bypass scoped to an internal range cannot widen into a direct dial of addresses outside it. Hostname-level matches (loopback, wildcard, domain entries, IP-literal hosts) keep authorizing all validated addresses. proxy_for is replaced by decision(host, port, resolved) returning either the proxy endpoint or the permitted direct-dial subset, and dial_upstream restricts the direct connect to that subset. Adds regression coverage for port-scoped bypasses on domain, IP, and CIDR entries, invalid port qualifiers, resolved-address matching, and split-resolution subset dialing. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): bind proxied CONNECT tunnels to validated addresses The CONNECT request sent to the corporate proxy carried the destination hostname, so the proxy resolved the name itself and the addresses that had passed SSRF and allowed_ips validation were discarded. Split-horizon DNS or rebinding at the proxy could then reach internal or otherwise unapproved destinations through a tunnel the supervisor logged as validated, and IP-range policy could not be enforced at all on proxied dials. CONNECT now targets a validated resolved address by default: the proxy performs no DNS resolution and the tunnel stays bound to the answer the supervisor checked. The hostname still travels inside the tunnel (TLS SNI, application Host), so destination servers behave normally. In split-horizon networks, operators point the gateway host at the corporate resolver so internal names validate to their internal addresses. For proxies whose ACLs filter on hostnames and reject IP CONNECT targets, a new proxy_connect_by_hostname opt-in (CLI --sandbox-proxy-connect-by-hostname, env OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME, reserved OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME) restores hostname CONNECT, documented as re-opening proxy-side resolution and making the proxy's ACLs the effective egress control. Fail-closed pairing on both sides: the opt-in without a proxy, or any value other than 'true', is fatal. Adds regression coverage for the IP CONNECT request line (including IPv6 bracketing and hostname non-leakage), the hostname opt-in, and the config pairing rules in driver and supervisor. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): reject empty port after bracketed IPv6 proxy host http://[fd00::1]: passed the explicit-port check because the bracketed branch only tested for a colon after the bracket, then fell back to port 80 — violating the fail-closed http://host:port contract and potentially sending configured Basic credentials to an unintended service. Require a non-empty suffix after ]:, matching the unbracketed branch, and cover the case in the shared parser tests. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): fall back across validated addresses in proxied CONNECT The direct path hands TcpStream::connect the whole validated address list and it falls back across them, but the validated-IP CONNECT path attempted only the first address, so a dual-stack destination could fail through the corporate proxy even when a later validated address was reachable. connect_via_validated tries each validated address in order under one aggregate CONNECT_HANDSHAKE_TIMEOUT budget, returning the first success; when every attempt fails the error names the attempt count and carries the last failure. An empty address list is rejected up front. Adds regressions for first-fails/second-succeeds fallback (asserting both CONNECT request lines), the aggregate all-addresses failure message, and the empty-list rejection. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): strip new reserved proxy vars and complete docs/tests Follow-ups from review: - Add OPENSHELL_UPSTREAM_PROXY_AUTH_ALLOW_INSECURE and OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME to the supervisor-only strip list so workload child processes never inherit them, matching the documented contract for the other reserved proxy variables, and cover both in the supervisor-only variable test. - Document all five OPENSHELL_SANDBOX_* proxy variables in the mise run gateway help text, marked Podman-only and stating the auth-file/acknowledgement pairing, and complete the gateway-key list in the Podman README. - Add NO_PROXY composition coverage for bracketed IPv6 entries with port qualifiers, bare IPv6 entries, and IPv6 CIDR matching against an IPv6-literal host and against a hostname's resolved addresses, including the port-qualified CIDR form. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): cap each proxied CONNECT attempt within the shared budget A proxy that accepted the first CONNECT request but never responded consumed the entire aggregate handshake timeout, so later validated addresses were never tried and the hang defeated the multi-address fallback. Each attempt is now time-boxed to its fair share of the time remaining before the shared deadline (remaining / attempts_left): a hanging attempt is cut off with enough budget left for every remaining address, while time a fast failure does not use rolls over to later attempts and the total never exceeds CONNECT_HANDSHAKE_TIMEOUT. A timed-out attempt is recorded like any other failure, and the aggregate error distinguishes all-attempted from budget-exhausted runs. Adds a first-hangs/second-succeeds regression driven through a test-visible budget parameter so it runs in about a second instead of a real 30s window. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): deliver corporate proxy config on the supervisor argv The proxy settings were injected as reserved OPENSHELL_UPSTREAM_* container environment variables. The driver only wrote names the operator configured, but container runtimes layer the spec environment over ENV values baked into the sandbox image, so an image could supply NO_PROXY=*, enable hostname CONNECT, or point an unconfigured deployment at an attacker-controlled proxy whenever the operator left a field unset. The settings now travel as supervisor command-line arguments (--upstream-proxy, --upstream-no-proxy, --upstream-proxy-auth-file, --upstream-proxy-auth-allow-insecure, --upstream-proxy-connect-by-hostname) built by the driver from operator config. The driver sets the container entrypoint and command explicitly, so neither sandbox spec/template environment nor image ENV can influence argv, and an omitted flag genuinely means unconfigured — in every supervisor topology, since the supervisor no longer consults its environment for these settings at all. Credentials stay on the root-only secret mount; only the mount path appears on argv. The reserved environment names, their strip-list entries, and the env-based validation surface are removed. UpstreamProxyConfig::from_args replaces from_env, reusing the same shared fail-closed validation and pairing rules keyed by the CLI flag names. Driver tests now assert the argv contract, including that sandbox-supplied environment cannot add, remove, or redirect proxy flags; supervisor tests cover from_args mapping and its pairing rules. Signed-off-by: Philippe Martin <phmartin@redhat.com> * docs(sandbox): align proxy comments with the argv transport The argv migration left comments describing the configuration as reserved environment variables ("reserved value", "present-but-empty variable", "reserved upstream proxy variables"). Rephrase them as driver-supplied arguments and operator settings so the documented trust boundary matches the implementation. Comment-only change. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox): parse bracketed IPv6 authorities in client CONNECT targets parse_target split the CONNECT authority at the first colon, so an IPv6-literal target like [2001:db8::1]:443 always failed port parsing and IPv6-literal clients could never reach policy evaluation; a regression test even locked in that failure. Parse the RFC 3986 bracketed form and return the host bracket-free, matching what DNS resolution, SSRF validation, NO_PROXY matching, and the upstream CONNECT builder expect. Unclosed brackets, a missing or empty port after the bracket, and non-numeric ports are rejected; unbracketed behavior is unchanged. Replaces the failure-locking test with success coverage for bracketed targets and adds malformed-bracket rejection cases. Signed-off-by: Philippe Martin <phmartin@redhat.com> * test(podman): cover proxy-auth secret cleanup across lifecycle failures The per-sandbox proxy-auth credential secret is staged before the container is created and removed on cleanup, but no test proved the cleanup paths actually issue the secret removal. Add Podman-stub tests that drive create_sandbox to a container-create failure and to a start failure, and delete_sandbox for an out-of-band deletion, asserting each path issues the DELETE for the per-sandbox proxy-auth secret so a credential can never outlive the sandbox that owned it. Signed-off-by: Philippe Martin <phmartin@redhat.com> * docs: list corporate proxy keys in the Podman compute-driver overview The Fern Podman driver section enumerated its gateway.toml keys but omitted the corporate egress proxy settings. Add https_proxy, no_proxy, proxy_auth_file, proxy_auth_allow_insecure, and proxy_connect_by_hostname with a pointer to the gateway configuration reference for the full contract. No navigation change: the reference folder already includes the gateway configuration page. Signed-off-by: Philippe Martin <phmartin@redhat.com> * test(sandbox): cover the SSRF-to-TLS composition across the proxy tunnel Existing tests exercised validated-IP CONNECT and the upstream-TLS helper independently, but not the full boundary. Add an end-to-end regression that stands up a fake corporate proxy tunneling to a fake TLS server and drives the real path: connect_via_validated CONNECTs to the validated address, the proxy splices the tunnel, and tls_connect_upstream verifies the upstream certificate against the original hostname carried in SNI. It asserts the CONNECT authority is the validated IP and never the hostname, that verification succeeds for the matching hostname, and that a mismatched hostname is rejected — proving a rebinding or split-horizon substitution behind the proxy cannot pass certificate verification. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox,podman): bound proxy-auth reads, reject port 0, fix stale comment Three review findings: - CWE-400: the proxy-auth credential file was read with an unbounded read_to_string on both the driver (sandbox-create) and supervisor (startup) paths, so a huge file or a special file such as /dev/zero could exhaust memory. Add a shared bounded reader in openshell-core that rejects non-regular files, caps the size at 4 KiB, and reads at most that many bytes; the driver runs it via spawn_blocking. Covers oversized, special-file, and missing-path cases on both sides. - Reject an upstream proxy URL with port 0: it passed the explicit-port check and startup validation but is not a connectable TCP port, so every proxied dial would fail later. Add a typed ZeroPort error with shared-validator and Podman-config tests. - Reword a driver-config comment that still described a 'reserved variable' to match the argv transport. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(sandbox): open proxy-auth file non-blocking to reject FIFOs promptly read_upstream_proxy_credential_file opened the path with a blocking File::open before the regular-file check, so a configured FIFO with no writer would block open() indefinitely — hanging sandbox creation on the driver and supervisor startup. Open with O_NONBLOCK on Unix so the open returns immediately, then reject the non-regular file as before; O_NONBLOCK has no effect on the later read of a regular file. Adds a mkfifo regression asserting the reader returns promptly with a non-regular-file error instead of hanging. Signed-off-by: Philippe Martin <phmartin@redhat.com> * test(podman): cover corporate proxy egress across driver and supervisor The existing corporate-proxy tests construct config structs or call CONNECT helpers directly, so none of them detect a break in the wiring between layers: gateway TOML deserialization, the Podman argv and secret-mount semantics, supervisor CLI parsing, or policy denial before proxy contact. Add a Podman e2e that drives the whole chain against a fake authenticated forward proxy and asserts that an approved TLS request traverses it with a validated-IP CONNECT, a policy-denied destination is refused with 403 without ever reaching the proxy, credentials arrive through the mounted per-sandbox secret, and deleting the sandbox removes that secret. SupportContainer is a new harness fixture. Unlike ContainerHttpServer it probes readiness with a TCP connect rather than an HTTP GET, so it can host a forward proxy and TLS servers, and it exposes container logs and network IP for assertions. Signed-off-by: Philippe Martin <phmartin@redhat.com> * test(podman): restart the gateway on the proxy-config panic path The panic cleanup for the temporary corporate proxy configuration restored the gateway TOML but left the gateway process running with the temporary configuration still loaded, which could poison later test binaries in the same run. Nothing restarted it: the only ManagedGateway is the short-lived one inside restart_gateway, and its Drop only calls start, which does not reload config for an already-running gateway. Restore and synchronously stop/start the gateway in Drop, and set restored only after the normal restore and restart both succeed so a failed restart no longer suppresses the fallback. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(supervisor-network): derive the loopback proxy bypass from resolved IPs The automatic bypass treated the host string "localhost" as proof that the destination was loopback and returned every resolved address. A sandbox controls its own /etc/hosts and resolve_socket_addrs consults it before DNS, so a workload could map localhost to any policy-allowed address and dial it directly, escaping the operator proxy and the inspection and audit boundary it exists to provide. Check the resolved addresses instead: the name bypasses only when the resolution is non-empty and every address is loopback. A mixed answer is not partially honored, and an IP literal is still authoritative for itself. A spoofed localhost falls through to the entries below, so an explicit operator NO_PROXY entry is still honored. This matches the trust model detect_trusted_host_gateway already applies to the same hosts file, which validates the mapped address rather than the alias. Signed-off-by: Philippe Martin <phmartin@redhat.com> * fix(podman): clean up proxy-auth secret when container already deleted The delete_sandbox early-return path cleaned up the token secret but skipped the proxy-auth secret, leaking it on disk. Also update tests for recent API changes (Optional socket_path, workspace field, list-based container lookup). Signed-off-by: Philippe Martin <philippe@openshell.dev> Signed-off-by: Philippe Martin <phmartin@redhat.com> --------- Signed-off-by: Philippe Martin <phmartin@redhat.com> Signed-off-by: Philippe Martin <philippe@openshell.dev>
openshell-driver-podman
The Podman compute driver manages sandbox containers via the Podman REST API
over a Unix socket. It targets single-machine and developer environments where
rootless container isolation is preferred over a full Kubernetes cluster. The
driver runs in-process within the gateway server and delegates all sandbox
isolation enforcement to the openshell-sandbox supervisor binary, which is
sideloaded into each container via an OCI image volume mount.
For a rootless networking deep dive, see NETWORKING.md.
Architecture
The Podman driver communicates with the Podman daemon over a Unix socket and delegates sandbox isolation to the supervisor binary running inside each container.
graph TB
CLI["openshell CLI"] -->|gRPC| GW["Gateway Server<br/>(openshell-server)"]
GW -->|in-process| PD["PodmanComputeDriver"]
PD -->|HTTP/1.1<br/>Unix socket| PA["Podman API"]
PA -->|OCI runtime<br/>crun/runc| C["Sandbox Container"]
C -->|image volume<br/>read-only| SV["Supervisor Binary<br/>/opt/openshell/bin/openshell-sandbox"]
SV -->|creates| NS["Nested Network Namespace<br/>veth pair + proxy"]
SV -->|enforces| LL["Landlock + seccomp"]
SV -->|gRPC callback| GW
Isolation Model
The Podman driver provides the same protection layers as the other compute
drivers. The driver itself does not implement isolation primitives directly. It
configures the container so that the openshell-sandbox supervisor can enforce
them at runtime.
Container Security Configuration
The container spec in container.rs sets these security-critical fields:
| Setting | Value | Rationale |
|---|---|---|
user |
0:0 |
The supervisor needs root inside the container for namespace creation, proxy setup, Landlock, seccomp, and filesystem preparation. |
cap_drop |
Selected unneeded defaults | Podman's default capability set is already restricted. The driver drops capabilities the supervisor does not need. |
cap_add |
SYS_ADMIN, NET_ADMIN, SYS_PTRACE, SYSLOG, DAC_READ_SEARCH, SETPCAP |
Grants supervisor-only capabilities required for namespace setup, process identity, bypass diagnostics, and child bounding-set cleanup. |
no_new_privileges |
true |
Prevents privilege escalation after exec. |
seccomp_profile_path |
unconfined |
The supervisor installs its own policy-aware BPF filter. A container-level profile can block Landlock/seccomp syscalls during setup. |
mounts |
Private tmpfs at /run/netns |
Lets the supervisor create named network namespaces in rootless Podman. |
| CDI GPU devices | Opaque driver_config.cdi_devices values when set, otherwise the requested count of NVIDIA CDI GPUs selected in round-robin order. Local /dev/dxg permits nvidia.com/gpu=all as a WSL2 all-only compatibility fallback, where it counts as one selectable device. |
Exposes requested GPUs to GPU-enabled sandbox containers. Exact CDI device lists must not contain duplicates and must match the effective GPU count. |
The restricted agent child does not retain these supervisor privileges.
Driver Config Mounts
The gateway forwards the podman block from --driver-config-json to this
driver. The driver accepts user-supplied mounts entries with these Podman
mount types:
bind: mounts an absolute host path when[openshell.drivers.podman]hasenable_bind_mounts = true.volume: mounts an existing Podman named volume. The driver validates that the volume exists before provisioning and never creates or removes it. Podman local-driver volumes created with bind options are treated as host bind mounts and requireenable_bind_mounts = true.tmpfs: mounts an in-memory filesystem with optionaloptions,size_bytes, andmode.image: mounts an OCI image through Podman's image-volume API. The driver pulls the image during provisioning using the sandbox image pull policy.
Host bind mounts are disabled by default because they expose gateway host paths to sandbox requests. The driver still uses internal bind mounts for configured TLS material; per-sandbox gateway JWTs are delivered through Podman secrets.
Podman bind mounts accept source, target, optional read_only, and an
optional selinux_label of shared (applies :z) or private (applies
:Z) for SELinux-enforcing hosts. User-supplied bind and volume mounts are
read-only by default; set read_only: false to make them writable. Podman
image and volume mounts do not support subpath in OpenShell driver config.
Mount source and target values must not contain surrounding whitespace.
Mount targets must be absolute container paths and must not replace
the workspace root (/sandbox) or overlap OpenShell supervisor files,
/etc/openshell, /etc/openshell-tls, or /run/netns.
Example named-volume usage:
podman volume create openshell-work
openshell sandbox create \
--driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"openshell-work","target":"/sandbox/work"}]}}' \
-- claude
Capability Breakdown
| Capability | Purpose |
|---|---|
SYS_ADMIN |
seccomp filter installation, namespace creation, and Landlock setup. |
NET_ADMIN |
Network namespace veth setup, IP address assignment, routes, and nftables. |
SYS_PTRACE |
Reading /proc/<pid>/exe and walking process ancestry for binary identity. |
SYSLOG |
Reading /dev/kmsg for bypass-detection diagnostics. |
DAC_READ_SEARCH |
Reading /proc/<pid>/fd/ across UIDs so the proxy can resolve the binary responsible for a connection. |
SETPCAP |
Clearing the restricted child process capability bounding set before exec. |
The driver intentionally keeps Podman's default SETUID, SETGID, CHOWN,
and FOWNER capabilities because the supervisor needs them to drop privileges
and prepare writable sandbox directories. It also keeps SETPCAP until child
setup so drop_privileges() can clear the child capability bounding set before
exec. It drops unneeded defaults such as
DAC_OVERRIDE, FSETID, KILL, NET_BIND_SERVICE, NET_RAW, SETFCAP,
and SYS_CHROOT.
Supervisor Sideloading
The supervisor binary is delivered to sandbox containers via Podman's OCI image volume mechanism, distinct from both the Kubernetes pod-volume approach and the VM's embedded guest bundle.
sequenceDiagram
participant D as PodmanComputeDriver
participant P as Podman API
participant C as Sandbox Container
D->>P: pull_image(supervisor, "missing")
D->>P: create_container(spec with image_volumes)
Note over P: Podman resolves image_volumes at<br/>libpod layer before OCI spec generation
P->>C: Mount supervisor image at /opt/openshell/bin (read-only)
D->>P: start_container
C->>C: entrypoint: /opt/openshell/bin/openshell-sandbox
The supervisor image from deploy/docker/Dockerfile.supervisor provides the
static openshell-sandbox binary at /openshell-sandbox.
Mounting that image at /opt/openshell/bin makes the binary available as
/opt/openshell/bin/openshell-sandbox.
The container spec sets that binary as the entrypoint. This avoids relying on the sandbox image entrypoint or command, which might otherwise append the supervisor path as an argument to an image-provided shell.
TLS
When all three Podman TLS paths are set, the driver treats sandbox callbacks as mTLS callbacks:
OPENSHELL_PODMAN_TLS_CAOPENSHELL_PODMAN_TLS_CERTOPENSHELL_PODMAN_TLS_KEY
The driver validates that the TLS paths are provided as a complete set. Partial configuration fails early instead of silently falling back to plaintext.
When enabled, the driver:
- Switches the auto-detected endpoint scheme from
http://tohttps://. - Bind-mounts the client cert files read-only into the container at
/etc/openshell/tls/client/. - Sets
OPENSHELL_TLS_CA,OPENSHELL_TLS_CERT, andOPENSHELL_TLS_KEYto the container-side paths.
The supervisor reads these env vars and uses them to establish an mTLS connection back to the gateway. On SELinux systems, the bind mounts include Podman's shared relabel option so the container process can read the files.
The RPM packaging auto-generates a self-signed PKI on first start via
openshell-gateway generate-certs. Client certs are placed in the CLI
auto-discovery directory (~/.config/openshell/gateways/openshell/mtls/) so
the CLI connects with mTLS without manual configuration. See
deploy/rpm/CONFIGURATION.md for the full RPM configuration reference.
Network Model
Sandbox network isolation uses a two-layer approach: a Podman bridge network for container-to-host communication, and a nested network namespace created by the supervisor for sandbox process isolation.
graph TB
subgraph Host
GW["Gateway Server<br/>127.0.0.1:17670"]
PS["Podman Socket"]
end
subgraph Bridge["Podman Bridge Network (10.89.x.x)"]
subgraph Container["Sandbox Container"]
SV["Supervisor<br/>(root in user ns)"]
subgraph NestedNS["Nested Network Namespace"]
SP["Sandbox Process<br/>(sandbox user)"]
VE2["veth1: 10.200.0.2"]
end
VE1["veth0: 10.200.0.1<br/>(CONNECT proxy)"]
SV --- VE1
VE1 ---|veth pair| VE2
end
end
GW -.->|SSH via supervisor relay<br/>gRPC session| SV
SV -->|gRPC callback via<br/>host.containers.internal| GW
SP -->|all egress via proxy| VE1
Key points:
- Bridge network: created by
client.ensure_network()with DNS enabled. Containers on the bridge can see each other at L3, but sandbox processes cannot because they are isolated inside the nested netns. - Nested netns: the supervisor creates a private
NetworkNamespacewith a veth pair. Sandbox processes enter this netns viasetns(fd, CLONE_NEWNET)in thepre_exechook, forcing ordinary traffic through the CONNECT proxy. - Port publishing: the container spec still requests
host_port: 0for the configured SSH port. The gateway SSH tunnel uses the supervisor relay rather than connecting directly to the published port. - Host gateway:
host.containers.internalandhost.openshell.internalare injected into/etc/hostsso containers can reach services on the gateway host. Linux defaults to Podman'shost-gatewayresolver. macOS Podman machine defaults to gvproxy's host-loopback IP,192.168.127.254, because stale Podman machines may fail to resolvehost-gateway. - nsenter: the supervisor uses
nsenter --net=instead ofip netns execfor namespace operations, avoiding the sysfs remount path that fails in rootless containers.
See NETWORKING.md for the rootless Podman networking deep dive.
Supervisor Relay
Podman follows the same end-to-end contract as the Kubernetes and VM drivers
for the in-container SSH relay: gateway config to PodmanComputeConfig to
sandbox environment to supervisor session registration on that path.
openshell-coreConfig::sandbox_ssh_socket_pathis copied intoPodmanComputeConfig::sandbox_ssh_socket_pathwhen the gateway builds the in-process driver.build_env()incontainer.rssetsOPENSHELL_SSH_SOCKET_PATHto that value, alongside required vars such asOPENSHELL_ENDPOINTandOPENSHELL_SANDBOX_ID. These driver-controlled entries overwrite template environment variables to prevent spoofing.- The supervisor reads
OPENSHELL_SSH_SOCKET_PATHand uses it for the Unix socket the gateway's SSH stack bridges to.
The standalone openshell-driver-podman binary sets the same struct field from
OPENSHELL_SANDBOX_SSH_SOCKET_PATH.
Credential Injection
Sandboxes authenticate to the gateway via mTLS using client materials bind- mounted into the container from a Podman secret. No shared per-request secret is injected as an environment variable.
| Credential | Mechanism | Visible in inspect? |
Visible in /proc/<pid>/environ? |
|---|---|---|---|
| mTLS client cert/key | Bind-mounted file paths (OPENSHELL_TLS_* env vars point at them) |
Yes (paths only) | Yes (paths only) |
| Sandbox identity | Plaintext env var | Yes | Yes |
| gRPC endpoint | Plaintext env var, override-protected | Yes | Yes |
| Supervisor relay socket path | Plaintext env var, override-protected | Yes | Yes |
The build_env() function inserts user-supplied variables first, then
unconditionally overwrites all security-critical variables to prevent spoofing
via sandbox templates:
OPENSHELL_SANDBOXOPENSHELL_SANDBOX_IDOPENSHELL_ENDPOINTOPENSHELL_SSH_SOCKET_PATHOPENSHELL_CONTAINER_IMAGEOPENSHELL_SANDBOX_COMMAND
Sandbox Lifecycle
Creation Flow
sequenceDiagram
participant GW as Gateway
participant D as PodmanComputeDriver
participant P as Podman API
GW->>D: create_sandbox(DriverSandbox)
D->>D: validate name + id
D->>D: validated_container_name()
D->>P: pull_image(supervisor, "missing")
D->>P: pull_image(sandbox_image, policy)
D->>P: create_volume(workspace)
Note over D: On failure below, rollback volume
D->>P: create_container(spec)
alt Conflict (409)
D->>P: remove_volume
D-->>GW: AlreadyExists
end
Note over D: On failure below, rollback container + volume
D->>P: start_container
D-->>GW: Ok
Each step rolls back previously-created resources on failure. The Conflict path cleans up the volume because it is keyed by the new sandbox's ID, not the conflicting container's ID.
Readiness and Health
The container healthconfig marks the sandbox healthy when any of these
signals succeeds:
- Legacy marker file
/var/run/openshell-ssh-ready. test -Son the configured supervisor Unix socket path.- The prior TCP check for a listener on the in-container SSH port.
The Unix socket check allows relay-only readiness when the supervisor exposes the socket without the old marker or published-port signal.
Deletion Flow
- Validate
sandbox_nameand stablesandbox_idfromDeleteSandboxRequest. - Best-effort inspect cross-checks the container label when present, but
cleanup remains keyed by the request
sandbox_id. - Best-effort stop, ignoring the stop result.
- Force-remove the container.
- Remove workspace volume derived from the request
sandbox_id, warning on failure and continuing.
If the container is already gone during inspect or remove, the driver still
performs idempotent volume cleanup using the request sandbox_id and
returns Ok(false) for the container-delete result. This prevents leaked
Podman resources after out-of-band container removal or label drift.
Configuration
| Environment Variable | CLI Flag | Default | Description |
|---|---|---|---|
OPENSHELL_PODMAN_SOCKET |
--podman-socket |
Probes known local Podman API sockets and uses the first responsive socket. Fails to start if none respond. | Podman API Unix socket path. |
OPENSHELL_SANDBOX_IMAGE |
--sandbox-image |
From gateway config | Default OCI image for sandboxes. |
OPENSHELL_SANDBOX_IMAGE_PULL_POLICY |
--sandbox-image-pull-policy |
missing |
Pull policy: always, missing, never, or newer. |
OPENSHELL_GRPC_ENDPOINT |
--grpc-endpoint |
Auto-detected via host.containers.internal |
Gateway gRPC endpoint for sandbox callbacks. |
OPENSHELL_GATEWAY_PORT |
--gateway-port |
17670 |
Gateway port used for endpoint auto-detection by the standalone binary. |
OPENSHELL_NETWORK_NAME |
--network-name |
openshell |
Podman bridge network name. |
OPENSHELL_PODMAN_HOST_GATEWAY_IP |
--host-gateway-ip |
empty on Linux, 192.168.127.254 on macOS |
Host gateway IP used for sandbox host aliases. Empty uses Podman's host-gateway resolver. |
OPENSHELL_SANDBOX_SSH_SOCKET_PATH |
--sandbox-ssh-socket-path |
/run/openshell/ssh.sock |
Supervisor Unix socket path in PodmanComputeConfig. |
OPENSHELL_STOP_TIMEOUT |
--stop-timeout |
10 |
Container stop timeout in seconds. |
OPENSHELL_SANDBOX_PIDS_LIMIT |
--sandbox-pids-limit |
2048 |
Podman cgroup PID limit for sandbox containers. Set 0 to inherit Podman's runtime/default PID limit. |
OPENSHELL_SUPERVISOR_IMAGE |
--supervisor-image |
ghcr.io/nvidia/openshell/supervisor:latest through the gateway, required standalone |
OCI image containing the supervisor binary. |
OPENSHELL_PODMAN_TLS_CA |
--podman-tls-ca |
unset | Host path to the CA certificate mounted for sandbox mTLS. |
OPENSHELL_PODMAN_TLS_CERT |
--podman-tls-cert |
unset | Host path to the client certificate mounted for sandbox mTLS. |
OPENSHELL_PODMAN_TLS_KEY |
--podman-tls-key |
unset | Host path to the client private key mounted for sandbox mTLS. |
OPENSHELL_SANDBOX_HTTPS_PROXY |
--sandbox-https-proxy |
unset | Corporate forward proxy URL for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Only credential-free http://host:port URLs are supported (scheme and port required). Plain-HTTP requests always dial directly. |
OPENSHELL_SANDBOX_NO_PROXY |
--sandbox-no-proxy |
unset | Comma-separated NO_PROXY list (hostnames, domain suffixes, IPs, CIDRs, each with an optional :port qualifier) dialed directly instead of through the corporate proxy. IP/CIDR entries also match hostnames through their validated DNS resolution. |
OPENSHELL_SANDBOX_PROXY_AUTH_FILE |
--sandbox-proxy-auth-file |
unset | Path to a file containing the proxy credentials as user:pass. Staged as a root-only Podman secret so credentials never appear in config or container metadata. Requires the insecure-auth acknowledgement below. |
OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE |
--sandbox-proxy-auth-allow-insecure |
unset | Explicit acknowledgement (true) that the credential is sent as cleartext Basic auth over the plain-TCP connection to the http:// proxy. Required when the auth file is set; rejected when it is not. |
OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME |
--sandbox-proxy-connect-by-hostname |
unset | Send the destination hostname in CONNECT requests instead of a validated IP. Last resort for proxies whose ACLs filter on hostnames: the proxy then resolves the name itself, so sandbox SSRF/allowed_ips validation no longer binds the connection. |
Through the gateway, the same settings are the https_proxy, no_proxy,
proxy_auth_file, proxy_auth_allow_insecure, and
proxy_connect_by_hostname keys under [openshell.drivers.podman]; see
docs/reference/gateway-config.mdx.
This is an operator-owned egress boundary: the driver passes the settings on
the supervisor's command line, so sandbox and template environment — and any
ENV baked into the sandbox image — cannot override them, and the
conventional HTTPS_PROXY/HTTP_PROXY/NO_PROXY variables a sandbox
controls do not steer it. Credentials must be supplied through
proxy_auth_file; an inline user:pass@ in the URL is rejected at startup.
Basic auth over an http:// proxy is cleartext on the wire: anyone on the
network path between the sandbox host and the proxy can recover the
credential. Setting proxy_auth_file therefore requires
proxy_auth_allow_insecure = true; both the driver and the in-container
supervisor reject credentials without that explicit acknowledgement.
CONNECT requests target a validated resolved IP by default, so the proxy
performs no DNS resolution and the tunnel stays bound to the address that
passed the sandbox's SSRF and allowed_ips checks; the hostname still
travels inside the tunnel (TLS SNI, application Host). In split-horizon
networks, point the gateway host at the corporate resolver. Set
proxy_connect_by_hostname = true only when the proxy's ACLs filter on
hostnames and reject IP CONNECT targets — it re-opens proxy-side DNS
resolution, making the proxy's ACLs the effective egress control.
Rootless-Specific Adaptations
The Podman driver is designed for rootless operation. The following adaptations matter compared to cluster or rootful runtimes:
- subuid/subgid preflight check: on non-macOS hosts,
check_subuid_range()indriver.rswarns operators if/etc/subuidor/etc/subgidentries are missing for the current user. This is not a hard error because some systems use LDAP or other mechanisms. macOS skips the check becausepodman machineruns the Podman service inside a Linux VM. - cgroups v2 requirement: the driver refuses to start if cgroups v1 is detected. Rootless Podman requires the unified cgroup hierarchy.
nsenterfor namespace operations:openshell-sandboxusesnsenter --net=instead ofip netns execto avoid the sysfs remount path that requires realCAP_SYS_ADMINin the host user namespace.DAC_READ_SEARCHcapability: required for the proxy to read/proc/<pid>/fd/across UIDs within the user namespace.SETUIDandSETGIDcapabilities: kept from Podman's default capability set sodrop_privileges()can callsetuid()andsetgid().host.containers.internal: used instead of Docker'shost.docker.internalfor container-to-host communication. The driver also injects the OpenShell-ownedhost.openshell.internalalias.- Ephemeral port publishing: the SSH compatibility port uses
host_port: 0because the bridge network IP is not reliably routable from the host in rootless mode. - tmpfs at
/run/netns: a private tmpfs lets the supervisor create named network namespaces viaip netns add.
Implementation References
- Gateway integration:
crates/openshell-server/src/compute/mod.rs(new_podmanandPodmanComputeDriverwiring). - Server configuration:
crates/openshell-server/src/lib.rs(ComputeDriverKind::PodmanbuildsPodmanComputeConfigincludingsandbox_ssh_socket_pathfrom gatewayConfig). - Gateway relay path:
openshell-coreConfig::sandbox_ssh_socket_pathincrates/openshell-core/src/config.rs. - SSRF mitigation:
crates/openshell-core/src/net.rs,crates/openshell-sandbox/src/proxy.rs, andcrates/openshell-server/src/grpc/policy.rs. - Sandbox supervisor:
crates/openshell-sandbox/src/for Landlock, seccomp, netns, proxy, and relay behavior shared by all drivers. - Container engine abstraction:
tasks/scripts/container-engine.shfor build/deploy support across Docker and Podman. - Supervisor image build:
deploy/docker/Dockerfile.supervisor.