Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
961e456
feat(network): enable Docker transparent TCP egress
johntmyers Aug 12, 2026
c54dcfe
test(e2e): cover Docker transparent TCP egress
johntmyers Aug 12, 2026
cc37086
feat(network): correlate transparent TCP audit events
johntmyers Aug 13, 2026
e2bc255
docs(examples): add transparent TCP Redis demo
johntmyers Aug 13, 2026
fd772ad
docs(examples): demonstrate blocked TCP connections
johntmyers Aug 13, 2026
670b0a2
docs(examples): focus Redis demo audit output
johntmyers Aug 13, 2026
bc8406b
fix(network): close transparent TCP policy bypasses
johntmyers Aug 13, 2026
a721e91
fix(sandbox): reject unsupported TCP policy reloads
johntmyers Aug 13, 2026
38622f0
fix(ci): satisfy Linux transparent TCP lints
johntmyers Aug 13, 2026
08c6ee2
feat(podman): enable transparent TCP egress
johntmyers Aug 13, 2026
d5a0e9b
fix(podman): permit policy DNS port binding
johntmyers Aug 13, 2026
be54bce
test(e2e): use qualified transparent TCP hostname
johntmyers Aug 13, 2026
8cfcf93
fix(podman): preserve exact policy DNS names
johntmyers Aug 13, 2026
e95e1fa
fix(podman): route policy DNS over TCP
johntmyers Aug 13, 2026
72d91de
fix(dns): serve multiple TCP queries per connection
johntmyers Aug 13, 2026
00707bd
docs(network): explain native DNS and TCP egress
johntmyers Aug 14, 2026
85eaf7e
fix(sandbox): reconcile runtime reload with upstream
johntmyers Aug 17, 2026
70c1bfd
fix(network): harden transparent DNS capture
johntmyers Aug 17, 2026
5779cab
fix(podman): preserve resolver behavior for native tcp
johntmyers Aug 17, 2026
cee830a
docs(network): clarify native tcp runtime constraints
johntmyers Aug 17, 2026
b400b94
fix(network): remove unused transparent tcp pin
johntmyers Aug 17, 2026
e76f032
fix(network): admit redirected transparent tcp
johntmyers Aug 17, 2026
5d7d7f5
fix(network): restore podman transparent networking
johntmyers Aug 17, 2026
03137c6
test(podman): permit alpine busybox binaries
johntmyers Aug 17, 2026
ec1be61
test(podman): use portable alpine keepalive
johntmyers Aug 17, 2026
0140736
test(podman): build musl networking fixture
johntmyers Aug 18, 2026
c84e74e
test(podman): isolate musl DNS probe
johntmyers Aug 18, 2026
96305ed
fix(podman): keep privileged port capability dropped
johntmyers Aug 19, 2026
044e22f
fix(network): preserve transparent TCP port 53
johntmyers Aug 19, 2026
eac8cdd
fix(network): report synthetic pool pressure by family
johntmyers Aug 19, 2026
aa8d4b8
test(podman): bind tcp fixtures before readiness
johntmyers Aug 20, 2026
bbffdb4
test(podman): grant fixture low-port bind
johntmyers Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .agents/skills/debug-openshell-cluster/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ Common findings:
callbacks. On an older release, set `bind_address = "127.0.0.1:17670"` or
upgrade.
- Supervisor image exits before printing `openshell-sandbox --version`: the image should be the scratch supervisor image from `deploy/docker/Dockerfile.supervisor` and must contain a static executable at `/openshell-sandbox`.
- A sandbox with explicit `protocol: tcp` endpoints fails before workload readiness: confirm the Docker or Podman driver supplied the `policy-dns-transparent-tcp` runtime capability and inspect supervisor logs for missing `nft`, synthetic-route overlap, or namespace-local DNS/TCP listener bind failures. Kubernetes, VM, sidecar, and out-of-tree drivers must reject this policy until they provide the complete substrate; use omitted protocol with an explicit proxy on those runtimes.
- `mise run e2e:docker:gpu` fails with `docker info --format json did not report any discovered NVIDIA CDI GPU devices`: Docker may report `CDISpecDirs` while still having no generated NVIDIA CDI specs. Verify `.DiscoveredDevices` contains entries such as `nvidia.com/gpu=all`, verify `/etc/cdi` or `/var/run/cdi` contains a generated NVIDIA spec, and check that `nvidia-cdi-refresh.service` and `nvidia-cdi-refresh.path` from NVIDIA Container Toolkit are enabled and healthy. The service is a one-shot unit, so `inactive (dead)` can be normal after a successful run; use `systemctl status` and `journalctl` to distinguish success from a skipped or failed refresh. NVIDIA recommends enabling the path and service units, and restarting `nvidia-cdi-refresh.service` to regenerate missing or stale CDI specs. If specs are generated but Docker still reports no discovered devices, restart Docker or reload the daemon and re-check `docker info`.

For source checkout development, restart the local gateway with:
Expand Down Expand Up @@ -219,6 +220,10 @@ Common findings:
- Sandbox image missing or pull denied: verify image reference and registry credentials.
- Sandbox fails before readiness with an identity-resolution error: inspect the image's OCI `USER` and matching `/etc/passwd` and `/etc/group` entries, or explicitly set both process identity fields in policy. Numeric workload identities `1` through `4294967294` are accepted; root, the invalid identity sentinel, and missing identities are rejected.
- Supervisor cannot call back: check callback endpoint and gateway logs.
- A sandbox with explicit `protocol: tcp` endpoints fails before readiness:
inspect supervisor logs for policy DNS port-53 binding, synthetic-route, or
nftables redirect failures. Rootless Podman must provide these primitives
inside the supervisor-owned nested network namespace; setup fails closed.
- Gateway exits before becoming healthy with a callback-listener discovery
error: inspect `podman info --debug`, the configured Podman network, and the
host's IPv4 default route. Rootless pasta uses the private source address
Expand Down
17 changes: 9 additions & 8 deletions .agents/skills/generate-sandbox-policy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ For this tier, default to:
- `access: read-only` when the user says "read", "browse", "view", "query", "fetch"
- `access: read-write` when the user says "read-write", "create", "update" (but not "delete")
- `access: full` when the user says "full access", "everything", "unrestricted"
- L4-only (omit `protocol`, or use explicit `protocol: tcp`) when the user says
"just allow it", "pass through", "no inspection". Prefer omission unless the
user wants the transport intent stated explicitly. Explicit TCP requires a
valid DNS hostname; omit `protocol` for a legacy hostless `allowed_ips`
proxy endpoint.
- L4-only when the user says "just allow it", "pass through", or "no
inspection". Omit `protocol` for explicit-proxy clients. Use
`protocol: tcp` only when the workload must use native DNS and direct socket
calls, the endpoint has a valid DNS hostname, and the selected runtime
support (currently Docker and Podman).

### Moderate Tier (host + partial path knowledge)

Expand Down Expand Up @@ -192,7 +192,8 @@ Follow this decision tree based on the detail tier and user intent:
```
Is L7 inspection needed?
├─ No (user wants pass-through / "just allow it")
│ └─ Generate L4-only policy (no protocol, or protocol: tcp with a DNS hostname; no tls/rules/access)
│ ├─ Explicit-proxy client → omit protocol
│ └─ Native DNS/socket client with a DNS hostname on a supported runtime → protocol: tcp
└─ Yes (user wants method/path control)
Expand All @@ -213,7 +214,7 @@ Is L7 inspection needed?
| API host port | TLS setting |
|--------------|-------------|
| Port 443 (HTTPS) and L7 rules/preset needed | `tls: terminate` (required for inspection) |
| Port 443 (HTTPS) and L4-only | Omit `tls` (passthrough, no L7) |
| Port 443 (HTTPS) and L4-only | Omit `tls` (passthrough, no L7); choose omitted protocol or explicit TCP based on client/runtime as above |
| Non-443 (HTTP) | Omit `tls` |

**Critical**: `protocol: rest` on port 443 without `tls: terminate` will not work — the proxy cannot inspect encrypted traffic. Always set `tls: terminate` when combining port 443 with L7 rules.
Expand Down Expand Up @@ -415,7 +416,7 @@ Evaluate the generated policy for overly broad access and **include warnings in

| Condition | Warning to show |
|-----------|----------------|
| **L4-only** (no `protocol`, or `protocol: tcp`) | "This policy allows all HTTP methods and paths without inspection. The proxy will only check host:port and binary identity. Consider adding `protocol: rest` with a preset if you want method-level control." |
| **L4-only** (no `protocol`, or `protocol: tcp`) | "This policy allows all application methods and paths without inspection. An omitted protocol uses explicit-proxy behavior; `protocol: tcp` enables policy DNS and transparent TCP only on a runtime that advertises the complete substrate (currently Docker and Podman). Consider `protocol: rest` with a preset if you want HTTP method-level control." |
| **`access: full`** | "This policy allows all HTTP methods (including DELETE) on all paths. If you don't need DELETE, `read-write` is safer. If you only need to read, `read-only` is the most restrictive option." |
| **`access: full` + `enforcement: audit`** | "Full access in audit mode provides no actual restriction — all traffic flows through. This is effectively a monitoring-only policy." |
| **`access: read-write`** when user hasn't confirmed write need | "This policy allows POST, PUT, and PATCH on all paths. If you only need to read data, `read-only` is more restrictive." |
Expand Down
5 changes: 5 additions & 0 deletions .agents/skills/openshell-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,11 @@ This is the most important multi-step workflow. It enables a tight feedback cycl

**Key concept**: Policies have static fields (immutable after creation: `filesystem_policy`, `landlock`, `process`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox.

An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit
`protocol: tcp` requests policy DNS and transparent TCP and currently requires
the Docker or Podman runtime; unsupported runtimes reject the policy before starting the
workload rather than activating only part of the network contract.

```
Create sandbox with initial policy
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ delete, reconciliation removes the row; otherwise it can remain `Deleting`.

| Runtime | Best fit | Sandbox boundary | Notes |
|---|---|---|---|
| Docker | Local development with Docker available. | Container plus nested sandbox namespace. | Uses host networking so loopback gateway endpoints work from the supervisor. |
| Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API and CDI GPU devices when available. Delivers the supervisor via OCI image volume by default; falls back to extracting the binary to a host-side cache and bind-mounting it when `userns` is configured (overlay does not support idmapped mounts). |
| Docker | Local development with Docker available. | Container plus nested sandbox namespace. | Uses host networking so loopback gateway endpoints work from the supervisor. Advertises the combined-supervisor policy-DNS and transparent-TCP substrate. |
| Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API and CDI GPU devices when available. Delivers the supervisor via OCI image volume by default; falls back to extracting the binary to a host-side cache and bind-mounting it when `userns` is configured (overlay does not support idmapped mounts). Advertises the combined-supervisor policy-DNS and transparent-TCP substrate. |
| Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, and GPU resources. |
| VM | Experimental microVM isolation. | Per-sandbox libkrun VM. | Managed endpoint-backed driver. The gateway spawns `openshell-driver-vm`, waits for its Unix socket, and then consumes it through the same remote `compute_driver.proto` path used by unmanaged endpoint drivers. The VM driver boots a cached bootstrap `rootfs.ext4`, prepares requested OCI images inside a bootstrap VM with `umoci`, attaches the prepared image disk read-only, and gives each sandbox a writable `overlay.ext4` for merged-root changes and runtime material. The driver persists each accepted launch request beside the overlay and restarts those VMs on driver startup without recreating the overlay. |
| Extension | Out-of-tree drivers operated alongside the gateway. | Whatever boundary the driver implements. | Selected by a custom `compute_drivers = ["<name>"]` entry with `[openshell.drivers.<name>].socket_path`, or at launch time by pairing `--drivers <name>` with `--compute-driver-socket=<path>`. A launch-time endpoint may use a canonical built-in name to preserve its driver-config key while replacing in-process construction. The gateway connects to an operator-provisioned UDS, snapshots `GetCapabilities`, and dispatches all sandbox lifecycle calls through `compute_driver.proto`. The driver process and socket lifecycle are operator-owned; the gateway does not spawn, supervise, or remove unmanaged extension drivers. The trust boundary is the socket's filesystem permissions: the operator must ensure only the gateway uid can read/write it. |
Expand All @@ -185,6 +185,16 @@ operator override because they place gateway-host filesystem state inside the
sandbox and can negate OpenShell workspace isolation and filesystem-policy
controls. Driver-owned supervisor, token, and TLS bind mounts stay reserved.

Network features follow the existing driver/substrate split. Compute drivers
advertise only the runtime mechanics they can guarantee: namespace and
capability ownership, DNS/TCP capture installation, and coupled
restart ordering. The shared supervisor remains the sole owner of DNS
eligibility, synthetic mappings, process authorization, destination filtering,
pinned dialing, relay behavior, and OCSF decisions. Docker and Podman advertise
`policy-dns-transparent-tcp`; other runtimes reject explicit TCP policy until
they implement and validate the same complete contract. The capability marker
is driver-owned supervisor input and is removed from workload environments.

Kubernetes deployments may set an AppArmor profile on sandbox agent containers
through the driver configuration. The Helm chart defaults sandbox agents to
`Unconfined` so runtime/default AppArmor profiles do not block supervisor
Expand Down
46 changes: 33 additions & 13 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,39 @@ the shared raw byte relay after the existing adapter gates. Forward HTTP retains
its guarded single-request relay while sharing authorization, request context,
policy-pinning, and destination boundaries.
Adapter-specific response and OCSF event shapes remain at the protocol boundary.
Policy authors may use `protocol: tcp` as an explicit spelling of the existing
L4 passthrough behavior. Explicit TCP endpoints require a valid DNS hostname;
hostless `allowed_ips` and literal-IP selectors remain available only to the
legacy forward-proxy path when `protocol` is omitted. The network supervisor
contains a dormant policy-DNS boundary for explicit TCP endpoints:
it snapshots eligible endpoint identities from one policy generation, resolves
eligible names only through an explicitly supplied trusted resolver, filters
answers through the shared destination controls, and publishes expiring
synthetic-address mappings with separate mapping generations. Refreshes retain
their synthetic identity, and policy reload, expiry, wrong ports, missing
mappings, or pool exhaustion fail closed. The pinned connector never resolves
the name again. No DNS listener is exposed to workloads, resolver configuration
is not injected, and transparent TCP capture is not active in this increment.
An explicit `protocol: tcp` endpoint with a valid DNS hostname opts into native
DNS and transparent TCP when the selected runtime advertises that substrate.
Hostless `allowed_ips` and literal-IP selectors remain available only to the
legacy explicit-proxy path when `protocol` is omitted. The shared supervisor
answers only eligible DNS names, returns an epoch-scoped synthetic address, and
publishes the expiring name, endpoint, ports, policy generation, and validated
real addresses as one correlation. A connection to that synthetic address is
captured before the bypass fence, mapped back to its workload process, authorized
through the same egress pipeline, and dialed only through the pinned addresses.
Omitted protocol endpoints retain explicit-proxy behavior.

The DNS store is in-memory and sandbox-local. A combined-supervisor restart also
restarts its workload; before execution, the supervisor advances a persisted
boot epoch and installs only that epoch's synthetic capture ranges. An address
cached from the preceding epoch therefore falls through to the bypass fence
instead of inheriting a new mapping. Policy reload, expiry, wrong ports, direct real-IP access, missing
mappings, or pool exhaustion fail closed. Resolver injection, DNS listeners,
capture rules, and the transparent listener are all ready before workload
execution. A runtime that cannot provide the complete contract rejects a policy
containing explicit TCP endpoints rather than partially activating it. Because
that substrate is startup infrastructure, a sandbox created without explicit
TCP endpoints rejects a hot reload that introduces one and keeps its complete
previous policy active; recreating the sandbox installs the substrate before
the workload starts. A sandbox that started with the substrate may continue to
remove and re-add TCP endpoints through ordinary atomic policy reloads.
Workload DNS targets port 53, while nftables redirects eligible IPv4 DNS traffic
to an unprivileged supervisor listener. The filter admits DNS and transparent
TCP only when the kernel records the traffic as DNATed to the corresponding
supervisor listener, so direct dials to either unprivileged listener port remain
fenced. `SO_ORIGINAL_DST`, synthetic mapping lookup, endpoint correlation, and
generation-pinned authorization form the transparent TCP security boundary.
Docker and Podman do not currently advertise usable IPv6 egress for this
substrate, so AAAA queries return NOERROR/NODATA and IPv6 DNS remains fenced.

Provider credential placeholders are resolved through the live provider state
for each HTTP request, after destination and L7 policy admission. A static
Expand Down
8 changes: 8 additions & 0 deletions crates/openshell-core/src/sandbox_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ pub const SUPERVISOR_TOPOLOGY: &str = "OPENSHELL_SUPERVISOR_TOPOLOGY";
/// Network enforcement backend selected by the compute driver.
pub const NETWORK_ENFORCEMENT_MODE: &str = "OPENSHELL_NETWORK_ENFORCEMENT_MODE";

/// Comma-separated runtime networking capabilities supplied by the compute
/// driver. Capabilities describe substrate the shared supervisor may activate;
/// they never move policy evaluation into the driver.
pub const NETWORK_RUNTIME_CAPABILITIES: &str = "OPENSHELL_NETWORK_RUNTIME_CAPABILITIES";

/// Driver capability for policy-gated DNS and transparent TCP interception.
pub const POLICY_DNS_TRANSPARENT_TCP_CAPABILITY: &str = "policy-dns-transparent-tcp";

/// Whether network policy evaluation must bind requests to the peer binary.
///
/// The default when unset is `"required"`. Kubernetes sidecar experiments may
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ contract:
| `restart_policy = unless-stopped` | Keeps managed sandboxes resumable across daemon or gateway restarts. |
| `PidsLimit` | Enforces the sandbox PID budget at the Docker cgroup layer. Set `[openshell.drivers.docker].sandbox_pids_limit = 0` to inherit the Docker/runtime default. |
| CDI GPU request | Uses opaque `driver_config.cdi_devices` values when set; otherwise selects the requested count of NVIDIA CDI GPUs in round-robin order when daemon CDI support is detected. Docker daemon `/info` can permit `nvidia.com/gpu=all` as a WSL2 all-only compatibility fallback, where it counts as one selectable device. Exact CDI device lists must not contain duplicates and must match the effective GPU count. |
| `policy-dns-transparent-tcp` capability | Declares that the combined Docker supervisor can own namespace-local DNS/TCP capture and coupled workload restart. The shared supervisor still owns DNS eligibility, mappings, authorization, pinned dialing, relaying, and OCSF decisions. The marker is stripped from the workload environment. |

The agent child process does not retain these supervisor privileges.

Expand Down
4 changes: 4 additions & 0 deletions crates/openshell-driver-docker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2394,6 +2394,10 @@ fn build_environment_for_oci_user(
openshell_core::sandbox_env::TELEMETRY_ENABLED.to_string(),
openshell_core::telemetry::enabled_env_value().to_string(),
);
environment.insert(
openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.to_string(),
Comment thread
johntmyers marked this conversation as resolved.
openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY.to_string(),
);
// The root supervisor executes namespace helpers during bootstrap; keep
// their search path driver-owned even when the template/spec set PATH.
environment.insert("PATH".to_string(), SUPERVISOR_PATH.to_string());
Expand Down
21 changes: 21 additions & 0 deletions crates/openshell-driver-docker/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,27 @@ fn build_environment_sets_docker_tls_paths() {
assert!(env.contains(&"TEMPLATE_ENV=template".to_string()));
assert!(env.contains(&"SPEC_ENV=spec".to_string()));
assert!(env.contains(&"OPENSHELL_SANDBOX_COMMAND=sleep infinity".to_string()));
assert!(env.contains(&format!(
"{}={}",
openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES,
openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY
)));
}

#[test]
fn build_environment_keeps_network_capabilities_driver_controlled() {
let mut sandbox = test_sandbox();
sandbox.spec.as_mut().unwrap().environment.insert(
openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.to_string(),
"spoofed".to_string(),
);
let env = build_environment(&sandbox, &runtime_config());
assert!(env.contains(&format!(
"{}={}",
openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES,
openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY
)));
assert!(!env.iter().any(|entry| entry.ends_with("=spoofed")));
}

#[test]
Expand Down
Loading
Loading