Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d1a797a
feat(kubernetes): add sidecar supervisor topology
TaylorMutch Jul 2, 2026
566cf14
fix(supervisor): avoid similar process id names
TaylorMutch Jul 2, 2026
9a16c6b
fix(supervisor): avoid similar process id names
TaylorMutch Jul 2, 2026
ae3d3d8
fix(sandbox): avoid similar proxy id names
TaylorMutch Jul 3, 2026
53a5b90
docs(kubernetes): clarify sidecar topology limits
TaylorMutch Jul 6, 2026
4904e44
fix(kubernetes): keep sidecar process leaf capless
TaylorMutch Jul 6, 2026
e492342
fix(kubernetes): refresh sidecar provider env snapshots
TaylorMutch Jul 6, 2026
8e6c504
test(supervisor): align hot-swap identity regression
TaylorMutch Jul 6, 2026
254cdbe
fix(kubernetes): stage sidecar mtls files before proxy chown
TaylorMutch Jul 7, 2026
aa87aad
fix(kubernetes): simplify sidecar supervisor topology
TaylorMutch Jul 7, 2026
6e0e781
chore(helm): reuse sidecar skaffold values
TaylorMutch Jul 7, 2026
b21a98d
fix(supervisor): avoid similar iptables helper names
TaylorMutch Jul 7, 2026
969e97b
fix(e2e): harden kube gateway wrapper setup
TaylorMutch Jul 7, 2026
5681187
fix(supervisor): avoid nft batch rollback on OCP
sjenning Jul 8, 2026
cd0477f
fix(kubernetes): preserve process identity in sidecar topology
TaylorMutch Jul 8, 2026
43cce1d
refactor(kubernetes): replace sidecar snapshots with control socket
TaylorMutch Jul 8, 2026
0458baa
feat(kubernetes): support relaxed sidecar network identity
TaylorMutch Jul 8, 2026
d46cd48
fix(sandbox): satisfy sidecar clippy lint
TaylorMutch Jul 8, 2026
5d43818
refactor(kubernetes): standardize topology naming
TaylorMutch Jul 8, 2026
ac4211c
fix(sandbox): satisfy linux clippy timeout import
TaylorMutch Jul 8, 2026
49192b8
fix(kubernetes): support kata sidecar on ipv4 pods
TaylorMutch Jul 9, 2026
5ab5a52
fix(kubernetes): satisfy linux clippy for sidecar fallback
TaylorMutch Jul 9, 2026
e29baea
chore: merge main into sidecar topology branch
TaylorMutch Jul 9, 2026
552605c
chore(kubernetes): remove stale supervisor topology references
TaylorMutch Jul 9, 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
42 changes: 42 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,48 @@ 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 `proxy_uid`. The long-running
network sidecar runs as `proxy_uid` with primary GID `sandbox_gid`; the pod
`fsGroup` is also set to `sandbox_gid`.

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). 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`), and the SSH socket should be group-connectable (`0660`) so the
network sidecar can bridge gateway relay requests to the process supervisor.
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
28 changes: 25 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,23 @@ 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 a non-root `openshell-supervisor-network` runtime sidecar for
proxying. 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 +88,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 +141,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 +271,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
31 changes: 30 additions & 1 deletion architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,43 @@ 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. In sidecar
mode, an init container performs the privileged pod-network nftables setup with
`NET_ADMIN` and hands shared state ownership to the configured proxy UID; the
long-running network sidecar runs as that UID, does not keep `NET_ADMIN`, and
adds only `SYS_PTRACE` so it can resolve workload process/binary identity
through shared `/proc`. Operators can set the sidecar
`process_binary_aware_network_policy` flag false to omit `SYS_PTRACE`; that
downgrades 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 on lines +32 to +59

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do these constants need to be in core?

@TaylorMutch TaylorMutch Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They don't; I'll clean this up. It looks like this sandbox_env is all the configuration that's used by the supervisor, and the config for this isn't located in the driver. Not sure how best to clean this up.

/// Path to the CA certificate for mTLS communication with the gateway.
pub const TLS_CA: &str = "OPENSHELL_TLS_CA";

Expand Down
Loading
Loading