feat(proxy): supervisor-proxied host-local endpoints — generalize the inference.local pattern for arbitrary host services
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 8.7k
- Forks
- 1.3k
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 253
Description
Problem Statement
Sandbox agents increasingly need to reach services running on the host machine — build tools, repo provisioners, MCP tool servers, development utilities. Today there are two paths, and both have significant limitations.
Path 1: Bind to 0.0.0.0 + allowed_ips. The host service binds to all interfaces and the sandbox policy declares an allowed_ips CIDR matching the host IP that the container can reach. This works but has problems:
- The service is exposed on all network interfaces — any process on any network can reach it, not just sandboxes. Token-based auth mitigates this but doesn't eliminate the attack surface.
- The IP that containers use to reach the host varies by driver and networking mode. With rootless Podman + pasta,
host.containers.internalresolves to a link-local address (169.254.1.2) that is handled by the pasta user-space proxy. With Docker, it's typically a bridge gateway (172.17.0.1). With Kubernetes, it requires explicithost_gateway_ipconfiguration. - On rootless Podman, the bridge gateway IP (e.g.,
10.88.0.1) lives inside the container namespace and cannot be bound to from the host —bind()fails withEADDRNOTAVAIL. So binding to a specific internal interface is not an option; it's0.0.0.0or nothing. EDIT:allowed_ipsmust be templated with the correct IP at runtime, adding a fragile setup step that varies per platform.allowed_ipsis no longer required when endpoints are explicitly declared with host+port (PR #1560). Validated on OpenShell v0.0.83 — see comment. The remaining motivation for this issue is the0.0.0.0exposure.- On macOS and WSL2, the bridge/gateway IP is often unreachable from the host entirely (#1358, #811).
Path 2: iptables DNAT to loopback. Forward traffic from the container-reachable IP to 127.0.0.1 using NAT rules. This requires root, the route_localnet sysctl (system-wide security implications), and completely bypasses the supervisor proxy's L7 policy enforcement.
Meanwhile, OpenShell already solves a structurally identical problem for inference: inference.local lets sandbox agents reach host-side inference endpoints through the supervisor proxy, with TLS termination, credential handling, and L7 inspection. The service binds to 127.0.0.1 and the supervisor proxies the connection from inside the sandbox network namespace. This works identically across all drivers.
But inference.local is hardcoded for inference APIs — the intercepted hostname, the HTTP pattern matching, and the route bundle format are all inference-specific. There is no equivalent mechanism for a host service that exposes a build API, a repo provisioner, or an MCP tool server.
Concrete use cases
Host process model (GitHub Actions runners). We are running an experiment (fullsend-ai/experiments#28) where two host-side services — a container builder and a repo provisioner — provide capabilities to sandbox agents via REST APIs. The environment is Linux with rootless Podman + pasta networking on GitHub Actions workers.
Today the setup must:
- Bind both servers to
0.0.0.0(can't bind to the bridge gateway IP10.88.0.1— it doesn't exist on the host in rootless mode) Template the host IP intoEDIT: No longer needed — PR #1560 removed this requirement for declared endpoints.allowed_ipsin the sandbox policy- Pass
BUILDER_URL=http://host.openshell.internal:9090to the sandbox - Rely on bearer token auth as the only access control, since the services are exposed on all interfaces
If these servers could bind to 127.0.0.1 and the supervisor proxied the connection, the setup would be platform-independent, the servers would never be network-exposed, and L7 policy enforcement would apply.
Kubernetes sidecar model. The same API servers can be deployed as sidecar containers in the same pod as the OpenShell supervisor. Since Kubernetes mode always creates a nested network namespace (NetworkMode::Proxy is hardcoded in policy.rs:102-107), the agent cannot reach the sidecar at 127.0.0.1 directly — all traffic routes through the supervisor proxy via the veth pair (10.200.0.2 → 10.200.0.1:3128). The supervisor, running in the pod's root network namespace, can reach the sidecar at 127.0.0.1. The same host-local proxying mechanism applies: the proxy intercepts the request, evaluates L7 policy, and connects to 127.0.0.1:<port> in the pod netns where the sidecar is listening.
Both models use the same mechanism — 127.0.0.1 from the supervisor's perspective — which is what makes the proposal driver-agnostic.
Related work
- #994 — sandbox ingress (the inverse problem: exposing services from the sandbox)
- #1555 — auto-populate
allowed_ipsfor declared endpoints (would reduce friction on path 1, but doesn't solve the loopback or cross-platform problems) - #1358, #811 — bridge IP unreachable on macOS/WSL2
- #23, PR #348, PR #1438 — the gateway's own bind-address journey (
0.0.0.0→127.0.0.1→ configurable per driver) - PR #1501 — managed loopback proxy (closed; opened a new listener inside the sandbox netns, which the maintainers preferred to avoid)
- #1531 — MCP L7 inspection (would benefit from this feature: MCP tool servers running on the host could be proxied with full L7 inspection)
Proposed Design
Generalize the inference.local supervisor-proxy mechanism so that policy-declared endpoints can be proxied through the supervisor to 127.0.0.1 on the host, instead of requiring the sandbox to reach the host service directly over the network.
Core mechanism
When the sandbox proxy receives a CONNECT request for a host-local endpoint:
- The proxy matches the destination against host-local endpoints declared in the active policy.
- Instead of resolving DNS and connecting to an external IP, the supervisor opens a TCP connection to
127.0.0.1:<port>on the host (the supervisor runs in the host/pod network namespace, not the sandbox netns). - The proxy relays bytes between the sandbox client and the host-local upstream, applying L7 inspection and method/path rules from the policy.
- No new listeners are opened — this reuses the existing CONNECT proxy path.
This is structurally identical to how inference.local works today, minus the inference-specific pattern matching and route bundle format. The key difference is that the routing target (127.0.0.1:<port>) comes from the policy declaration rather than from a gateway-provided inference route bundle.
Policy surface — two options
The design question is how to declare host-local endpoints in the policy schema. Two options worth considering:
Option A: Reserved hostname. Introduce a new hostname (e.g., host.local or services.local) that the proxy intercepts, distinct from host.openshell.internal:
network_policies:
builder:
name: container-builder
endpoints:
- host: host.local
port: 9090
protocol: rest
rules:
- allow:
method: POST
path: /build
- allow:
method: GET
path: /tools.json
- Clean separation:
host.openshell.internal= direct host access (existing, requiresallowed_ips);host.local= supervisor-proxied host access (new, noallowed_ipsneeded). - Agents use
http://host.local:9090/build— the hostname signals the routing path. - The proxy detects
host.locallike it detectsinference.local— hardcoded interception, no DNS resolution needed.
Option B: Flag on existing endpoints. Add a host_local: true field to the endpoint schema:
network_policies:
builder:
name: container-builder
endpoints:
- host: host.openshell.internal
port: 9090
host_local: true
protocol: rest
rules:
- allow:
method: POST
path: /build
- Reuses
host.openshell.internal— no new hostname. - The
host_localflag changes proxy behavior: instead of connecting to the driver-injected IP, connect to127.0.0.1. - Agent URLs stay
http://host.openshell.internal:9090/buildregardless of whether the endpoint is proxied or direct.
Both options have trade-offs. Option A makes the routing path visible in the URL; Option B keeps the URL stable but requires the proxy to check a flag before deciding how to connect.
What changes in the proxy
The generalizable components from the inference.local path:
- CONNECT interception (
proxy.rs:427): extend the hardcodedinference.localcheck to also match host-local endpoints from the active policy. - Connection dispatch: where
inference.localproxies to the router, host-local endpoints connect directly to127.0.0.1:<port>. No TLS termination, route bundle, or inference pattern matching needed — just TCP relay with L7 inspection. - SSRF enforcement: host-local endpoints bypass the normal SSRF tiers (they don't resolve DNS at all), similar to how
inference.localbypasses OPA network policy atproxy.rs:374. - L7 rules: the existing
method/pathrule enforcement from the OPA engine applies unchanged.
What does NOT change
- No new listeners (unlike PR #1501).
- No kernel-level changes (no iptables, no
route_localnet). - No changes to
host.openshell.internalDNS resolution or driver host-alias injection. - The existing
allowed_ipspath continues to work for users who prefer direct connectivity.
Driver compatibility
Because the supervisor always runs in the host/pod network namespace, 127.0.0.1 reaches co-located services from the supervisor's perspective. This works across drivers:
| Driver | Deployment model | 127.0.0.1 reaches |
|---|---|---|
| Podman (rootless + pasta) | Host process | Host loopback — API servers run as host processes |
| Podman (rootful) | Host process | Host loopback |
| Docker (Linux) | Host process | Host loopback |
| Kubernetes | Sidecar (same pod) | Pod loopback — API servers run as sidecar containers sharing the pod netns |
| Docker Desktop (macOS/Windows) | Host process | VM loopback — host services need Docker Desktop's host-to-VM forwarding |
| VM (gvproxy) | Host process | VM loopback — could use 192.168.127.254 (gvproxy's host-loopback NAT) instead |
Note: for Docker Desktop and VM drivers, "loopback" is the VM's loopback, not the physical host's. The design should consider whether the target address should be configurable (default 127.0.0.1, override via gateway config per driver).
Alternatives Considered
-
Bind to
0.0.0.0+allowed_ips(current approach). Works today but exposes services on all interfaces, varies by platform, and fails to bind to specific interfaces on rootless Podman. This is what we use now — it works, but the security and portability trade-offs motivate this proposal. EDIT:allowed_ipsis no longer required (PR #1560). The setup simplifies to0.0.0.0bind only. The security concern (all-interface exposure) remains and is the primary motivation for thehost.localproposal. -
iptables DNAT to loopback. Requires root,
route_localnetsysctl (system-wide — allows any network traffic to reach loopback on all interfaces), and bypasses the supervisor proxy entirely — no L7 policy enforcement. Not viable for production. -
socat/port-forwarding shim on the host. A user-space forwarder (e.g.,
socat TCP-LISTEN:9090,bind=$BRIDGE_GW,fork TCP:127.0.0.1:9090) per port. Extra process per service, still requires knowing the bridge IP, and provides no policy enforcement. -
Managed loopback proxy inside the sandbox netns (PR #1501). Opens a new listener inside the sandbox network namespace that the supervisor dispatches. Closed by maintainer with the note: "would prefer not to open any additional listeners if we can avoid it" — but acknowledged as "likely enduring sandbox primitives." The current proposal avoids new listeners by reusing the existing CONNECT proxy path.
-
Extend
inference.localwith more hardcoded hostnames. Add a second hardcoded hostname (e.g.,tools.local) with a parallel interception path. Doesn't scale — each new use case would need another hardcoded hostname and dedicated routing logic. The policy-driven approach proposed here generalizes the mechanism. -
Separate Kubernetes pod with a Service. For Kubernetes deployments, the API server could run in its own pod with a cluster Service. This works with existing policy (
host: api-server.ns.svc.cluster.local+allowed_ips) and doesn't need the host-local mechanism. However, it doesn't cover the host-process model (GitHub Actions, local dev) and adds deployment complexity compared to a sidecar.
Agent Investigation
Traced the inference.local implementation through the codebase to assess what's generalizable:
-
CONNECT interception (
crates/openshell-sandbox/src/proxy.rs:427-451): hardcoded check forinference.local:443. The interception sends a200 Connection Establishedresponse, then hands off tohandle_inference_interception()for TLS termination and HTTP parsing. This is the insertion point for host-local endpoints. -
L7 pattern matching (
crates/openshell-sandbox/src/l7/inference.rs:62-83):detect_inference_pattern()matches HTTP method + path against a hardcoded list of OpenAI/Anthropic endpoints. The matching framework (method + path glob) is generic; only the pattern list is inference-specific. Host-local endpoints would not need this — L7 rules are already defined in the policy. -
Route dispatching (
crates/openshell-router/src/lib.rs:61-99):openshell_router::Routerproxies matched requests to upstream endpoints viaResolvedRoute. This is the inference-specific routing layer — host-local endpoints would bypass it entirely and connect directly to127.0.0.1:<port>. -
SSRF enforcement tiers (
proxy.rs:572-731): three tiers — trusted gateway (link-local only, for rootless Podman + pasta),allowed_ips(CIDR allowlist), and default reject. Loopback is always blocked in all tiers (is_always_blocked_ip()incrates/openshell-core/src/net.rs:45-62). Host-local endpoints would need a new tier or a bypass similar to howinference.localbypasses OPA atproxy.rs:374. -
Policy schema (
crates/openshell-policy/src/lib.rs:91-138):NetworkEndpointDefhas generic fields —host,port,protocol(freeform string),rules(L7 method/path),allowed_ips. Addinghost_local: bool(Option B) would be a one-field schema extension. Option A (reserved hostname) would require no schema changes — the proxy would match on hostname. -
Kubernetes nested netns (
crates/openshell-sandbox/src/policy.rs:102-107,sandbox/linux/netns.rs:61-186): Kubernetes always usesNetworkMode::Proxy, creating a nested network namespace with veth pair (10.200.0.1↔10.200.0.2). The supervisor runs in the pod's root netns and can reach sidecar containers at127.0.0.1. The agent in the nested netns cannot — all traffic goes through the proxy. This confirms the sidecar model works with the same mechanism. -
Validated experimentally: ran the fullsend host-side API server experiment on Linux with rootless Podman + pasta. Servers bound to
0.0.0.0:9090and0.0.0.0:9091, sandbox agent reached them viahost.openshell.internalwith. Both the builder (allowed_ipspolicyPOST /build) and provisioner (POST /repo/provision) APIs completed successfully. The0.0.0.0bind is the workaround this proposal aims to eliminate. EDIT: Subsequently validated withoutallowed_ips— works with endpoint declaration alone (fullsend-ai/experiments#42).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in proxy.rs around the CONNECT interception at line 427 and the SSRF handling near line 374; compare this path with the existing inference.local behavior. Review the endpoint policy schema and the Kubernetes networking assumptions in policy.rs:102-107. Done means a selected policy representation routes declared host-local endpoints to 127.0.0.1 with existing L7 rules, without adding listeners or changing direct host access.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend-api-design, networking, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100