docs(rfc): update sandbox proxy adapter proposal

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>
This commit is contained in:
John Myers
2026-06-26 09:46:49 -07:00
parent f565618e16
commit 03d25482b0
6 changed files with 801 additions and 641 deletions
@@ -1,420 +0,0 @@
---
authors:
- "@johntmyers"
state: draft
links:
- https://github.com/NVIDIA/OpenShell/issues/1107
- https://github.com/NVIDIA/OpenShell/pull/1083
- https://github.com/NVIDIA/OpenShell/pull/1151
---
# RFC 0004 - Sandbox Proxy Egress Adapter Model
<!--
See rfc/README.md for the full RFC process and state definitions.
-->
## Summary
Refactor sandbox egress around one shared authorization and relay pipeline.
CONNECT, forward HTTP proxy, transparent native TCP, policy DNS,
`inference.local`, and `policy.local` should become adapters that translate
userland entry points into a common egress intent. Policy evaluation,
destination validation, credential injection, request-body rewrite,
WebSocket upgrade handling, protocol parsing, and relay ownership should happen
behind shared boundaries.
This RFC keeps the main direction in this document. Supporting detail lives in:
- [Current shape appendix](current-shape.md)
- [Technical design appendix](technical-design.md)
- [Implementation plan](implementation-plan.md)
## Motivation
The sandbox proxy has accumulated separate egress paths for CONNECT, forward
HTTP, local services, inference routing, endpoint metadata, credential
injection, and L7 policy. That makes security changes easy to apply to one path
and miss in another.
The target shape separates three concerns:
- **Adapters** describe how userland reached the proxy.
- **Authorization** decides whether that egress is allowed and what endpoint
behavior applies.
- **Relays** own bytes, credentials, protocol parsing, and upstream dialing.
## Non-goals
- Replace CONNECT with forward proxy as the only explicit proxy mode.
- Add SOCKS support.
- Add HTTP/2 L7 parsing in this refactor.
- Redesign provider credential storage.
- Reintroduce iptables as the sandbox packet filtering backend.
- Use eBPF connect hooks for transparent capture. Native TCP capture needs a
userland proxy in the byte stream for TLS termination and protocol parsing.
## Proposal
### Migration Big Rocks
1. **Transport adapters.** CONNECT, forward HTTP, transparent TCP, policy DNS,
and local service routes become small entry adapters. They parse their
surface and produce either an egress intent, a local response, or a DNS
answer. They do not duplicate policy evaluation.
2. **Egress intent and decision.** The shared authorization boundary evaluates
L4 policy once per connection intent and returns one decision containing the
matched policy, matched endpoint, process identity, allowed IP metadata, TLS
behavior, and protocol enforcement.
3. **Relays.** Relays receive an authorized destination connector, not an
already-open upstream socket. HTTP relays evaluate every request before
dialing, own REST request-body credential rewrite, and hand allowed
WebSocket upgrades to the WebSocket relay. TCP application parsers own their
protocol loop and decide when a validated upstream connection is needed.
### Unified Adapter Flow
```mermaid
flowchart TD
User["Userland payload / harness"]
subgraph ExplicitProxy["Explicit proxy listener"]
ProxyBytes["HTTP proxy bytes"]
IsConnect{"CONNECT request?"}
Connect["CONNECT adapter"]
Forward["Forward HTTP adapter"]
ProxyBytes --> IsConnect
IsConnect -- Yes --> Connect
IsConnect -- No --> Forward
end
subgraph NativeTcp["Policy DNS + native TCP"]
NameLookup["Userland DNS lookup"]
PolicyDns["Policy DNS adapter"]
DnsAnswer["DNS answer"]
NativeConnect["Userland connect(ip:port)"]
TcpAdapter["Transparent TCP adapter"]
NameLookup --> PolicyDns
PolicyDns --> DnsAnswer
DnsAnswer --> NativeConnect
NativeConnect --> TcpAdapter
end
subgraph LocalApis["Sandbox-local services"]
InferenceReq["Request to inference.local"]
PolicyReq["Request to policy.local"]
InferenceAdapter["Inference local adapter"]
PolicyAdapter["Policy local adapter"]
InferenceReq --> InferenceAdapter
PolicyReq --> PolicyAdapter
end
subgraph Shared["Shared egress pipeline"]
Intent["Egress intent"]
Auth["Authorize and select endpoint"]
Decision["Egress decision"]
Validate["Resolve and validate destination"]
Relay["Relay"]
Deny["Adapter-specific deny response"]
Intent --> Auth
Auth --> Allowed{"Allowed?"}
Allowed -- No --> Deny
Allowed -- Yes --> Decision
Decision --> Validate
Validate --> Relay
end
User --> ProxyBytes
User --> NameLookup
User --> NativeConnect
User --> InferenceReq
User --> PolicyReq
Connect --> Intent
Forward --> Intent
TcpAdapter --> Intent
InferenceAdapter --> InferenceResp["Local inference response"]
PolicyAdapter --> PolicyResp["Local policy response"]
```
### Relay Flow
```mermaid
flowchart TD
Start["Authorized egress + destination connector"]
Start --> HasFirst{"First HTTP request already parsed?"}
HasFirst -- Yes --> ForwardMode{"Selected enforcement"}
ForwardMode -- "L4 only" --> HttpCred["HTTP relay<br/>credential injection only"]
ForwardMode -- "HTTP rules" --> HttpL7["HTTP relay<br/>REST/GraphQL/WebSocket policy"]
ForwardMode -- "TCP app rules" --> BadForward["Deny: HTTP request for TCP app endpoint"]
HasFirst -- No --> Inspect["Inspect tunnel or native stream bytes"]
Inspect --> SkipTls{"Endpoint says skip TLS handling?"}
SkipTls -- Yes --> TcpBytes["TCP relay<br/>byte copy"]
SkipTls -- No --> Peek["Peek client bytes"]
Peek --> IsTls{"TLS ClientHello?"}
IsTls -- Yes --> Tls["Shared TLS terminator"]
IsTls -- No --> Readable["Readable client stream"]
Tls --> Readable
Readable --> Mode{"Selected enforcement"}
Mode -- "L4 only" --> SniffHttp{"Looks like HTTP?"}
SniffHttp -- Yes --> HttpCred
SniffHttp -- No --> TcpBytes
Mode -- "HTTP rules" --> MustHttp{"Looks like HTTP?"}
MustHttp -- Yes --> HttpL7
MustHttp -- No --> DenyHttp["Deny: expected HTTP"]
Mode -- "TCP app rules" --> TcpParser["TCP relay<br/>application parser owns loop"]
HttpCred --> Creds["Resolve and redact credentials"]
HttpL7 --> CredsL7["Resolve and redact credentials"]
CredsL7 --> ParseHttp["Parse and evaluate each HTTP request"]
ParseHttp --> HttpAllowed{"Request allowed?"}
HttpAllowed -- No --> HttpDeny["Local HTTP deny<br/>no upstream connect"]
HttpAllowed -- Yes --> Rewrite["Rewrite configured credential slots"]
Creds --> Rewrite
Rewrite --> HttpDial["Connect or reuse upstream"]
HttpDial --> HttpResponse["Write request and relay response"]
HttpResponse --> Upgrade{"101 WebSocket upgrade?"}
Upgrade -- No --> NextHttp["Continue HTTP request loop"]
Upgrade -- Yes --> WsMode{"WebSocket inspection needed?"}
WsMode -- No --> RawUpgrade["Raw upgraded stream"]
WsMode -- Yes --> WsRelay["WebSocket relay<br/>text-frame rewrite / message policy"]
NextHttp --> ParseHttp
TcpParser --> ParserDial["Parser dials upstream when protocol allows"]
TcpBytes --> TcpDial["Connect upstream"]
TcpDial --> ByteCopy["Copy bytes"]
```
Relay rules:
- HTTP credential injection happens in both HTTP modes: L4-only HTTP and
HTTP-inspected.
- Credential injection includes request target, query, headers, opt-in REST
request bodies, and opt-in client-to-server WebSocket text frames.
- HTTP L7 policy is evaluated before upstream dial for each request.
- WebSocket upgrade policy is evaluated as HTTP first. After an allowed `101`
upgrade, the WebSocket relay owns frame parsing when text-frame credential
rewrite, WebSocket transport policy, GraphQL-over-WebSocket policy, or safe
compression handling is configured. Other upgraded streams remain raw.
- Forward HTTP must stay in the shared HTTP relay loop. It must not evaluate
one request and then switch to raw bidirectional copy. Keeping forward HTTP
single-request with `Connection: close` is also acceptable, but the invariant
is that no follow-on request bytes reach upstream unevaluated.
- `protocol: tcp` means L4 authorization plus byte copy unless HTTP is detected
for credential injection.
- Future TCP application parsers, such as Redis or Postgres, own the full
message loop and can parse multiple commands over one TCP session.
### CONNECT Adapter
CONNECT remains the standard explicit proxy tunnel for HTTPS and arbitrary TCP.
It parses the CONNECT line into an egress intent, then waits for the shared
relay to decide if and when an upstream connection should be opened.
```mermaid
flowchart TD
Client["Client sends CONNECT host:port"] --> Parse["Parse target"]
Parse --> Intent["Build egress intent"]
Intent --> Auth["Shared authorization"]
Auth --> Allowed{"Destination allowed?"}
Allowed -- No --> Deny["CONNECT deny response"]
Allowed -- Yes --> Ready["Return tunnel-ready response"]
Ready --> Relay["Relay inspects tunneled bytes"]
Relay --> Dial["Relay or parser connects upstream when allowed"]
```
CONNECT should stay because forward proxy is only a plaintext HTTP request
format. CONNECT is still the generic explicit proxy mode for TLS and non-HTTP
TCP clients.
### Forward HTTP Adapter
Forward HTTP is compatibility for clients that send absolute-form HTTP requests.
The adapter parses the first request and hands it to the shared HTTP relay or
an equivalent guarded single-request relay.
```mermaid
flowchart TD
Req["Absolute-form HTTP request"] --> Parse["Parse URI and first request"]
Parse --> Intent["Build egress intent"]
Intent --> Auth["Shared authorization"]
Auth --> Allowed{"Allowed?"}
Allowed -- No --> Deny["HTTP deny response"]
Allowed -- Yes --> Relay["Shared or guarded HTTP relay"]
Relay --> Mode{"Connection mode"}
Mode -- "Persistent" --> Loop["Evaluate every request on this connection"]
Mode -- "Single request" --> Close["Force Connection: close"]
```
### Transparent TCP Adapter
Transparent TCP supports native clients that do not know they are using a
proxy. The capture mechanism should be network namespace interception into a
userland proxy listener. Since main now uses nftables for sandbox bypass
enforcement, transparent capture should be designed as nftables
REDIRECT/TPROXY state in the inner sandbox network namespace, not as an
iptables path.
```mermaid
flowchart TD
Policy["Policy load / reload"] --> Register["Register native TCP names"]
Lookup["Userland DNS lookup"] --> Dns["Policy DNS adapter"]
Register --> Dns
Dns --> Answer["Return approved IPs"]
Answer --> Capture["Enable capture for active IP:port"]
Connect["Userland connect(ip:port)"] --> Capture
Capture --> Adapter["Transparent TCP adapter"]
Adapter --> Intent["Build egress intent from original destination"]
Intent --> Shared["Shared authorization and relay"]
```
### Policy DNS
Policy DNS replaces static `/etc/hosts` snapshots for native TCP names. It is
query-driven: check whether the name is policy-eligible, resolve through trusted
DNS, filter returned IPs, publish the active endpoint mapping, and answer
userland.
```mermaid
flowchart TD
Query["DNS query from userland"] --> Adapter["Policy DNS adapter"]
Adapter --> Known{"Registered native TCP policy name?"}
Known -- No --> Refuse["NXDOMAIN / REFUSED / SERVFAIL"]
Known -- Yes --> Upstream["Trusted upstream DNS lookup"]
Upstream --> Filter["Filter answers against endpoint policy"]
Filter --> Publish["Publish active mapping and capture rule"]
Publish --> Answer["DNS answer"]
```
The later `connect(ip:port)` still creates the egress intent and runs through
normal authorization.
### Network Enforcement Substrate
Current main uses nftables for bypass enforcement. It accepts proxy-bound
traffic and loopback, accepts established flows, then rejects and optionally
logs other TCP/UDP traffic for the bypass monitor. That is enforcement, not
native TCP capture.
```mermaid
flowchart TD
Conn["Userland packet"] --> ProxyDest{"Proxy destination?"}
ProxyDest -- Yes --> AcceptProxy["nftables accept"]
ProxyDest -- No --> Capture{"Future native TCP capture match?"}
Capture -- Yes --> Redirect["nftables redirect/TPROXY to transparent adapter"]
Capture -- No --> Reject["nftables log + reject bypass"]
Reject --> Monitor["Bypass monitor emits OCSF"]
```
The transparent TCP work should extend this nftables model with explicit
capture rules that run before the reject path and are scoped to active policy
DNS mappings.
### Local Service Adapters
`inference.local` and `policy.local` are sandbox-local APIs. They should use
the adapter model, but they do not represent normal external egress.
```mermaid
flowchart TD
A["Request to inference.local"] --> B["Inference local adapter"]
B --> C{"TLS and inference context available?"}
C -- No --> D["Local denial and log"]
C -- Yes --> E["Terminate client TLS"]
E --> F["Parse HTTP request"]
F --> G{"Known inference route?"}
G -- Yes --> H["Route through openshell-router"]
H --> I["Strip caller auth and inject provider auth/model"]
I --> J["Stream response with limits"]
G -- No --> K["403 and close"]
```
```mermaid
flowchart TD
A["Request to policy.local"] --> B["Policy local adapter"]
B --> C{"Local route"}
C -- "Current policy" --> D["Policy snapshot response"]
C -- "Recent denials" --> E["Bounded denial summaries"]
C -- "Policy proposal" --> F["Validate and submit proposal"]
D --> G["HTTP response"]
E --> G
F --> G
```
### Deployment Modes
The first implementation can remain embedded in `openshell-sandbox`, but the
proxy should be shaped around explicit runtime contracts.
| Mode | Shape | Main concern |
|------|-------|--------------|
| Embedded | Current sandbox process owns proxy modules | Lowest migration cost |
| Standalone process | Sandbox supervisor launches a proxy binary | Clear process/API boundary |
| Sidecar | Proxy runs outside the payload container but inside the sandbox boundary | Reliable process identity across namespaces |
A pluggable proxy must expose the configured userland surfaces, implement the
gateway APIs it needs, and prove equivalent policy enforcement through tests.
The nftables rules that force or reject userland traffic belong to the sandbox
network boundary even if the proxy process later moves into a standalone binary
or sidecar.
## Implementation plan
The migration plan lives in [implementation-plan.md](implementation-plan.md).
The intended order is: first add regression coverage, then introduce the shared
authorization result and destination validation, then preserve the current
forward HTTP single-request/guarded-relay invariant, then add shared TLS
handling, TCP parser boundaries, nftables-backed policy DNS capture, local
service adapters, and finally the runtime boundary cleanup.
## Risks
- Tightening endpoint metadata failures from fail-open to deny may expose
latent policy or Rego errors.
- Deterministic endpoint selection may reject policies that currently load but
only work by accident.
- Transparent TCP capture adds network namespace interception complexity.
- Transparent TCP capture must coexist with the current nftables bypass
reject/log table without creating gaps where direct egress bypasses the proxy.
- Sidecar mode needs a reliable identity source for binary/path scoped policy.
- `policy.local` expands the sandbox-local control surface and needs strict
route validation, body limits, redaction, and gateway authentication.
## Alternatives
- Keep patching each current proxy path separately. This has the lowest short
term cost but keeps the security surface duplicated.
- Replace CONNECT with forward proxy. This does not work for arbitrary TCP and
is not a replacement for HTTPS tunnels.
- Build only transparent TCP. This helps native clients but does not replace
explicit proxy support used by common HTTP tooling.
## Open questions
1. Should overlapping endpoint metadata be rejected at policy load time, or
should policy name plus endpoint index define precedence?
2. Should missing TLS state fail closed for credential-capable or inspected
endpoints?
3. Should direct IP connects to a policy-DNS-resolved TCP endpoint be accepted,
or should DNS query correlation be required for stricter modes?
4. What TTL cap and stale-generation grace period should policy DNS use?
5. Which process identity source should sidecar mode use when it cannot inspect
payload process metadata through local `/proc`?
6. Which proxy capabilities should be negotiated with the gateway at startup?
## Expected result
Adding a new HTTP-family protocol parser should require parser code, policy
schema/Rego support, tests, and docs. It should not require new CONNECT and
forward-proxy branches. REST, GraphQL, WebSocket upgrade policy, request-body
credential rewrite, and WebSocket text-frame rewrite should all remain behind
the shared HTTP/WebSocket relay boundary.
Adding a native TCP application parser should require policy DNS/capture
support, a TCP application parser, policy rules, tests, and docs. Plain
`protocol: tcp` remains L4 authorization plus byte relay.
@@ -1,167 +0,0 @@
# Current Shape Appendix
This appendix records the current proxy shape and the review findings that
motivate the adapter model. The main RFC intentionally keeps these details out
of the direction document.
## Current Entry Points
The sandbox proxy currently handles multiple userland-facing paths in the same
large module:
- CONNECT proxy traffic for HTTPS and generic TCP tunnels.
- Forward HTTP proxy traffic for absolute-form HTTP requests.
- Local service routes such as `inference.local`.
- Network namespace bypass enforcement through nftables reject/log rules.
- Policy and endpoint metadata lookups through OPA/Rego.
- DNS resolution and endpoint validation for CONNECT and forward HTTP egress.
- Credential injection and redaction for provider-backed HTTP egress.
- Opt-in REST request-body credential rewrite.
- L7 REST, GraphQL, WebSocket, and GraphQL-over-WebSocket enforcement.
The issue is not that these features exist. The issue is that entry mechanisms,
policy evaluation, endpoint metadata lookup, credential injection, and byte
relay decisions are interleaved.
## Current CONNECT Shape
```mermaid
flowchart TD
Client["Client CONNECT host:port"] --> Parse["Parse CONNECT target"]
Parse --> L4["Evaluate network policy"]
L4 --> Allowed{"Allowed?"}
Allowed -- No --> Deny["CONNECT denial"]
Allowed -- Yes --> Meta["Query endpoint metadata"]
Meta --> Config{"L7 or credential config?"}
Config -- No --> Raw["Open upstream and copy bytes"]
Config -- Yes --> Tunnel["Return tunnel-ready response"]
Tunnel --> Inspect["Parse tunneled HTTP when possible"]
Inspect --> L7["Evaluate HTTP policy"]
L7 --> Inject["Inject credentials if configured"]
Inject --> Upstream["Write upstream and relay response"]
```
This path has the strongest HTTP relay behavior because it can keep parsing
requests on a long-lived tunnel and enforce L7 rules per request.
## Current Forward HTTP Shape
```mermaid
flowchart TD
Client["Absolute-form HTTP request"] --> Parse["Parse first request"]
Parse --> L4["Evaluate network policy"]
L4 --> Allowed{"Allowed?"}
Allowed -- No --> Deny["HTTP denial"]
Allowed -- Yes --> L7{"Matching L7 endpoint?"}
L7 -- Yes --> Eval["Evaluate REST/GraphQL/WebSocket policy"]
Eval --> Rewrite["Rewrite to origin-form + configured credentials"]
L7 -- No --> Rewrite
Rewrite --> Close["Force Connection: close except WebSocket upgrade"]
Close --> Upstream["Open upstream"]
Upstream --> Relay["Guarded HTTP relay / upgrade relay"]
```
The latest main branch no longer has the old raw-copy-after-first-request shape
for ordinary forward HTTP. It rewrites ordinary requests with `Connection:
close`, uses guarded HTTP relay helpers for body handling, and sends allowed
WebSocket upgrades through the same upgrade relay. That is a narrower surface
than the historical bidirectional copy, but it is still implemented separately
from the CONNECT relay path.
## Current Network Namespace Enforcement
```mermaid
flowchart TD
Start["Process in sandbox network namespace"] --> Dest{"Destination"}
Dest -- "Proxy host_ip:port" --> Proxy["Accept to sandbox proxy"]
Dest -- "Loopback" --> Loopback["Accept loopback"]
Dest -- "Established/related" --> Established["Accept response packet"]
Dest -- "Other TCP/UDP" --> Reject["nftables log + reject"]
Reject --> Monitor["Bypass monitor reads dmesg"]
Monitor --> OCSF["OCSF network + detection events"]
```
The sandbox now installs an `inet` nftables filter table for bypass
enforcement. The table accepts proxy-bound traffic, loopback, and established
flows, then rejects and optionally logs other TCP/UDP traffic. It does not
currently redirect native TCP connections into the proxy.
## Current Local Service Shape
```mermaid
flowchart TD
Request["Request to local name"] --> Match{"Known local route?"}
Match -- "inference.local" --> Inference["Inference routing logic"]
Match -- "policy.local" --> Policy["Policy local logic"]
Match -- No --> External["Normal egress path"]
Inference --> LocalResponse["Local response"]
Policy --> LocalResponse
```
Local routes are userland-facing proxy surfaces. They should stay distinct from
external egress while still fitting the adapter model.
## Findings To Preserve
### Invariant: forward proxy must not relay unevaluated follow-on HTTP bytes
The historical forward path evaluated at most the first absolute-form request,
rewrote it, then switched to bidirectional copy. Bytes already buffered after
the first header block, or later pipelined requests on the same client/upstream
connection, could reach upstream without the CONNECT L7 relay's per-request
parser/evaluator.
Latest main mitigates this by forcing ordinary forward HTTP to one request per
connection and by using guarded relay helpers. The adapter model should
preserve the invariant either by keeping forward HTTP single-request/close or
by passing the first parsed request into a shared HTTP relay loop.
### Endpoint config is not tied to deterministic matched policy
The policy name used for L4 authorization and logging can be selected through a
different precedence rule than endpoint metadata. With overlapping host, port,
and binary rules, allowed IPs, TLS behavior, enforcement, and
`allow_encoded_slash` can come from a different endpoint than the policy name
logged and used for L4 allow.
The adapter model requires authorization to return one decision with one
deterministic matched endpoint.
### Endpoint metadata query failures fail open to L4 behavior
If endpoint metadata lookup fails, callers can interpret the result as no L7
configuration and downgrade to credential-only or raw L4 relay.
The adapter model treats endpoint metadata as part of the authorization result.
Failure to materialize required metadata should deny rather than erase extended
configuration.
### Control-plane port block only applies on one resolution path
Blocked control-plane ports are enforced inside one allowed-IPs validation
path, while the normal host-based path uses a different validation route.
The adapter model moves resolution, allowed IP checks, SSRF checks, and
control-plane port blocks into shared destination validation.
## Existing Feature Inventory
The refactor should preserve:
- CONNECT explicit proxy support.
- Forward HTTP explicit proxy support.
- nftables bypass reject/log enforcement.
- Provider credential injection and redaction.
- REST request-body credential rewrite.
- WebSocket text-frame credential rewrite.
- REST endpoint method/path policy.
- GraphQL L7 policy.
- WebSocket transport and GraphQL-over-WebSocket policy.
- Inference routing through `inference.local`.
- Agent-facing policy routes through `policy.local`.
- Timeout and resource tracking for client, upstream, and local service work.
- Structured OCSF logging for network and HTTP policy outcomes.
- SSRF and internal address protections.
- Control-plane port protection.
- `allowed_ips` endpoint restrictions.
- TLS termination for inspectable client connections.
@@ -0,0 +1,404 @@
---
authors:
- "@johntmyers"
state: draft
links:
- https://github.com/NVIDIA/OpenShell/issues/1107
- https://github.com/NVIDIA/OpenShell/pull/1083
- https://github.com/NVIDIA/OpenShell/pull/1151
- https://github.com/NVIDIA/OpenShell/pull/1286
- https://github.com/NVIDIA/OpenShell/pull/1511
---
# RFC 0005 - Sandbox Proxy Egress Adapter Model
<!--
See rfc/README.md for the full RFC process and state definitions.
-->
## Summary
Refactor sandbox egress around one shared authorization and relay pipeline.
CONNECT, forward HTTP, native TCP capture, policy DNS, `inference.local`,
`policy.local`, and metadata loopback should become narrow adapters that
translate userland entry points into common runtime intents. Policy evaluation,
destination validation, credential injection, request-body rewrite, WebSocket
handling, protocol parsing, and upstream dialing should happen behind shared
boundaries.
The codebase has already moved in this direction by splitting networking into
`openshell-supervisor-network` and process/netns work into
`openshell-supervisor-process`. This RFC proposes the next internal boundary:
make proxy entry mechanisms pluggable without duplicating authorization,
destination validation, or relay behavior.
Supporting detail lives in:
- [Current shape appendix](current-shape.md)
- [Technical design appendix](technical-design.md)
- [Implementation plan](implementation-plan.md)
## Motivation
The sandbox proxy supports several connection surfaces: explicit CONNECT,
forward HTTP, local inference and policy APIs, metadata loopback, TLS
termination, REST and GraphQL inspection, WebSocket inspection, credential
injection, and nftables-backed bypass detection. These features are valuable,
but changes to policy and enforcement still tend to touch multiple entry paths.
The risk is asymmetric enforcement. A security fix can be added to CONNECT and
missed in forward HTTP; endpoint metadata can be selected differently from the
logged policy; a credential path can gain request-body or WebSocket support
without the same behavior existing in another relay mode.
The target shape separates three concerns:
- **Adapters** describe how userland reached the networking component.
- **Authorization** decides whether the egress is allowed and what endpoint
behavior applies.
- **Relays** own bytes, credentials, protocol parsing, and upstream dialing.
This also prepares the proxy for future deployment modes. Today the proxy runs
inside the sandbox supervisor process. The networking leaf can already run in a
network-only mode, and a future standalone binary or sidecar should be possible
if it implements the same userland surfaces, gateway APIs, and policy
enforcement contracts.
## Non-goals
- Replace CONNECT with forward proxy as the only explicit proxy mode.
- Add SOCKS support.
- Add HTTP/2 L7 parsing in this refactor. Inspected HTTP paths should continue
to reject unsupported h2c upgrades instead of silently upgrading to raw
traffic.
- Redesign provider credential storage.
- Reintroduce iptables as the sandbox packet filtering backend.
- Use eBPF connect hooks for transparent capture. Native TCP capture needs a
userland proxy in the byte stream for TLS termination and protocol parsing.
## Proposal
### Migration Big Rocks
1. **Transport and local-service adapters.** CONNECT, forward HTTP,
transparent TCP, policy DNS, `inference.local`, `policy.local`, and metadata
loopback become small adapters. They parse their surface and produce either
an egress intent, a local response, or a DNS answer. They do not duplicate
policy evaluation.
2. **Egress intent and decision.** Shared authorization evaluates L4 policy and
endpoint selection once per connection intent and returns one decision
containing the matched policy, matched endpoint, process identity, allowed
IP metadata, TLS behavior, protocol enforcement, and credential injection
plan.
3. **Relays.** Relays receive an authorized destination connector, not an
already-open upstream socket. HTTP relays evaluate every request before
upstream write. TCP relays copy bytes for L4-only endpoints or hand the
stream to a protocol parser when endpoint policy requires native protocol
enforcement.
### Unified Adapter Flow
```mermaid
flowchart TD
User["Userland payload / harness"]
subgraph ExplicitProxy["Explicit proxy listener"]
ProxyBytes["HTTP proxy bytes"]
IsConnect{"CONNECT request?"}
Connect["CONNECT adapter"]
Forward["Forward HTTP adapter"]
ProxyBytes --> IsConnect
IsConnect -- Yes --> Connect
IsConnect -- No --> Forward
end
subgraph NativeTcp["Policy DNS + native TCP"]
NameLookup["Userland DNS lookup"]
PolicyDns["Policy DNS adapter"]
DnsAnswer["DNS answer + active mapping"]
NativeConnect["Userland connect(ip:port)"]
TcpAdapter["Transparent TCP adapter"]
NameLookup --> PolicyDns
PolicyDns --> DnsAnswer
DnsAnswer --> NativeConnect
NativeConnect --> TcpAdapter
end
subgraph LocalApis["Sandbox-local services"]
InferenceReq["Request to inference.local"]
PolicyReq["Request to policy.local"]
MetadataReq["Request to metadata loopback"]
InferenceAdapter["Inference local adapter"]
PolicyAdapter["Policy local adapter"]
MetadataAdapter["Metadata loopback adapter"]
InferenceReq --> InferenceAdapter
PolicyReq --> PolicyAdapter
MetadataReq --> MetadataAdapter
end
subgraph Shared["Shared external egress pipeline"]
Intent["EgressIntent"]
Auth["Authorize and select endpoint"]
Decision["EgressDecision"]
Validate["Resolve and validate destination"]
Relay["Relay"]
Deny["Adapter-specific deny response"]
Intent --> Auth
Auth --> Allowed{"Allowed?"}
Allowed -- No --> Deny
Allowed -- Yes --> Decision
Decision --> Validate
Validate --> Relay
end
User --> ProxyBytes
User --> NameLookup
User --> NativeConnect
User --> InferenceReq
User --> PolicyReq
User --> MetadataReq
Connect --> Intent
Forward --> Intent
TcpAdapter --> Intent
InferenceAdapter --> InferenceResp["Local inference response"]
PolicyAdapter --> PolicyResp["Local policy response"]
MetadataAdapter --> MetadataResp["Local metadata credential response"]
```
Each adapter still owns its response shape. If authorization denies a CONNECT
intent, the CONNECT adapter returns a tunnel denial. If forward HTTP is denied,
the forward adapter returns an HTTP denial. If policy DNS refuses a name, it
returns the appropriate DNS response. The shared layer decides the outcome; the
adapter renders it for its protocol.
### Relay Flow
```mermaid
flowchart TD
Start["Authorized egress + destination connector"]
Start --> FirstReq{"First HTTP request already parsed?"}
FirstReq -- Yes --> ForwardMode{"decision.endpoint.enforcement"}
ForwardMode -- "None" --> HttpCred["HTTP relay<br/>credential injection only"]
ForwardMode -- "Http" --> HttpL7["HTTP relay<br/>REST/GraphQL/WebSocket policy"]
ForwardMode -- "TcpApplication" --> BadForward["Deny: HTTP request for TCP app endpoint"]
FirstReq -- No --> TlsPolicy{"TLS handling skipped?"}
TlsPolicy -- Yes --> Readable["Readable client stream"]
TlsPolicy -- No --> Peek["Peek client bytes"]
Peek --> Tls{"TLS ClientHello?"}
Tls -- Yes --> Terminate["Shared TLS terminator"]
Tls -- No --> Readable
Terminate --> Readable
Readable --> Enforce{"decision.endpoint.enforcement"}
Enforce -- "None" --> Sniff{"Looks like HTTP?"}
Sniff -- Yes --> HttpCred
Sniff -- No --> TcpRelay["TcpRelay<br/>byte copy"]
Enforce -- "Http" --> MustHttp{"Looks like HTTP?"}
MustHttp -- Yes --> HttpL7
MustHttp -- No --> DenyHttp["Deny: expected HTTP"]
Enforce -- "TcpApplication" --> TcpParser["TcpRelay<br/>protocol parser owns loop"]
HttpCred --> ReqLoop["HTTP request loop"]
HttpL7 --> ReqLoop
ReqLoop --> ReqPolicy{"Request allowed?"}
ReqPolicy -- No --> ReqDeny["Local HTTP deny<br/>no upstream write"]
ReqPolicy -- Yes --> StaticCreds["Resolve static placeholders"]
StaticCreds --> TokenGrant["Apply endpoint token grant if configured"]
TokenGrant --> Rewrite["Rewrite configured credential slots"]
Rewrite --> HttpDial["Connect or reuse upstream"]
HttpDial --> HttpResponse["Write request and relay response"]
HttpResponse --> Upgrade{"101 WebSocket upgrade?"}
Upgrade -- No --> ReqLoop
Upgrade -- Yes --> WsInspect{"WebSocket inspection or rewrite configured?"}
WsInspect -- No --> RawUpgrade["Raw upgraded stream"]
WsInspect -- Yes --> WsRelay["WebSocket relay<br/>text-frame rewrite / message policy"]
TcpParser --> ParserDial["Parser calls connector when protocol allows"]
TcpRelay --> TcpDial["Connect upstream"]
TcpDial --> ByteCopy["Copy bytes"]
```
Relay rules:
- HTTP credential injection happens in both HTTP modes: L4-only HTTP and
HTTP-inspected.
- Credential injection includes static placeholder rewrite and endpoint-bound
dynamic token grants. Token grants run after policy allow and before upstream
write; failures deny without forwarding the request.
- Static credential rewrite covers request target, query, headers, opt-in REST
request bodies, and opt-in client-to-server WebSocket text frames.
- HTTP L7 policy is evaluated before upstream write for each request.
- WebSocket upgrade policy is evaluated as HTTP first. After an allowed `101`
upgrade, the WebSocket relay owns frame parsing when text-frame credential
rewrite, WebSocket transport policy, GraphQL-over-WebSocket policy, or safe
compression handling is configured. Other upgraded streams remain raw.
- Forward HTTP must stay in the shared HTTP relay loop or in an equivalent
guarded single-request relay. It must not evaluate one request and then
switch to raw bidirectional copy.
- `protocol: tcp` or an omitted protocol means L4 authorization plus byte copy,
except that HTTP-looking streams may still use HTTP credential injection.
- Future native protocol parsers, such as Redis, Postgres, or MySQL, own the
full message loop and can parse multiple commands or queries on one TCP
session.
### Adapter Responsibilities
CONNECT remains the generic explicit proxy mode for HTTPS and arbitrary TCP.
The CONNECT adapter parses `CONNECT host:port` into an `EgressIntent`, asks the
shared authorization boundary for an `EgressDecision`, returns the tunnel-ready
response only after the connection is allowed, and then hands the tunnel to the
relay. The upstream connection is opened by the HTTP relay or TCP parser when
payload policy allows it.
Forward HTTP is compatibility for clients that send absolute-form HTTP
requests. The adapter parses the first request, rewrites proxy framing only at
the relay boundary, rejects `https://` absolute-form requests, rejects
unsupported h2c upgrades on inspected routes, and either stays in a shared HTTP
request loop or forces `Connection: close` for a guarded single request.
Transparent TCP is for native clients that do not know they are using a proxy.
It depends on policy DNS and nftables capture: DNS answers create active
endpoint mappings, userland later calls `connect(ip:port)`, nftables redirects
matching traffic to a userland listener, and the TCP adapter recovers the
original destination before building an intent.
Policy DNS replaces static `/etc/hosts` snapshots for native TCP names. It is
query-driven: check whether the name is policy-eligible, resolve through
trusted DNS, filter returned IPs, publish the active endpoint mapping, and
answer userland. The later `connect(ip:port)` still runs through normal
authorization.
Local service adapters stay outside the normal external egress relay:
`inference.local` routes chat, completion, model discovery, embeddings, and
provider-specific inference traffic through the router with local limits;
`policy.local` exposes current policy, denial summaries, proposal submission,
and proposal wait routes; metadata loopback serves provider metadata
credentials to SDKs that bypass HTTP proxy variables.
### Network Enforcement Substrate
Current main uses nftables for sandbox bypass enforcement. It accepts
proxy-bound traffic, loopback, and established flows, then rejects and
optionally logs other TCP/UDP traffic for the bypass monitor. That is
enforcement, not native TCP capture.
```mermaid
flowchart TD
Packet["Userland packet"] --> ProxyDest{"Proxy destination?"}
ProxyDest -- Yes --> AcceptProxy["nftables accept"]
ProxyDest -- No --> Capture{"Future native TCP capture match?"}
Capture -- Yes --> Redirect["nftables redirect/TPROXY to transparent adapter"]
Capture -- No --> Reject["nftables log + reject bypass"]
Reject --> Monitor["Bypass monitor emits OCSF"]
```
Transparent TCP work should extend this nftables model with explicit capture
rules that run before the reject path and are scoped to active policy DNS
mappings. It should not add a parallel iptables path.
### Deployment Modes
| Mode | Shape | Status |
|------|-------|--------|
| Embedded supervisor | `openshell-sandbox` orchestrates `openshell-supervisor-network` and `openshell-supervisor-process` | Current |
| Network-only supervisor | Networking, policy, proxy, local services, and background tasks run without a payload process leaf | Current runtime mode |
| Standalone proxy binary | Supervisor launches networking as a separate process with explicit APIs | Future packaging/API work |
| Sidecar proxy | Proxy runs outside the payload container but inside the sandbox boundary | Future isolation mode |
A pluggable proxy must expose the right userland surfaces, implement the
gateway APIs it needs, and prove equivalent policy enforcement through tests.
The nftables rules that force or reject userland traffic belong to the sandbox
network boundary even if the proxy process later moves into a standalone binary
or sidecar.
## Implementation plan
The detailed migration plan lives in [implementation-plan.md](implementation-plan.md).
The intended order is:
1. Add regression coverage around the current split, forward HTTP invariants,
endpoint selection, token grants, WebSocket/body rewrite, metadata loopback,
and nftables bypass enforcement.
2. Introduce `EgressIntent` and `EgressDecision` inside
`openshell-supervisor-network`.
3. Move destination validation and endpoint metadata materialization behind the
shared decision and connector boundary.
4. Consolidate forward HTTP, CONNECT HTTP inspection, credential injection,
request-body rewrite, and WebSocket handling behind shared HTTP/WebSocket
relay code.
5. Move TLS detection and termination ahead of the HTTP/TCP relay split.
6. Add the TCP relay/parser boundary, then policy DNS and native TCP capture.
7. Treat local services and deployment modes as explicit runtime contracts.
## Risks
- Tightening endpoint metadata failures from fail-open to deny may expose
latent policy or Rego errors.
- Deterministic endpoint selection may reject policies that currently load but
only work by accident.
- Token grants add a runtime dependency on SPIFFE Workload API and token
endpoints. Failures should remain fail-closed and sanitized.
- Transparent TCP capture adds network namespace interception complexity and
must coexist with the nftables bypass reject/log table.
- Sidecar mode needs a reliable identity source for binary/path scoped policy.
- Metadata loopback and `policy.local` expand sandbox-local control surfaces
and need strict route validation, body limits, redaction, and authentication
boundaries.
- Provider-composed policy rules use a reserved namespace. Decisions and logs
must distinguish provider-derived policy from user-authored policy without
exposing provider rules as editable sandbox proposals.
## Alternatives
### Keep patching each entry path
This has the lowest short-term cost but keeps security behavior duplicated
across CONNECT, forward HTTP, and local services. It also makes future TCP
application protocol support harder because each parser must be wired through
multiple entry mechanisms.
### Replace CONNECT with forward proxy
Forward proxy only covers plaintext absolute-form HTTP requests. It is not a
replacement for HTTPS tunnels, WebSocket tunnels, or arbitrary TCP clients.
CONNECT should remain the generic explicit proxy mode.
### Build only transparent TCP
Transparent TCP helps native clients but does not replace explicit proxy
support used by common HTTP tooling. It also requires policy DNS and nftables
capture before it can safely preserve endpoint identity.
## Prior art
The current `openshell-supervisor-network` split is the immediate prior step:
it already separates proxy, OPA, L7, inference routing, policy-local routes,
TLS, and token grants from process supervision.
The current `openshell-supervisor-process` netns and bypass monitor are the
packet-enforcement substrate. Transparent TCP should extend that nftables
model rather than creating a second firewall path.
The existing L7 relay is the behavioral prior art for this RFC. It already
proves per-request HTTP evaluation, GraphQL parsing, WebSocket frame handling,
request-body rewrite, and token-grant injection can live behind relay
boundaries.
## Open questions
1. Should overlapping endpoint metadata be rejected at policy load time, or
should policy name plus endpoint index define precedence?
2. Should direct IP connects to a policy-DNS-resolved TCP endpoint be accepted,
or should DNS query correlation be required for stricter modes?
3. What TTL cap and stale-generation grace period should policy DNS use?
4. Which process identity source should sidecar mode use when it cannot inspect
payload process metadata through local `/proc`?
5. Which proxy capabilities should be negotiated with the gateway at startup?
6. Should metadata loopback be modeled as an adapter inside
`openshell-supervisor-network`, or remain orchestrated by `openshell-sandbox`
with shared credential/provider helpers?
@@ -0,0 +1,223 @@
# Current Shape Appendix
This appendix records the current proxy shape and the review findings that
motivate the adapter model. The main RFC intentionally keeps these details out
of the direction document.
## Current Runtime Split
The proxy is no longer only a large module inside `openshell-sandbox`.
Current main has three relevant runtime owners:
```mermaid
flowchart TD
Sandbox["openshell-sandbox<br/>orchestrator"]
Network["openshell-supervisor-network<br/>proxy, OPA, L7, TLS, inference,<br/>policy.local, token grants"]
Process["openshell-supervisor-process<br/>process leaf, SSH, netns,<br/>nftables, bypass monitor"]
Denials["Denial/activity aggregators"]
Gateway["Gateway policy/provider APIs"]
Sandbox --> Network
Sandbox --> Process
Network --> Denials
Process --> Denials
Sandbox --> Gateway
Network --> Gateway
```
`openshell-sandbox` creates the shared network namespace, owns denial/activity
channels, starts the policy poll loop, starts networking, starts the metadata
loopback server when needed, and then optionally starts the process leaf. If
`process_enabled` is false, the supervisor can run in network-only mode and
keep networking/background tasks alive until shutdown.
`openshell-supervisor-network` owns the explicit proxy listener, OPA engine
integration, L7 enforcement, TLS termination, inference routing, policy-local
routes, identity cache, provider credential injection, and token grants.
`openshell-supervisor-process` owns process execution, SSH, network namespace
helpers, nftables bypass rules, and the bypass monitor that turns nftables LOG
entries into OCSF events.
## Current Userland-Facing Surfaces
The networking surface currently includes:
- CONNECT proxy traffic for HTTPS and generic TCP tunnels.
- Forward HTTP proxy traffic for absolute-form HTTP requests.
- `inference.local` for local inference routing.
- `policy.local` for current policy, denial summaries, proposal submission,
and proposal wait routes.
- GCE metadata loopback for SDKs that bypass HTTP proxy variables.
- nftables bypass enforcement for direct TCP/UDP egress that does not enter
the proxy.
- OPA/Rego policy and endpoint metadata lookups.
- DNS resolution and endpoint validation for CONNECT and forward HTTP egress.
- Static provider credential injection and redaction.
- Endpoint-bound dynamic token grant injection.
- Opt-in REST request-body credential rewrite.
- L7 REST, GraphQL, WebSocket, and GraphQL-over-WebSocket enforcement.
The issue is not that these features exist. The issue is that entry mechanisms,
policy evaluation, endpoint metadata lookup, credential injection, and byte
relay decisions are still interleaved.
## Current CONNECT Shape
```mermaid
flowchart TD
Client["Client CONNECT host:port"] --> Parse["Parse CONNECT target"]
Parse --> L4["Evaluate network policy"]
L4 --> Allowed{"Allowed?"}
Allowed -- No --> Deny["CONNECT denial"]
Allowed -- Yes --> Meta["Query endpoint metadata"]
Meta --> Config{"L7, TLS, or credential config?"}
Config -- No --> Tunnel["Return tunnel-ready response"]
Config -- Yes --> Tunnel
Tunnel --> Inspect["Inspect tunneled bytes when possible"]
Inspect --> Relay["HTTP/WebSocket/TCP relay selection"]
Relay --> Inject["Static credentials and token grants if configured"]
Inject --> Upstream["Open upstream when relay policy allows"]
```
CONNECT is still the strongest entry shape because the tunnel relay can keep
parsing HTTP requests on long-lived connections and enforce request policy per
request.
## Current Forward HTTP Shape
```mermaid
flowchart TD
Client["Absolute-form HTTP request"] --> Parse["Parse first request"]
Parse --> L4["Evaluate network policy"]
L4 --> Allowed{"Allowed?"}
Allowed -- No --> Deny["HTTP denial"]
Allowed -- Yes --> L7{"Matching L7 endpoint?"}
L7 -- Yes --> Eval["Evaluate REST/GraphQL/WebSocket policy"]
Eval --> Guard["Reject unsupported h2c upgrade when inspected"]
Guard --> Rewrite["Rewrite to origin-form + configured credentials"]
L7 -- No --> Rewrite
Rewrite --> Token["Apply token grant if endpoint-bound"]
Token --> Close["Force Connection: close except WebSocket upgrade"]
Close --> Upstream["Open upstream"]
Upstream --> Relay["Guarded HTTP relay / upgrade relay"]
```
Latest main no longer has the old raw-copy-after-first-request shape for
ordinary forward HTTP. It rewrites ordinary requests with `Connection: close`,
uses guarded HTTP relay helpers for body handling, rejects inspected h2c
upgrades, injects token grants, and sends allowed WebSocket upgrades through
the upgrade relay. That is a narrower surface than the historical bidirectional
copy, but it is still orchestrated separately from the CONNECT relay path.
## Current Local Service Shape
```mermaid
flowchart TD
Request["Request to local name"] --> Match{"Known local route?"}
Match -- "inference.local" --> Inference["Inference route adapter"]
Match -- "policy.local" --> Policy["Policy local adapter"]
Match -- "metadata loopback" --> Metadata["Metadata credential server"]
Match -- No --> External["Normal egress path"]
Inference --> InferenceResp["Local inference response"]
Policy --> PolicyResp["Local policy response"]
Metadata --> MetadataResp["Metadata response"]
```
`inference.local` now covers buffered and streaming inference shapes including
chat/completion routes, model discovery, embeddings, and provider-specific
routes. `policy.local` supports the agentic approval loop: agents can submit
narrow proposals and wait on approval/reload before retrying. Metadata
loopback exists for provider credentials consumed by SDKs that do not honor
HTTP proxy variables.
These are userland-facing network surfaces. They should stay distinct from
external egress while still fitting the adapter model.
## Current Network Namespace Enforcement
```mermaid
flowchart TD
Start["Process in sandbox network namespace"] --> Dest{"Destination"}
Dest -- "Proxy host_ip:port" --> Proxy["Accept to sandbox proxy"]
Dest -- "Loopback" --> Loopback["Accept loopback"]
Dest -- "Established/related" --> Established["Accept response packet"]
Dest -- "Other TCP/UDP" --> Reject["nftables log + reject"]
Reject --> Monitor["Bypass monitor reads dmesg"]
Monitor --> OCSF["OCSF network + detection events"]
```
The process leaf installs an `inet` nftables filter table for bypass
enforcement. The table accepts proxy-bound traffic, loopback, and established
flows, then rejects and optionally logs other TCP/UDP traffic. It does not
currently redirect native TCP connections into the proxy.
## Findings To Preserve
### Invariant: forward proxy must not relay unevaluated follow-on HTTP bytes
The historical forward path evaluated at most the first absolute-form request,
rewrote it, then switched to bidirectional copy. Bytes already buffered after
the first header block, or later pipelined requests on the same client/upstream
connection, could reach upstream without the CONNECT L7 relay's per-request
parser/evaluator.
Latest main mitigates this by forcing ordinary forward HTTP to one request per
connection and by using guarded relay helpers. The adapter model should
preserve the invariant either by keeping forward HTTP single-request/close or
by passing the first parsed request into a shared HTTP relay loop.
### Endpoint config is not tied to deterministic matched policy
The policy name used for L4 authorization and logging can be selected through a
different precedence rule than endpoint metadata. With overlapping host, port,
and binary rules, allowed IPs, TLS behavior, enforcement, and
`allow_encoded_slash` can come from a different endpoint than the policy name
logged and used for L4 allow.
The adapter model requires authorization to return one decision with one
deterministic matched endpoint.
### Endpoint metadata query failures should not erase enforcement
If endpoint metadata lookup fails, callers can interpret the result as no L7
configuration and downgrade to credential-only or raw L4 relay.
The adapter model treats endpoint metadata as part of the authorization result.
Failure to materialize required metadata should deny rather than erase extended
configuration.
### Destination validation must be shared
Private address checks, `allowed_ips`, exact declared private endpoint trust,
trusted gateway aliases, SSRF checks, and control-plane port blocks have grown
over time. They should be centralized so CONNECT, forward HTTP, future
transparent TCP, and local-service egress use the same resolved-destination
rules.
## Existing Feature Inventory
The refactor should preserve:
- CONNECT explicit proxy support.
- Forward HTTP explicit proxy support.
- Network-only supervisor mode.
- nftables bypass reject/log enforcement.
- Provider credential injection and redaction.
- Dynamic token grant injection through SPIFFE-backed provider credentials.
- REST request-body credential rewrite.
- WebSocket text-frame credential rewrite.
- REST endpoint method/path policy.
- GraphQL-over-HTTP policy.
- WebSocket transport and GraphQL-over-WebSocket policy.
- h2c rejection on inspected HTTP routes.
- Inference routing through `inference.local`, including embeddings.
- Agent-facing policy advisor routes through `policy.local`.
- GCE metadata loopback for supported provider credentials.
- Timeout and resource tracking for client, upstream, and local service work.
- Structured OCSF logging for network and HTTP policy outcomes.
- SSRF and internal address protections.
- Exact declared private endpoint handling.
- Control-plane port protection.
- `allowed_ips` endpoint restrictions.
- TLS auto-detection and termination for inspectable client connections.
@@ -7,28 +7,46 @@ direction-focused.
- Add tests for forward HTTP pipelining and keep-alive follow-on requests,
including the current `Connection: close` mitigation.
- Add tests for forward HTTP h2c rejection on inspected endpoints.
- Add tests for overlapping endpoint metadata selection.
- Add tests for endpoint metadata query failures.
- Add tests for control-plane port blocking through all destination validation
paths.
- Add tests for exact declared private endpoint trust and `allowed_ips`
behavior across CONNECT and forward HTTP.
- Add tests proving static credential injection works in L4-only HTTP and
HTTP-inspected paths.
- Add tests proving token grant success injects the configured header and token
grant failure does not forward upstream.
- Add tests for REST request-body credential rewrite, WebSocket text-frame
credential rewrite, WebSocket GraphQL policy, and compression handling.
- Add tests for `policy.local` proposal wait behavior and `inference.local`
buffered/streaming route limits.
- Add tests for metadata loopback startup/failure behavior when provider
credentials require it.
- Add nftables bypass enforcement tests that verify proxy-bound traffic is
accepted while direct TCP/UDP egress is rejected and logged when available.
## Phase 1 - Authorization Result
- Introduce `EgressIntent` and `EgressDecision`.
- Introduce `EgressIntent` and `EgressDecision` inside
`openshell-supervisor-network`.
- Make authorization return matched policy and matched endpoint metadata
together.
- Include policy source on the decision: user-authored, provider-derived, or
local-service internal.
- Include protocol enforcement and credential injection plan on the decision.
- Fail closed when required endpoint metadata cannot be materialized.
- Emit consistent OCSF network denial events from the shared boundary.
## Phase 2 - Shared Destination Validation
- Move DNS resolution, allowed IP filtering, SSRF checks, and control-plane port
checks into one destination validation path.
- Move DNS resolution, allowed IP filtering, SSRF checks, exact declared
endpoint handling, trusted gateway aliases, and control-plane port checks
into one destination validation path.
- Return an `UpstreamConnector` rather than an opened upstream socket.
- Add tests proving CONNECT, forward HTTP, and transparent TCP use the same
validation behavior.
- Add tests proving CONNECT, forward HTTP, and future transparent TCP use the
same validation behavior.
## Phase 3 - Forward HTTP Adapter
@@ -36,15 +54,20 @@ direction-focused.
request and builds an egress intent.
- Route the parsed first request into the shared HTTP relay or preserve the
current guarded single-request relay behavior.
- Preserve `https://` absolute-form rejection.
- Preserve h2c rejection on inspected routes.
- Keep the no-raw-copy invariant after the first request.
## Phase 4 - HTTP And WebSocket Relay Consolidation
## Phase 4 - HTTP, WebSocket, And Credential Relay Consolidation
- Centralize HTTP request parsing, REST policy, GraphQL policy, WebSocket
upgrade policy, credential resolution, redaction, request rewrite, upstream
dial, and response relay.
- Evaluate every HTTP request before upstream write.
- Ensure denied HTTP requests do not create upstream TCP sessions.
- Preserve static placeholder rewrite for target, query, and headers.
- Preserve dynamic token grant injection after request allow and before
upstream write.
- Preserve opt-in REST request-body credential rewrite behind the shared HTTP
relay, including bounded buffering, supported content-type handling,
`Content-Length` recomputation, and fail-closed unresolved placeholders.
@@ -58,13 +81,14 @@ direction-focused.
- Move client-side TLS detection and termination before the HTTP/TCP relay
split.
- Keep endpoint TLS behavior on `EgressDecision`.
- Treat `tls: skip` as the explicit opt-out for TLS handling.
- Remove duplicate HTTP-specific and TCP-specific TLS termination decisions.
## Phase 6 - TCP Relay And Parser Boundary
- Rename raw TCP relay concepts to `TcpRelay`.
- Use `TcpRelay` for byte relay and TCP application parser dispatch.
- Keep `protocol: tcp` or omitted protocol as L4 authorization plus byte copy.
- Add a TCP application parser dispatch point for future protocol enforcement.
- Keep `protocol: tcp` as L4 authorization plus byte copy.
- Let TCP application parsers own their message loop and call the connector
when protocol state allows.
@@ -75,6 +99,7 @@ direction-focused.
- Publish active DNS answer state and capture rules.
- Implement nftables REDIRECT/TPROXY capture rules ahead of the bypass reject
path; do not add a parallel iptables path.
- Coordinate capture rule ownership with `openshell-supervisor-process::netns`.
- Implement transparent TCP adapter lookup from captured original destination
to active endpoint generation.
- Decide TTL and stale-generation behavior.
@@ -82,35 +107,51 @@ direction-focused.
## Phase 8 - Local Service Adapters
- Model `inference.local` as a local adapter with TLS termination, route
validation, provider auth injection, streaming limits, and OCSF logging.
validation, provider auth injection, streaming/buffered limits, and OCSF
logging.
- Model `policy.local` as a local adapter for current policy, bounded denial
summaries, and policy proposals.
- Keep both paths outside normal external egress relay.
summaries, policy proposals, and proposal wait.
- Decide whether metadata loopback remains orchestrated in `openshell-sandbox`
or moves behind a local adapter boundary in `openshell-supervisor-network`.
- Keep these paths outside normal external egress relay while preserving
credential redaction and route validation.
## Phase 9 - Runtime Boundary
- Keep embedded mode for the first migration.
- Keep embedded supervisor mode as the first migration target.
- Treat the existing `openshell-supervisor-network` and
`openshell-supervisor-process` split as the structural baseline.
- Define the proxy runtime API needed for a future standalone binary:
configured listeners, policy updates, gateway calls, telemetry, and shutdown.
configured listeners, policy updates, provider credentials, token grants,
gateway calls, telemetry, denial/activity events, and shutdown.
- Identify process identity requirements for standalone and sidecar modes.
- Add capability negotiation with the gateway if standalone proxy versions can
differ from gateway versions.
## Phase 10 - Cleanup
- Remove duplicated endpoint metadata queries from relay paths.
- Remove duplicated deny rendering where adapters can own response shape.
- Remove duplicated destination validation and deny rendering where adapters
can own response shape.
- Remove any remaining forward HTTP raw-copy fallback.
- Remove stale references to iptables or static `/etc/hosts` native TCP
mapping from proxy design docs.
- Update architecture docs once implementation lands.
## Testing Plan
- Unit-test each adapter's intent construction and deny response shape.
- Unit-test authorization precedence for overlapping policy and endpoint rules.
- Unit-test provider-derived rule namespace handling and `policy.local`
filtering.
- Integration-test shared destination validation across CONNECT, forward HTTP,
and transparent TCP.
- Integration-test HTTP keep-alive and pipelined requests with REST, GraphQL,
and WebSocket upgrade enforcement.
- Integration-test credential injection in L4-only HTTP and HTTP-inspected
paths.
- Integration-test token grant success, cache hit, malformed token, resolver
unavailable, and token endpoint failure.
- Integration-test REST request-body credential rewrite for JSON,
form-url-encoded, `text/*`, unsupported content types, chunked framing, body
caps, and unresolved placeholders.
@@ -123,5 +164,5 @@ direction-focused.
application parsers.
- Integration-test policy DNS TTL, stale generation handling, and captured
connect correlation.
- Integration-test `inference.local` and `policy.local` body limits, timeout
behavior, redaction, and local denial responses.
- Integration-test `inference.local`, `policy.local`, and metadata loopback
body limits, timeout behavior, redaction, and local denial responses.
@@ -1,7 +1,19 @@
# Technical Design Appendix
This appendix carries the implementation-level design details behind the main
RFC.
This appendix carries implementation-level design details behind the main RFC.
## Existing Runtime Boundary
`openshell-supervisor-network::run::run_networking` is the current networking
startup boundary. It builds policy-local context, waits for policy binary
symlink resolution, creates the identity cache, writes the TLS CA, builds TLS
state, resolves inference routes, wires provider credentials and token grants,
and starts the proxy.
This is a useful outer boundary, but it is not yet the proxy adapter boundary.
The proxy still needs internal `EgressIntent` and `EgressDecision` boundaries
so CONNECT, forward HTTP, local routes, and future native TCP capture do not
duplicate policy and relay orchestration.
## Shared Data Boundaries
@@ -11,14 +23,16 @@ RFC.
It should carry:
- entry transport: CONNECT, forward HTTP, transparent TCP, or local HTTP;
- entry transport: CONNECT, forward HTTP, transparent TCP, local HTTP, policy
DNS, or metadata loopback;
- requested destination host/port or captured original IP/port;
- process identity inputs collected by the adapter/runtime;
- optional first HTTP request for forward proxy traffic;
- optional local service route.
- optional local service route;
- policy generation or DNS mapping generation when relevant.
Adapters build intents. They should not query endpoint metadata or select
relays.
Adapters build intents. They should not query endpoint metadata, select TLS
mode, or select relays.
### EgressDecision
@@ -28,15 +42,19 @@ It should carry:
- allow or deny;
- deterministic matched policy identifier;
- whether the policy is user-authored, provider-derived, or local-service
internal;
- deterministic matched endpoint identifier and endpoint metadata;
- process identity used for evaluation;
- destination and allowed IP constraints;
- TLS behavior;
- protocol enforcement;
- credential injection plan;
- logging context and denial reason.
Relay code should read this decision. It should not query OPA again for
endpoint metadata, TLS mode, allowed IPs, or parser selection.
endpoint metadata, TLS mode, allowed IPs, credential behavior, or parser
selection.
## Protocol Enforcement
@@ -46,15 +64,14 @@ Use a protocol enforcement value derived from endpoint policy:
|-----------------|-------------|----------------|
| omitted / `tcp` | None | L4 authorization plus byte relay, with optional HTTP sniff for credential injection |
| `rest` | HTTP | HTTP request parser with REST rules, plus opt-in request-body and WebSocket text-frame credential rewrite |
| `graphql` | HTTP | HTTP request parser with GraphQL rules |
| `graphql` | HTTP | HTTP request parser with GraphQL-over-HTTP rules |
| `websocket` | HTTP | HTTP upgrade policy followed by WebSocket frame policy or GraphQL-over-WebSocket policy |
| future `redis`, `postgres`, `mysql`, ... | TCP application | Protocol-specific TCP parser owns the message loop |
`protocol: tcp` is effectively the default L4 mode. It should not run TCP
application parsers.
Avoid using the term "provider" for these parser concepts because providers
are already a first-class credential and routing domain in OpenShell.
application parsers. Avoid using the term "provider" for parser concepts
because providers are already a first-class credential and routing domain in
OpenShell.
## Suggested Types
@@ -65,7 +82,9 @@ enum EgressTransport {
Connect,
ForwardHttp,
TransparentTcp,
PolicyDns,
LocalHttp,
MetadataLoopback,
}
struct EgressIntent {
@@ -74,6 +93,7 @@ struct EgressIntent {
process: ProcessIdentity,
first_request: Option<ParsedHttpRequest>,
local_route: Option<LocalRoute>,
generation: Option<PolicyGeneration>,
}
struct EgressDecision {
@@ -83,11 +103,23 @@ struct EgressDecision {
log_context: EgressLogContext,
}
struct MatchedPolicy {
id: PolicyId,
source: PolicySource,
}
enum PolicySource {
User,
ProviderDerived,
LocalService,
}
struct MatchedEndpoint {
id: EndpointId,
allowed_ips: AllowedIpPolicy,
tls: TlsPolicy,
enforcement: ProtocolEnforcement,
credentials: CredentialInjectionPlan,
}
enum ProtocolEnforcement {
@@ -104,10 +136,30 @@ enum HttpL7Protocol {
struct HttpL7Config {
protocol: HttpL7Protocol,
path: EndpointPathScope,
allow_encoded_slash: bool,
enforcement_mode: L7EnforcementMode,
websocket_credential_rewrite: bool,
request_body_credential_rewrite: bool,
websocket_graphql_policy: bool,
graphql_max_body_bytes: usize,
}
struct CredentialInjectionPlan {
static_placeholders: StaticPlaceholderPlan,
token_grant: Option<TokenGrantPlan>,
}
struct StaticPlaceholderPlan {
http_target_query_header: bool,
rest_request_body: bool,
websocket_text_frames: bool,
}
struct TokenGrantPlan {
provider_key: String,
auth_style: TokenGrantAuthStyle,
token_endpoint: String,
}
struct RelayContext {
@@ -122,26 +174,19 @@ struct RelayContext {
validated destination and lets relays/parsers open an upstream connection only
after protocol policy allows it.
## Module Layout
## Current Owners And Proposed Cleanup
A future split could look like:
| Module | Responsibility |
|--------|----------------|
| `proxy::adapter::connect` | Parse CONNECT and render CONNECT responses |
| `proxy::adapter::forward_http` | Parse absolute-form HTTP and preserve first request |
| `proxy::adapter::transparent_tcp` | Recover captured original destination |
| `proxy::adapter::policy_dns` | Answer eligible DNS queries and publish active mappings |
| `proxy::adapter::local` | Implement `inference.local` and `policy.local` surfaces |
| `proxy::auth` | Build decisions from intents and OPA results |
| `proxy::destination` | Resolve, filter, and validate destinations |
| `proxy::netfilter` | Own nftables bypass and future transparent capture rules |
| `proxy::relay::http` | HTTP request loop, credentials, REST/GraphQL/WebSocket upgrade policy |
| `proxy::relay::websocket` | WebSocket frame validation, text-frame rewrite, and message policy |
| `proxy::relay::tcp` | TCP byte relay and TCP application parser dispatch |
| `proxy::relay::tls` | Shared client-side TLS termination |
| `proxy::parser` | HTTP, WebSocket, and TCP application parser traits/config |
| `proxy::telemetry` | OCSF and tracing helpers |
| Current owner | Current responsibility | Proposed cleanup |
|---------------|------------------------|------------------|
| `openshell-sandbox` | Orchestrator, policy poll loop, denial/activity channels, metadata loopback startup, network-only lifecycle | Keep as orchestration; avoid embedding per-entry proxy policy decisions |
| `openshell-supervisor-network::run` | Networking startup and handles | Become the stable runtime API for embedded and future standalone modes |
| `openshell-supervisor-network::proxy` | CONNECT, forward HTTP, local route dispatch, destination validation, denial rendering | Split into adapters, authorization, destination, relay selection, and adapter response rendering |
| `openshell-supervisor-network::opa` | Policy engine and Rego queries | Return deterministic `EgressDecision` data instead of separate policy and endpoint lookups |
| `openshell-supervisor-network::l7` | REST, GraphQL, WebSocket, inference helpers, TLS, token grants | Keep as protocol/relay implementation behind shared relay boundaries |
| `openshell-supervisor-network::policy_local` | `policy.local` state and routes | Model as a local adapter with explicit limits and proposal/wait behavior |
| `openshell-supervisor-process::netns` | nftables bypass rules and namespace helpers | Remain owner of bypass enforcement; coordinate future capture rules with network proxy mappings |
| `openshell-supervisor-process::bypass_monitor` | nftables LOG parsing and OCSF bypass telemetry | Remain telemetry producer for bypass violations |
| `openshell-core::secrets` and provider credential state | Static placeholder sources and dynamic credential metadata | Feed credential injection plans; do not leak secrets into decision logs |
## Policy DNS And Resolved TCP State
@@ -194,15 +239,23 @@ Two acceptable approaches:
Endpoint metadata query failures should fail closed when metadata is required
for the selected endpoint. They should not silently downgrade to L4 behavior.
Provider-derived policies use a reserved rule-name namespace. The gateway and
sandbox sync should prevent user-authored `_provider_*` rules, and
`policy.local` proposal surfaces should not expose provider-derived rules as
editable user policy. `EgressDecision` should still identify provider-derived
matches for logging and debugging.
## Credential Injection Boundary
Credential injection belongs in the HTTP relay:
Credential injection belongs in the HTTP/WebSocket relay after policy allow and
before upstream write.
1. Authorization selects the endpoint and confirms credentials may be used.
2. The HTTP relay resolves credentials only when it has an allowed HTTP request.
3. Secrets are redacted from logs and policy-visible metadata.
4. The final upstream request or frame is rewritten with real credentials
immediately before write.
1. Authorization selects the endpoint and computes a credential injection plan.
2. The HTTP relay resolves credentials only when it has an allowed request.
3. Static placeholder values are resolved and redacted from logs.
4. Endpoint-bound token grants obtain or reuse a dynamic access token.
5. The final upstream request or WebSocket frame is rewritten immediately
before write.
Both L4-only HTTP and HTTP-inspected paths can inject credentials. The
difference is whether REST, GraphQL, or WebSocket policy is evaluated before
@@ -215,7 +268,8 @@ Credential rewrite slots should be explicit:
- client-to-server WebSocket text frames only when
`websocket_credential_rewrite` is enabled;
- GraphQL-over-WebSocket connection/control messages when they are carried in
text frames and the endpoint enables the WebSocket rewrite path.
text frames and the endpoint enables the WebSocket rewrite path;
- token grant headers for endpoint-bound provider credentials.
Request-body rewrite is REST-only. It should buffer bounded UTF-8 textual
bodies, including JSON, form-url-encoded, and `text/*`, recompute
@@ -223,6 +277,12 @@ bodies, including JSON, form-url-encoded, and `text/*`, recompute
credential markers, and fail closed when a reserved placeholder cannot be
resolved safely. Binary WebSocket frames are not rewritten.
Token grants are dynamic credential injection. They use provider metadata to
request a SPIFFE JWT-SVID, exchange it for an OAuth2 access token, cache the
token, and inject either an `Authorization: Bearer` header or a configured
custom header. Token grant failures should return a local relay error and must
not forward the request upstream.
## Parser Boundary
Protocol parsers operate on streams owned by the relay.
@@ -241,6 +301,24 @@ Protocol parsers operate on streams owned by the relay.
This avoids a separate dial strategy enum. The parser knows which protocol
milestone is sufficient to call the validated connector.
## Local Service Adapter Boundary
Local services are network surfaces but not normal external egress:
- `inference.local` terminates local client traffic, validates known inference
routes, strips caller auth, injects provider routing/auth, and applies
streaming or buffered limits based on route type.
- `policy.local` serves policy snapshots, denial summaries, proposal
submission, and proposal wait. It should never expose secrets or provider
rules as editable policy.
- Metadata loopback serves provider metadata credentials for SDKs that bypass
HTTP proxy variables. It should use the same provider credential state and
redaction discipline as other credential paths.
These adapters may call gateway APIs or local credential helpers, but they
should not bypass policy and credential invariants that apply to external
egress.
## Timeout And Resource Ownership
| Owner | Resource |
@@ -254,6 +332,7 @@ milestone is sufficient to call the validated connector.
| TCP relay | Byte-copy idle timeout and half-close handling |
| TCP parser | Protocol message timeouts and parser-specific limits |
| Local service adapter | Local route body limits, response caps, gateway call timeout |
| Token grant resolver | SPIFFE Workload API timeout, token endpoint timeout, cache TTL |
Timeouts should be recorded in telemetry at the owner boundary that can explain
the failure.