Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
df1d2ce
feat(kubernetes): add sidecar supervisor topology
TaylorMutch Jul 2, 2026
05c56d5
fix(supervisor): avoid similar process id names
TaylorMutch Jul 2, 2026
d8c05f8
fix(supervisor): avoid similar process id names
TaylorMutch Jul 2, 2026
5c3d023
fix(sandbox): avoid similar proxy id names
TaylorMutch Jul 3, 2026
e9949a3
docs(kubernetes): clarify sidecar topology limits
TaylorMutch Jul 6, 2026
ddab48a
fix(kubernetes): keep sidecar process leaf capless
TaylorMutch Jul 6, 2026
541d68d
fix(kubernetes): refresh sidecar provider env snapshots
TaylorMutch Jul 6, 2026
83cdcac
test(supervisor): align hot-swap identity regression
TaylorMutch Jul 6, 2026
8cc6cba
fix(kubernetes): stage sidecar mtls files before proxy chown
TaylorMutch Jul 7, 2026
1edb9d6
fix(kubernetes): simplify sidecar supervisor topology
TaylorMutch Jul 7, 2026
18fa75a
chore(helm): reuse sidecar skaffold values
TaylorMutch Jul 7, 2026
a4a50b9
fix(supervisor): avoid similar iptables helper names
TaylorMutch Jul 7, 2026
56eab08
fix(e2e): harden kube gateway wrapper setup
TaylorMutch Jul 7, 2026
a621d93
fix(supervisor): avoid nft batch rollback on OCP
sjenning Jul 8, 2026
4c6db97
fix(kubernetes): preserve process identity in sidecar topology
TaylorMutch Jul 8, 2026
b92b86f
refactor(kubernetes): replace sidecar snapshots with control socket
TaylorMutch Jul 8, 2026
eecbb70
feat(kubernetes): support relaxed sidecar network identity
TaylorMutch Jul 8, 2026
fed7f4f
fix(sandbox): satisfy sidecar clippy lint
TaylorMutch Jul 8, 2026
202a4bd
refactor(kubernetes): standardize topology naming
TaylorMutch Jul 8, 2026
519ec54
fix(sandbox): satisfy linux clippy timeout import
TaylorMutch Jul 8, 2026
4e3795f
fix(kubernetes): support kata sidecar on ipv4 pods
TaylorMutch Jul 9, 2026
2089426
fix(kubernetes): satisfy linux clippy for sidecar fallback
TaylorMutch Jul 9, 2026
939554b
chore(kubernetes): remove stale supervisor topology references
TaylorMutch Jul 9, 2026
dad07c3
fix(kubernetes): enable sidecar binary policy inspection
TaylorMutch Jul 9, 2026
8e6fd24
fix(kubernetes): harden sidecar control boundary
TaylorMutch Jul 10, 2026
93b86f8
fix(kubernetes): couple sidecar supervisor lifecycles
TaylorMutch Jul 10, 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
51 changes: 51 additions & 0 deletions .agents/skills/debug-openshell-cluster/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,57 @@ kubectl -n openshell get configmap openshell-config -o jsonpath='{.data.gateway\
kubectl -n <sandbox-namespace> get sandbox <sandbox-name> -o jsonpath='{.spec.template.spec.serviceAccountName}{"\n"}'
```

If `topology = "sidecar"` is rendered under `[openshell.drivers.kubernetes]`,
sandbox pods should have an `openshell-network-init` init container running
`--mode=network-init`, an `agent` container running
`openshell-sandbox --mode=process`, and an `openshell-supervisor-network`
container running `--mode=network`. The init container owns nftables setup and
should be the only sidecar topology container with `NET_ADMIN`. It also needs
`CHOWN`/`FOWNER` to hand shared emptyDir state to the effective sidecar UID. The
default binary-aware network sidecar runs as UID 0 with primary GID
`sandbox_gid` and adds `SYS_PTRACE` plus `DAC_READ_SEARCH`. When
`process_binary_aware_network_policy = false`, it runs as the configured
non-root `proxy_uid` without those inspection capabilities. The pod `fsGroup`
is set to `sandbox_gid` in both modes.

In sidecar topology only the network sidecar should mount the gateway bootstrap
credentials (`openshell-sa-token` and `openshell-client-tls`). The process
container should not receive `OPENSHELL_ENDPOINT`, gateway TLS env vars, the
sandbox token file, or those credential mounts. Instead, the network sidecar
serves policy and provider environment state over the Unix control socket from
`OPENSHELL_SIDECAR_CONTROL_SOCKET` (`/run/openshell-sidecar/control.sock` by
default). The process supervisor must be the first and only client. After
validating its peer UID, GID, and PID, the sidecar unlinks the listener. If the
connection later closes, the network sidecar exits non-zero so Kubernetes can
restart it with a fresh listener. If the process supervisor fails before
launching the workload,
inspect both containers for control-socket bind, connect, bootstrap, or update
errors. If new SSH/exec sessions do not pick up refreshed provider environment,
inspect the network sidecar settings-poll logs and the process container logs
for provider environment update handling; the process container should consume
newer provider-env revisions without receiving gateway credentials.

The process container reports the workload entrypoint PID over the same control
socket, and the network sidecar uses that PID for binary-scoped policy
decisions through `/proc`. If rules with `policy.binaries` are unexpectedly
denied, inspect the sidecar control logs and confirm the pod has
`shareProcessNamespace: true`.
The shared state directory should preserve `sandbox_gid` inheritance
(`02775`). Sidecar SSH uses the Linux abstract socket
`@openshell-sidecar-ssh`; the network sidecar verifies its peer PID before
bridging gateway relay requests. No `ssh.sock` file should appear in the shared
state directory.
Inspect all three when sandbox registration or egress enforcement fails:

```bash
kubectl -n openshell get configmap openshell-config -o jsonpath='{.data.gateway\.toml}' | grep -E '^\[openshell\.drivers\.kubernetes\]|^topology\s*='
kubectl -n <sandbox-namespace> get pod <sandbox-pod> -o jsonpath='{range .spec.initContainers[*]}{.name}{" "}{.command}{"\n"}{end}'
kubectl -n <sandbox-namespace> get pod <sandbox-pod> -o jsonpath='{range .spec.containers[*]}{.name}{" "}{.command}{"\n"}{end}'
kubectl -n <sandbox-namespace> logs <sandbox-pod> -c openshell-network-init --tail=200
kubectl -n <sandbox-namespace> logs <sandbox-pod> -c openshell-supervisor-network --tail=200
kubectl -n <sandbox-namespace> logs <sandbox-pod> -c agent --tail=200
```

### Step 6: Check VM-Backed Gateways

Use the VM driver logs and host diagnostics available in the user's environment. Verify:
Expand Down
30 changes: 27 additions & 3 deletions .agents/skills/helm-dev-environment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,25 @@ mise run helm:skaffold:dev
mise run helm:skaffold:run
```

**Supervisor sidecar topology** (build once and leave running):
```bash
mise run helm:skaffold:run:sidecar
```

**Supervisor sidecar topology with TLS/mTLS enabled** (build once and leave running):
```bash
mise run helm:skaffold:run:sidecar-mtls
```

Both commands build the `gateway` and `supervisor` images and deploy the OpenShell Helm
chart. The `pkiInitJob` hook (a pre-install Job that runs `openshell-gateway generate-certs`)
generates mTLS secrets on first install. Envoy Gateway opt-in; see the Optional Add-ons section below.
chart. The sidecar profile renders an `openshell-network-init` init container for
nftables setup and an `openshell-supervisor-network` runtime sidecar for proxying.
Binary-aware policy mode runs that sidecar as UID 0 with `SYS_PTRACE` and
`DAC_READ_SEARCH`; relaxed mode can run it as the configured proxy UID. The
sidecar-mTLS profile reuses `ci/values-sidecar.yaml` and restores
`server.disableTls=false` inline for Skaffold. The `pkiInitJob` hook (a pre-install
Job that runs `openshell-gateway generate-certs`) generates mTLS secrets on first
install. Envoy Gateway opt-in; see the Optional Add-ons section below.

The gateway Service uses ClusterIP. Access is via Envoy Gateway (port `8080`) or `kubectl port-forward`.

Expand All @@ -74,7 +90,8 @@ create the Secret named `openshell-ha-pg` with a `uri` key, then run
### TLS behaviour

`ci/values-skaffold.yaml` sets `server.disableTls: true`, so Skaffold-based deploys run
plaintext by default. To test with TLS enabled, comment out that line and redeploy.
plaintext by default. To test sidecar topology with TLS enabled, use
`mise run helm:skaffold:run:sidecar-mtls`.

| Mode | `server.disableTls` | Gateway scheme |
|------|---------------------|----------------|
Expand Down Expand Up @@ -126,6 +143,12 @@ openshell sandbox list --gateway-endpoint https://localhost:8090
mise run helm:skaffold:delete
```

For a sidecar-profile deployment:

```bash
mise run helm:skaffold:delete:sidecar
```

### Delete the cluster entirely

```bash
Expand Down Expand Up @@ -250,6 +273,7 @@ for dependencies still declared in `Chart.yaml`.
| `deploy/helm/openshell/ci/values-gateway.yaml` | Envoy Gateway GRPCRoute + Gateway overlay |
| `deploy/helm/openshell/ci/values-high-availability.yaml` | HA test overlay (`replicaCount: 2` with external PostgreSQL Secret) |
| `deploy/helm/openshell/ci/values-keycloak.yaml` | Keycloak OIDC overlay |
| `deploy/helm/openshell/ci/values-sidecar.yaml` | Supervisor sidecar topology overlay for Kubernetes e2e/dev |
| `deploy/helm/openshell/ci/values-spire.yaml` | SPIFFE/SPIRE provider token grant overlay |
| `deploy/helm/openshell/ci/values-spire-stack.yaml` | SPIRE hardened chart values for local dev |
| `deploy/helm/openshell/ci/values-tls-disabled.yaml` | Lint-only: TLS + auth disabled (reverse-proxy edge termination) |
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/branch-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,16 +121,25 @@ jobs:
include:
- agent_sandbox_api: v1beta1
agent_sandbox_version: v0.5.0
topology: combined
extra_helm_values: ""
- agent_sandbox_api: v1alpha1
agent_sandbox_version: v0.4.6
topology: combined
extra_helm_values: ""
- agent_sandbox_api: v1beta1
agent_sandbox_version: v0.5.0
topology: sidecar
extra_helm_values: deploy/helm/openshell/ci/values-sidecar.yaml
permissions:
contents: read
packages: read
uses: ./.github/workflows/e2e-kubernetes-test.yml
with:
image-tag: ${{ github.sha }}
job-name: Kubernetes E2E (Rust smoke, Agent Sandbox ${{ matrix.agent_sandbox_api }})
job-name: Kubernetes E2E (Rust smoke, ${{ matrix.topology }}, Agent Sandbox ${{ matrix.agent_sandbox_api }})
agent-sandbox-version: ${{ matrix.agent_sandbox_version }}
extra-helm-values: ${{ matrix.extra_helm_values }}

kubernetes-ha-e2e:
needs: [pr_metadata, build-gateway, build-supervisor]
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

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

9 changes: 5 additions & 4 deletions architecture/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,11 @@ Runtime layout:
as a release artifact. Linux GNU VM driver binaries must not reference
`GLIBC_*` symbols newer than `GLIBC_2.28`; release workflows verify this
before publishing artifacts.
- **Supervisor**: `scratch` base, static musl binary at `/openshell-sandbox`.
Static linkage is required because the image is mounted/extracted into
sandbox environments (Docker extraction, Podman image volumes, Kubernetes
init-container copy-self) and cannot rely on a dynamic loader.
- **Supervisor**: Alpine base with `nftables`, static musl binary at
`/openshell-sandbox`. Static linkage keeps the binary usable when the image
is mounted/extracted into sandbox environments (Docker extraction, Podman
image volumes, Kubernetes init-container copy-self), while `nftables` supports
Kubernetes supervisor sidecar egress enforcement.

Gateway image builds bake the corresponding supervisor image tag into the
gateway binary so Docker sandboxes do not depend on `:latest` by default.
Expand Down
40 changes: 39 additions & 1 deletion architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,52 @@ The supervisor must be available inside each sandbox workload:
|---|---|
| Docker | Bind-mounted local supervisor binary, or a binary extracted from the configured supervisor image. |
| Podman | Read-only OCI image volume containing the supervisor binary. |
| Kubernetes | Sandbox pod image or pod template configuration. |
| Kubernetes | Supervisor image side-loaded into the sandbox pod by image volume or init container. |
| VM | Embedded in the guest rootfs bundle. |
| Extension | Defined by the out-of-tree driver. |

Driver-controlled environment variables must override sandbox image or template
values for sandbox ID, sandbox name, gateway endpoint, relay socket path, TLS
paths, and command metadata.

Kubernetes can run the supervisor in the default combined topology or in a
sidecar topology. Combined mode keeps network and process supervision in the
agent container. Sidecar mode runs network enforcement, the proxy, and gateway
session in a dedicated sidecar, while the agent container runs only the
process-supervision leaf and launches the user workload after the sidecar
serves bootstrap state over a local control socket. The network sidecar owns
gateway credentials and sends policy plus workload-facing provider environment
state to the process leaf over that socket. It also streams provider
environment updates after settings polls so future process sessions see
updated provider env without giving the process leaf gateway access. The
pre-workload process supervisor is the only accepted control client: the
network sidecar verifies its UID, GID, and PID with peer credentials, removes
the listener after accepting it, and ignores workload-supplied relay targets.
SSH relays use a Linux abstract socket and verify its peer PID against that
authenticated process-supervisor connection, so workload filesystem access
cannot replace the relay endpoint. Either supervisor exits when this control
connection closes. This couples their restart lifecycle and prevents a workload
that survives an isolated network-sidecar restart from becoming the next
authoritative control client. In sidecar mode, an init container performs the
privileged pod-network nftables setup with
`NET_ADMIN`. The default binary-aware network sidecar runs as UID 0 without
`NET_ADMIN` and adds `SYS_PTRACE` plus `DAC_READ_SEARCH` so it can resolve
cross-UID workload process/binary identity through shared `/proc`. Operators
can set the sidecar `process_binary_aware_network_policy` flag false to run the
sidecar as the configured non-root proxy UID, omit both inspection capabilities,
and downgrade network policy to endpoint/L7 matching without `policy.binaries`.
The init path applies nftables as individual commands so optional conntrack and
log expressions can fail without rolling back the required table, chain, and
reject rules.
The agent container runs as the resolved sandbox UID/GID with no added Linux
capabilities. Sidecar mode preserves gateway session and SSH behavior, but
treats the process leaf as network-only: Landlock filesystem policy and child
seccomp still apply where supported, while process privilege dropping and
supervisor identity mount isolation do not run because the agent container is
already unprivileged. Sidecar pods use a shared process namespace so the
network sidecar can resolve workload process and binary identity through
`/proc/<entrypoint-pid>`.

## Images

The gateway image and Helm chart are built from this repository. Sandbox images
Expand Down
7 changes: 6 additions & 1 deletion crates/openshell-core/src/grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,14 @@ async fn build_plain_channel(endpoint: &str) -> Result<Channel> {
.into_diagnostic()
.wrap_err_with(|| format!("failed to read client key from {key_path}"))?;

let tls_config = ClientTlsConfig::new()
let mut tls_config = ClientTlsConfig::new()
.ca_certificate(Certificate::from_pem(ca_pem))
.identity(Identity::from_pem(cert_pem, key_pem));
if let Ok(server_name) = std::env::var(sandbox_env::GATEWAY_TLS_SERVER_NAME)
&& !server_name.is_empty()
{
tls_config = tls_config.domain_name(server_name);
}

ep = ep
.tls_config(tls_config)
Expand Down
90 changes: 90 additions & 0 deletions crates/openshell-core/src/provider_credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,63 @@ impl ProviderCredentialState {
}
}

/// Build a static provider state from an already-prepared child
/// environment snapshot.
///
/// Kubernetes sidecar topology uses this in the process-only supervisor:
/// the network sidecar owns provider credential resolvers and sends the
/// workload-facing env map over a local control channel. The process leaf
/// must inject that map into child processes without re-placeholderizing it
/// or holding the gateway-side resolver material.
pub fn from_child_env_snapshot(revision: u64, child_env: HashMap<String, String>) -> Self {
let snapshot = Arc::new(ProviderCredentialSnapshot {
revision,
child_env,
dynamic_credentials: HashMap::new(),
});

Self {
inner: Arc::new(RwLock::new(ProviderCredentialStateInner {
current: snapshot,
generations: VecDeque::new(),
current_resolver: None,
combined_resolver: None,
suppressed_keys: HashSet::new(),
})),
}
}

/// Install an already-prepared child environment snapshot.
///
/// This is intentionally narrower than [`Self::install_environment`]: it
/// updates only the workload-facing env map and clears resolver state so a
/// process that does not own gateway/provider resolver material can still
/// pick up refreshed provider env for future child processes.
pub fn install_child_env_snapshot(
&self,
revision: u64,
mut child_env: HashMap<String, String>,
) -> usize {
let mut inner = self
.inner
.write()
.expect("provider credential state poisoned");

for key in &inner.suppressed_keys {
child_env.remove(key);
}

inner.current = Arc::new(ProviderCredentialSnapshot {
revision,
child_env,
dynamic_credentials: HashMap::new(),
});
inner.generations.clear();
inner.current_resolver = None;
inner.combined_resolver = None;
inner.current.child_env.len()
}

pub fn snapshot(&self) -> Arc<ProviderCredentialSnapshot> {
self.inner
.read()
Expand Down Expand Up @@ -594,6 +651,39 @@ mod tests {
);
}

#[test]
fn child_env_snapshot_install_updates_env_without_resolver_material() {
let state = ProviderCredentialState::from_child_env_snapshot(
1,
HashMap::from([
("GITHUB_TOKEN".to_string(), "old".to_string()),
("GCE_METADATA_HOST".to_string(), "marker".to_string()),
]),
);
state.remove_env_key("GCE_METADATA_HOST");

let env_count = state.install_child_env_snapshot(
2,
HashMap::from([
("GITHUB_TOKEN".to_string(), "new".to_string()),
("GCE_METADATA_HOST".to_string(), "marker".to_string()),
]),
);

let snapshot = state.snapshot();
assert_eq!(snapshot.revision, 2);
assert_eq!(env_count, 1);
assert_eq!(
snapshot.child_env.get("GITHUB_TOKEN").map(String::as_str),
Some("new")
);
assert!(!snapshot.child_env.contains_key("GCE_METADATA_HOST"));
assert!(
state.resolver().is_none(),
"child-env snapshots must not install provider resolver material"
);
}

#[test]
fn stale_generation_falls_back_to_current_credential_after_retention_window() {
let state = ProviderCredentialState::from_environment(
Expand Down
28 changes: 28 additions & 0 deletions crates/openshell-core/src/sandbox_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,34 @@ pub const SANDBOX_COMMAND: &str = "OPENSHELL_SANDBOX_COMMAND";
/// Deployment-controlled telemetry toggle propagated to the sandbox supervisor.
pub const TELEMETRY_ENABLED: &str = "OPENSHELL_TELEMETRY_ENABLED";

/// Supervisor pod/runtime topology. Kubernetes sidecar mode sets this to
/// `"sidecar"`; the default combined supervisor path omits it.
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";

/// Whether network policy evaluation must bind requests to the peer binary.
///
/// The default when unset is `"required"`. Kubernetes sidecar experiments may
/// set this to `"relaxed"` to enforce endpoint and L7 policy without per-binary
/// `/proc` identity binding.
pub const NETWORK_BINARY_IDENTITY: &str = "OPENSHELL_NETWORK_BINARY_IDENTITY";

/// Unix socket used by Kubernetes sidecar topology for local coordination.
///
/// The network sidecar owns gateway credentials and serves policy/provider
/// state over this socket instead of exposing gateway credentials to the agent
/// container.
pub const SIDECAR_CONTROL_SOCKET: &str = "OPENSHELL_SIDECAR_CONTROL_SOCKET";

/// Optional TLS server name override used when connecting to the gateway.
pub const GATEWAY_TLS_SERVER_NAME: &str = "OPENSHELL_GATEWAY_TLS_SERVER_NAME";

/// Directory where the network supervisor writes the proxy CA files consumed
/// by workload child processes.
pub const PROXY_TLS_DIR: &str = "OPENSHELL_PROXY_TLS_DIR";

Comment thread
TaylorMutch marked this conversation as resolved.
/// Path to the CA certificate for mTLS communication with the gateway.
pub const TLS_CA: &str = "OPENSHELL_TLS_CA";

Expand Down
Loading
Loading