CNCF’s OpenShell-on-Kubernetes Guide Turns Agent Sandboxing Into a Two-Layer Split, but the Demo’s Seven Kyverno Policies Admit Secret Volumes, Oversized Init Containers and Unconfined Seccomp, and the OpenShell Release It Describes Is Five Behind the Current One
Nirmata’s CNCF guide says OpenShell controls the agent and Kyverno controls the rest; run offline, the demo’s seven Kyverno policies admit Secret volumes, oversized init containers and Unconfined...
Jim Bugwadia of Nirmata published “OpenShell on Kubernetes: A guide for platform engineers” as a CNCF member post on November 11. It explains NVIDIA’s OpenShell, a CNCF Sandbox project that runs each AI agent in a sandbox under a trusted supervisor, on top of the SIG Apps Agent Sandbox. It sets OpenShell beside Google’s Agent Substrate in a comparison table, and it finishes with seven Kyverno policies from nirmata/demo-openshell under one sentence: “OpenShell controls the agent, and Kyverno controls the rest.”
Table Of Content
- What the guide argues
- The statements that reproduce
- The test: the demo’s seven policies against 23 fixtures
- Setup
- Results
- Secret volumes: the policy checks environment variables
- Init containers and sidecars: the bounds read only containers
- Seccomp: the policy reads the Pod, the container can override it
- The fence selects by label, and nothing requires the label
- What the guide says is out of scope
- A patched set that still admits OpenShell’s own Sandbox
- What changed since the versions the guide pins
- Reading the guide with this in hand
- Reproduce it
- Sources
I checked the guide three ways. I ran the demo’s seven policies through the Kyverno CLI against 23 fixtures (Sandbox, Role, NetworkPolicy and Pod objects); there is no cluster involved, because this machine has no Docker, so the CLI evaluates the same policy expressions offline. I read OpenShell’s Kubernetes driver at v0.1.3, the release the demo installs, and at v0.1.8, the current one (I read OpenShell’s docs and source before for our post on Nadella’s insider-risk essay, which graded its audit-log and verification claims). And I checked the comparison table, the dates and the issues the guide cites against their sources. What came out:
- The guide’s own examples hold. All six rows of its Kyverno table reproduce: five attempts are denied with the messages it quotes, and the unsized Sandbox receives the defaults it describes before the oversized one is denied. Of the 16 statements in the guide that I could check against a source, 14 hold, one holds in part, and one points at a link that returns 404.
- Three near-misses pass all seven policies: a Secret mounted as a volume, an init container that asks for 64 CPUs and 512 GiB of memory, and a container-level Unconfined seccomp profile. A Sandbox without the boundary label, which the egress fence never selects, passes too. The verdicts are identical on Kyverno CLI v1.18.2, the demo’s version, and on v1.20.0, published on November 12.
- The obvious fixes would break OpenShell. The workload Pod that OpenShell’s own v0.1.3 driver builds carries a Secret volume, and its bootstrap init container declares no resources, so a ban on Secret volumes or a rule requiring resources on init containers would deny OpenShell’s own sandboxes. A patched set of eight policies, tested offline, closes the gaps and still admits a Sandbox shaped like the driver’s.
- The guide describes OpenShell v0.1.3, and the project moved. By v0.1.8, five releases later, new sandboxes open the control connection from the sandbox to its supervisor (since v0.1.6), and an experimental shared supervisor has shipped (also v0.1.6) that the guide still lists as roadmap. The Agent Sandbox release the demo pins, v1.0.5, is also five releases behind v1.0.10.
- Fine print. The demo installs the gateway with unauthenticated users allowed, which its own README says not to expose, and the link behind two Substrate table cells returns 404.
What the guide argues
The guide makes four moves. First, it describes OpenShell’s four YAML-declared controls: Landlock for the filesystem, a non-root user with no capabilities and seccomp for the process, default-deny outbound traffic with per-binary, per-host, per-port and per-method rules for the network, and placeholder credentials that the supervisor swaps for real keys. Second, it walks through the life of a sandbox on Kubernetes: the gateway records the request, the driver creates two NetworkPolicies and a suspended Sandbox resource, Agent Sandbox creates the workload Pod, OpenShell re-reads the admitted Pod, and a separate supervisor Pod connects. Third, it compares OpenShell with Agent Substrate in a table. Fourth, it argues for a split of duties: use RBAC to control who can create sandboxes, use Kyverno to control what a sandbox may contain, and let OpenShell control the agent.
The guide also states its own limits. It says OpenShell “checks only the Pod settings it needs to stay safe” and that “Which images run, how big a sandbox can get, which credentials it gets, and who can change OpenShell’s RBAC are left to the platform team.” It says the demo “leaves out four more kinds of policy”, and it tells readers that “For production, extend the policies to Pods and to any NetworkPolicy that selects OpenShell workloads.” The checks below test what is inside that boundary.
The statements that reproduce
These are the statements I could check against a primary source, with what I checked and what I found. Dates are UTC.
| The guide says | Checked against | Result |
|---|---|---|
| The demo runs “Kubernetes 1.35, Cilium 1.20, Agent Sandbox v1.0.5, OpenShell 0.1.3, and Kyverno 1.18.2” | The demo’s scripts/lib.sh, and the Kyverno Helm chart index (chart 3.8.2 carries appVersion v1.18.2) |
Holds |
| “Six are ValidatingPolicy resources and one is a MutatingPolicy, all written in CEL” | The seven policy files | Holds |
| Each denied attempt in the Kyverno table carries the message the guide quotes | Kyverno CLI, fixtures a1, a2, a3, a4, a6 and a7 below | Holds, messages identical |
| An unsized Sandbox “keeps the 100m CPU request that Kyverno added” | Mutated output for fixture a5 | Holds (100m request, 1 CPU limit) |
| OpenShell re-checks the admitted Pod for a “non-root UID and GID, no added capabilities, RuntimeDefault seccomp, no host namespaces, no ServiceAccount token, and the expected labels and volumes” | The driver’s validate_capability_free_workload_pod, v0.1.3 |
Holds; the code also checks restart policy, DNS posture, the scheduling gate, one sysctl and the bootstrap Secret |
| “Sandbox has no /scale subresource, so HPA, KEDA, and VPA cannot target a sandbox” | The CRD in the Agent Sandbox v1.0.5 and v1.0.10 release manifests | Holds on both: one served version (v1beta1), subresources holds only status |
| “WorkerPool has a /scale subresource” | Substrate’s workerpool_types.go |
Holds (a scale subresource marker) |
The worker flag max-actors defaults to 1,000 |
Substrate’s ateworker-gvisor and ateworker-microvm entry points |
Holds (default 1000 in both) |
| “A SPIFFE X.509 certificate per actor, valid for 1 hour” | Substrate’s docs/security/security.md |
Holds (“short lifetime (1h) SPIFFE ID X.509 certificate”) |
| Suspend and resume: “the goal is 100 ms at p95” | Substrate’s docs/concepts/architecture.md |
Holds (“Target: 100ms at 95th percentile”) |
| OpenShell “Voted in Sep 26, 2026”, Substrate “Voted in Sep 28, 2026” | The vote-closed comments on cncf/sandbox issues 522 and 523 | Holds (the vote-closed comments are stamped 08:15 and 08:16 UTC) |
| RFC 0012 “merged Sep 10, 2026” and “#3366, merged Sep 21” | GitHub merge times for pull requests 2048 and 3366 | Holds (September 10 at 21:50 and September 21 at 22:27) |
| On issue 3362 (Substrate as an isolation backend), “As of Oct 11 the issue is open. A contributor has +1’d it and asked whether the maintainers would accept it, but no maintainer has replied yet.” | The thread on November 12 | Holds, with an update: still open, five comments, none from a member or owner account, but a new comment on October 22 changes the picture (below) |
| An open issue (1719) asks for an OpenShellSandbox CRD and an operator | The issue | Holds; on October 27 the user JimBugwadia wrote that the community meeting “decided to start a working group for it” |
| “OpenFGA access policies are in progress” | Substrate’s security doc and AGENTS.md |
Partly: the security doc marks atespace containment “In Progress (embedded OpenFGA)”, while AGENTS.md says every control RPC is checked against an embedded OpenFGA model, “always enforced” |
| “the autoscaling demo notes that each worker holds one actor today”, and “the demo runs on kind only” | The linked path demos/autoscaled-workerpool on Substrate’s main branch |
Could not check: the link returns 404 and I found neither statement in the repository’s markdown files |
The test: the demo’s seven policies against 23 fixtures
Setup
The Kyverno CLI’s apply command evaluates policies against a resource file with no cluster (for where Kyverno sits in a platform, see our earlier post on it). It needs the CRD for any custom kind, so I pointed --crd-path at the Agent Sandbox v1.0.5 release manifest, whose one CRD serves only the v1beta1 version that every demo policy matches. The policies are the demo’s seven at commit 98eeb34, the head of its main branch on November 12. In the fixtures, a0 to a7 replay the guide’s own attempts and controls, b1 to b12 are near-misses I added, and g1 is a Sandbox shaped like the one the v0.1.3 driver builds, with its fields copied from driver.rs and example image digests. The CLI applies the mutating policy first and validates the result, as admission does.
The limit is the one you would expect: this runs the same expressions the admission controller runs, not the webhook path around them (failure policy, ordering against other webhooks, background scans). A pass here is not a pass on a live cluster.
Results
The left column is the demo’s seven policies. The right column is the patched set described further down. The sections after the matrix list the messages and show whether init containers carry a resources block after mutation.
case demo (7 policies) patched (8 policies)
a0-safe-control ADMIT ADMIT
a1-privileged DENY pod-security DENY pod-security
a2-wildcard-role DENY rbac DENY rbac
a3-allow-all-egress DENY network-fence DENY network-fence
a3b-original-fence ADMIT ADMIT
a4-mutable-tag DENY image-hygiene DENY image-hygiene
a5-no-resources ADMIT ADMIT
a6-oversized DENY resource-bounds DENY resource-bounds
a7-envfrom-secret DENY credentials DENY credentials
b1-secret-volume ADMIT DENY credentials
b10-bare-pod-privileged no policy matched no policy matched
b11-role-other-name no policy matched no policy matched
b12-no-boundary-label ADMIT DENY boundary-labels
b2-projected-secret ADMIT DENY credentials
b3-csi-secrets-store ADMIT DENY credentials
b4-env-secretkeyref DENY credentials DENY credentials
b5-init-privileged DENY pod-security DENY pod-security
b6-init-oversized ADMIT DENY resource-bounds
b6b-sidecar-oversized ADMIT DENY resource-bounds
b7-init-no-resources ADMIT ADMIT
b8-container-seccomp-unconfined ADMIT DENY pod-security
b9-second-networkpolicy no policy matched no policy matched
g1-gateway-shaped ADMIT ADMIT
messages from the demo set:
a1-privileged: [pod-security] Every sandbox container must run as non-root, drop all capabilities, and disable privilege escalation.
a2-wildcard-role: [rbac] OpenShell RBAC rules may not contain wildcards.
a3-allow-all-egress: [network-fence] The workload fence must isolate both ingress and egress and may not allow direct workload egress.
a4-mutable-tag: [image-hygiene] Sandbox images must use immutable sha256 digest references.
a6-oversized: [resource-bounds] A sandbox container may use at most 4 CPU, 8Gi memory, and 20Gi ephemeral storage.
a7-envfrom-secret: [credentials] Use OpenShell providers instead of injecting Kubernetes Secrets into sandbox environment variables.
b4-env-secretkeyref: [credentials] Use OpenShell providers instead of injecting Kubernetes Secrets into sandbox environment variables.
b5-init-privileged: [pod-security] Every sandbox container must run as non-root, drop all capabilities, and disable privilege escalation.
messages that only the patched set adds:
b1-secret-volume: [credentials] Only the OpenShell bootstrap Secret volume may be Secret-backed.
b12-no-boundary-label: [boundary-labels] Sandbox Pods must carry openshell.ai/boundary-role=workload so the workload NetworkPolicy selects them.
b2-projected-secret: [credentials] Only the OpenShell bootstrap Secret volume may be Secret-backed.
b3-csi-secrets-store: [credentials] Only the OpenShell bootstrap Secret volume may be Secret-backed.
b6-init-oversized: [resource-bounds] A sandbox container may use at most 4 CPU, 8Gi memory, and 20Gi ephemeral storage.
b6b-sidecar-oversized: [resource-bounds] A sandbox container may use at most 4 CPU, 8Gi memory, and 20Gi ephemeral storage.
b8-container-seccomp-unconfined: [pod-security] A container-level seccomp profile may only be RuntimeDefault or Localhost.
init containers carry a resources block after mutation (demo / patched):
b5-init-privileged: True / True
b6-init-oversized: True / True
b6b-sidecar-oversized: True / True
b7-init-no-resources: False / True
g1-gateway-shaped: False / True
The attempts the guide shows being denied (a1, a2, a3, a4, a6, a7) are denied here with the messages it quotes, a5 receives its defaults, and a0 and a3b, the controls, are admitted. The rest of the matrix is the part the guide does not show.
Secret volumes: the policy checks environment variables
The credentials policy blocks Secrets in environment variables and projected ServiceAccount tokens, and its message says so: “Use OpenShell providers instead of injecting Kubernetes Secrets into sandbox environment variables.” The demo README describes the same policy more broadly, as one that “requires provider-based credentials instead of raw Secrets or ServiceAccount tokens”, and the guide lists “Can an agent receive a raw API key?” among the questions Kyverno settles. Kubernetes has a second route: “Secrets can be mounted as data volumes or exposed as environment variables to be used by a container in a Pod.” Fixtures b1 (a Secret volume), b2 (a projected volume with a Secret source) and b3 (a CSI volume for the Secrets Store driver) are all admitted, while b4 (a secretKeyRef variable) is denied.
A blanket ban on Secret volumes is not available, because OpenShell’s own workload Pod has one. In v0.1.3 the pod-template builder adds a volume named openshell-sandbox-bootstrap whose source is a Secret, and the post-admission check fails the sandbox unless that volume exists with the expected Secret name and the driver’s rule holds: “bootstrap Secret must be mounted only by the trusted init container”. The tightened rule in my patched set therefore allows exactly that volume, and only the init container of that name may mount it. The exemption is by name; pinning the bootstrap image by digest, which the demo already does for the runtime image, narrows it further.
Init containers and sidecars: the bounds read only containers
Two policies read only spec.podTemplate.spec.containers: the resource bounds and the defaulting policy. The pod-security and image policies build their list from containers, init containers and ephemeral containers. Fixture b6 gives an init container 64 CPUs, 512 GiB of memory and 1 TiB of ephemeral storage, with every other field compliant, and all seven policies admit it. So does b6b, where the same container has restartPolicy: Always, which makes it a sidecar that, in the Kubernetes docs’ words, “will start and remain running during the entire life of the Pod”. The init-container docs say that “The highest of any particular resource request or limit defined on all init containers is the effective init request/limit” and that “If any resource has no resource limit specified this is considered as the highest limit.” The Pod’s effective request is the higher of the app-container sum and that init figure. Computed that way (my arithmetic, from the rule in the docs), the fixtures come out like this:
case effective CPU request effective memory request containers with no CPU limit
a0-safe-control 0.1 0.125 Gi -
b6-init-oversized 64.0 512.000 Gi -
b6b-sidecar-oversized 64.1 512.125 Gi -
b7-init-no-resources 0.1 0.125 Gi setup
g1-gateway-shaped 0.0 0.000 Gi agent, openshell-sandbox-bootstrap
The guide says “Even a compromised gateway cannot push a privileged, unpinned, or oversized Sandbox through admission.” Privileged and unpinned hold for init containers too (b5 is denied, and the image rule’s expression reads every container). Oversized does not.
OpenShell’s own init containers explain the scoping. In v0.1.3 the bootstrap init container is built with a security context and mounts but no resources field (the last row above, g1, shows the effect: its agent and bootstrap containers have no CPU limit as built), so a rule that requires every init container to declare resources would deny OpenShell’s sandboxes. My patched set runs the defaulting mutation over init containers as well, so the bootstrap container receives the same defaults as the agent before the bounds are checked. I did not test what a 1 CPU, 2 GiB ephemeral-storage default does to the workspace-seeding init container that the driver adds when persistence is on.
Seccomp: the policy reads the Pod, the container can override it
enforce-sandbox-pod-security reads the Pod-level seccompProfile. The Kubernetes API reference says: “If seccomp options are provided at both the pod & container level, the container options override the pod options.” Fixture b8 sets RuntimeDefault on the Pod and Unconfined on the container, and it is admitted, although the demo README describes the policy as enforcing “seccomp-confined workloads”. The driver’s admitted-Pod check has the same shape. The only seccomp comparison in its validation function reads the Pod-level security context, in v0.1.3 and in v0.1.8, and its per-container check covers user and group IDs, non-root, privilege escalation and capabilities. What a container-level downgrade would mean in practice depends on the seccomp filter that OpenShell’s sandbox runtime applies from inside, which I did not test.
The fence selects by label, and nothing requires the label
The workload fence, the NetworkPolicy named openshell-sandbox-workloads, selects Pods by the label openshell.ai/boundary-role=workload and denies all egress. The driver’s comment on it reads: “An explicit empty list selects the pod for egress and allows no new workload-initiated connections, including DNS and the Kubernetes API.” The driver puts the label on the pod template, and none of the seven policies requires it. Fixture b12 is a compliant Sandbox without the label: it is admitted, and the fence does not select its Pod, so whether that Pod can reach the network depends on whatever else the cluster enforces. The gateway path does not have this problem, and since v0.1.5 the gateway rejects pod labels in sandbox templates unless an operator allowlists them. The gap matters for the cases the guide hands to Kyverno: a Sandbox created without the gateway, by a cluster admin or by a compromised gateway ServiceAccount.
What the guide says is out of scope
Fixtures b9 (a second NetworkPolicy that selects the workload Pods and allows all egress), b10 (a privileged bare Pod) and b11 (a wildcard Role not named openshell-*) match no policy at all. The guide says so itself. One thing has moved under that advice: in v0.1.8’s driver, runtime-initiated control, the default for new sandboxes since v0.1.6, comes with an extra per-sandbox NetworkPolicy that has an egress rule, and the driver’s comment on it reads “Egress is required because the namespace fence denies all workload egress”. A rule for any NetworkPolicy that selects workloads therefore has to check where egress may go, not forbid egress.
A patched set that still admits OpenShell’s own Sandbox
The right-hand column of the matrix comes from eight policies: the demo’s seven with five edits, made by make_patched.py below.
protect-sandbox-credentialsdenies any Secret-backed volume (Secret, projected Secret source, CSI, flexVolume) except theopenshell-sandbox-bootstrapSecret volume, and lets only the init container of that name mount it.enforce-sandbox-pod-securityrequires a container-level seccomp profile, if present, to be RuntimeDefault or Localhost.enforce-sandbox-resource-boundschecks init containers as well as containers.default-sandbox-resourcesruns its two mutations over init containers too, so containers that declare nothing get the defaults before the bounds apply.- A new
require-boundary-labelspolicy requires the workload label on the pod template. Scope it to the namespace OpenShell uses before enforcing it cluster-wide.
The matrix shows the result. The gaps b1, b2, b3, b6, b6b, b8 and b12 are denied, b7 is admitted with its init container defaulted (the last section of the output shows False / True), all the guide’s attempts behave as before, and g1, the Sandbox shaped like the driver’s, is admitted by both sets.
The caveats belong with the result. Everything here is offline, as above. The g1 fixture is reconstructed from the v0.1.3 driver’s code with example image digests, not captured from a cluster, so it shows that the rules do not contradict the driver as I read it, not that a live install passes. I did not rebuild it for v0.1.8. The bootstrap exemption is by name. And b9, b10 and b11 are still unmatched by design.
What changed since the versions the guide pins
The demo pins OpenShell 0.1.3 (published October 9), Agent Sandbox v1.0.5 (October 1) and Kyverno chart 3.8.2, and it pins three OpenShell image digests that would need new values on every upgrade. By the guide’s date, OpenShell had shipped five more releases and Agent Sandbox five more patch releases; Kyverno’s v1.20.0 followed on November 12. What the OpenShell releases changed touches sentences in the guide:
| Release | What the release notes say | The guide sentence it touches |
|---|---|---|
| v0.1.5, October 20 | “On Kubernetes, sandbox templates that set pod annotations or labels are now rejected unless an operator allowlists those keys” | No sentence in the guide; context for the label gap above |
| v0.1.6, October 27 | “New sandboxes on Kubernetes, Docker, Podman, and VM now open the control connection from the sandbox to its supervisor” | “The supervisor opens the only connection to it” and “The agent Pod has no outbound network access of its own.” The driver’s config calls supervisor-initiated mode the “original mode”. |
| v0.1.6, October 27 | “Kubernetes gateways can run an experimental shared supervisor that hosts many sandboxes in one process.” Opt-in, with supervisor_topology = "gateway"; the default is still a supervisor Pod per sandbox. |
Note 2: “a Supervisor Pod per agent provides strong isolation, but may not scale well in large environments. The roadmap includes flexible deployment options (see RFC 0018).” |
| v0.1.8, November 10 | “Deleting a sandbox on Kubernetes no longer waits out the pod’s 30-second termination grace period.” A late status update during a stop no longer flips the sandbox back to Provisioning. | The stop, start and delete paragraph |
The shared-supervisor row matters most. RFC 0018 is marked state: draft in the v0.1.8 tree and says “It does not pick one yet”, and its pull request (4377) merged on October 26 with the note that it “only adds the RFC and changes no code”. The implementation shipped in v0.1.6 the next day, and a commenter on issue 3362 had already measured it on October 22: “Per sandbox it goes from 2 pods and 16.4 MiB of supervisor memory to 1 pod and about 5.4 MiB.” It has limits. The driver’s config check rejects SPIFFE provider settings for it with “supervisor_topology=gateway does not support provider_spiffe_workload_api_socket_path; a shared supervisor has no per-sandbox workload identity”, and in the RFC thread a commenter wrote that sandbox-specific workload identity from a trusted SPIFFE provider is a must-have for NVIDIA AI Factory’s adoption. That is the same identity row that the guide’s table sets against Substrate’s per-actor SPIFFE certificates.
The same comment on issue 3362 bears on the guide’s line that a Substrate backend would bring “OpenShell’s per-request policy with Substrate’s density”. It reports that on gVisor, runsc “gives ENOSYS for Landlock and EINVAL for SECCOMP_FILTER_FLAG_NEW_LISTENER, so openshell-sandbox fails closed”, while on Substrate’s microVM class “the stock runtime qualifies”, also after suspend and resume in three of three runs, by the commenter’s account. That was posted twenty days before the guide, and the guide’s “As of Oct 11” status is its only report on the issue.
Two smaller items. The guide’s Note 1 says sandboxes can only be created through the Gateway API; an out-of-tree operator, lensapp/openshell-k8s-operator, was announced on issue 1719 on July 24 with “We’ve built this as an out-of-tree operator, now usable”, and the guide does not mention it. And the demo’s README says the gateway runs with OpenShell’s unauthenticated local-development principal, “do not expose this configuration outside a trusted local development machine”; the chart’s own comment on that setting reads “UNSAFE: accept unauthenticated CLI/user requests as a local developer principal.” The guide’s lifecycle step, “It authenticates the caller”, describes the design, not the demo’s configuration.
Reading the guide with this in hand
- Treat the seven policies as a starting point. The demo README is open about it: “Advanced attestation, vulnerability, runtime-isolation, CNI-specific, and lifecycle policies are deliberately not installed without their required trust and infrastructure configuration.” The three near-misses and the label are inside the policies it does install.
- Test policies against fixtures, in CI. The CLI needs no cluster, and a fixture built from what the driver emits catches the case where a tightened rule would deny OpenShell itself. Rebuild the fixture on each OpenShell upgrade; the Kubernetes driver crate differs by about 5,200 lines between v0.1.3 and v0.1.8 (my diff count).
- Re-check the controls that the guide attributes to Kyverno against the Pod the cluster actually runs. Seccomp, resources and credentials can each be set in more than one place: the Pod, a container, an init container or sidecar, a volume.
- Read the Substrate table as a snapshot. Most of its checkable cells hold, one rests on a dead link, and the supervisor and isolation-backend rows are moving: a shared supervisor now exists behind an experimental flag, and the Substrate backend proposal is still waiting for a maintainer.
- Pin, then budget for re-pinning. The demo pins the Kubernetes node image, Cilium, Agent Sandbox, OpenShell, three OpenShell image digests and Kyverno. OpenShell and Agent Sandbox both ship weekly.
Reproduce it
The setup is below, followed by the four scripts. Save them in one folder as make_cases.py, make_patched.py, run_cases.py and effective_size.py. The outputs above come from these files, run in that order with Python 3.13.14 (standard library only) and the Kyverno CLI v1.18.2 on Windows 11. The CLI v1.20.0 gave the same verdicts and messages.
#!/usr/bin/env bash
# reproduce.sh: the setup around the four Python scripts (Git Bash on Windows 11; use the Linux or macOS
# release assets of the Kyverno CLI elsewhere). Nothing here needs a cluster.
set -euo pipefail
mkdir -p lab/crds && cd lab
# 1. Kyverno CLI v1.18.2 (the version the demo installs) and the Agent Sandbox v1.0.5 manifest, whose one CRD
# (sandboxes.agents.x-k8s.io, served version v1beta1) lets `kyverno apply` resolve the Sandbox kind
curl -sL -o kyverno-cli.zip https://github.com/kyverno/kyverno/releases/download/v1.18.2/kyverno-cli_v1.18.2_windows_x86_64.zip
python -I -c "import zipfile; zipfile.ZipFile('kyverno-cli.zip').extractall('.')"
curl -sL -o crds/sandbox-v1.0.5.yaml https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v1.0.5/sandbox.yaml
# 2. The demo's seven policies at the commit I used
git clone -q https://github.com/nirmata/demo-openshell ../demo-openshell
git -C ../demo-openshell checkout -q 98eeb3452b941ab46904589a16ce8f589af388de
cp -r ../demo-openshell/config/kyverno/policies policies
# 3. Fixtures, the patched policy set, and the run (save the four scripts from this page next to this file)
python -I make_cases.py
python -I make_patched.py
python -I run_cases.py ./kyverno.exe
python -I effective_size.py a0-safe-control b6-init-oversized b6b-sidecar-oversized b7-init-no-resources g1-gateway-shaped
# make_cases.py
# make_cases.py: writes one JSON fixture per case into ./cases (standard library only).
# a0..a7 replay the six attempts in the guide's Kyverno table (plus a safe control and the original fence)
# b1..b12 are near-misses I added
# g1 is shaped like the Sandbox that OpenShell v0.1.3's Kubernetes driver builds (fields copied from
# crates/openshell-driver-kubernetes/src/driver.rs; image references are example digests)
import json
import pathlib
HERE = pathlib.Path(__file__).resolve().parent
OUT = HERE / "cases"
OUT.mkdir(exist_ok=True)
IMG = "nicolaka/netshoot@sha256:a20c2531bf35436ed3766cd6cfe89d352b050ccc4d7005ce6400adf97503da1b"
RUNTIME_IMG = "registry.example/openshell-sandbox-runtime@sha256:" + "1" * 64
LABELS = {"openshell.ai/boundary-role": "workload", "openshell.ai/boundary-pair": "pair-demo"}
SMALL = {
"requests": {"cpu": "100m", "memory": "128Mi", "ephemeral-storage": "256Mi"},
"limits": {"cpu": "1", "memory": "1Gi", "ephemeral-storage": "2Gi"},
}
BOOT = "openshell-sandbox-bootstrap"
def container(name="agent", **extra):
c = {
"name": name,
"image": IMG,
"resources": json.loads(json.dumps(SMALL)),
"securityContext": {
"runAsNonRoot": True,
"allowPrivilegeEscalation": False,
"capabilities": {"drop": ["ALL"]},
},
}
c.update(extra)
return c
def with_security(**sc):
c = container()
c["securityContext"].update(sc)
return c
def sandbox(name, containers=None, labels=True, **podspec):
spec = {
"automountServiceAccountToken": False,
"securityContext": {"seccompProfile": {"type": "RuntimeDefault"}},
"containers": containers if containers is not None else [container()],
}
spec.update(podspec)
template = {"spec": spec}
if labels:
template["metadata"] = {"labels": dict(LABELS)}
return {
"apiVersion": "agents.x-k8s.io/v1beta1",
"kind": "Sandbox",
"metadata": {"name": name, "namespace": "openshell"},
"spec": {"podTemplate": template},
}
def netpol(name, egress=None):
spec = {
"podSelector": {"matchLabels": {"openshell.ai/boundary-role": "workload"}},
"policyTypes": ["Ingress", "Egress"],
"ingress": [{
"from": [{"podSelector": {"matchLabels": {"openshell.ai/boundary-role": "supervisor"}}}],
"ports": [{"protocol": "TCP", "port": 5500}],
}],
}
if egress is not None:
spec["egress"] = egress
return {"apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy",
"metadata": {"name": name, "namespace": "openshell"}, "spec": spec}
def role(name):
return {"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role",
"metadata": {"name": name, "namespace": "openshell"},
"rules": [{"apiGroups": ["*"], "resources": ["*"], "verbs": ["*"]}]}
def gateway_shaped():
sc = {"runAsUser": 1000, "runAsGroup": 1000, "runAsNonRoot": True,
"allowPrivilegeEscalation": False, "capabilities": {"drop": ["ALL"]}}
agent = {
"name": "agent", "image": IMG, "securityContext": dict(sc),
"volumeMounts": [
{"name": "openshell-runtime", "mountPath": "/.openshell/runtime", "readOnly": True},
{"name": "openshell-runtime-state", "mountPath": "/.openshell/state"},
{"name": "openshell-pod-identity", "mountPath": "/.openshell/pod-identity", "readOnly": True},
{"name": "openshell-run", "mountPath": "/run"},
],
}
bootstrap = {
"name": BOOT, "image": RUNTIME_IMG, "command": ["/openshell-sandbox", "bootstrap"],
"securityContext": dict(sc, readOnlyRootFilesystem=True),
"volumeMounts": [
{"name": BOOT, "mountPath": "/.openshell/bootstrap", "readOnly": True},
{"name": "openshell-runtime", "mountPath": "/.openshell/runtime"},
{"name": "openshell-runtime-state", "mountPath": "/.openshell/state"},
],
}
spec = {
"automountServiceAccountToken": False,
"hostNetwork": False, "hostPID": False, "hostIPC": False, "shareProcessNamespace": False,
"dnsPolicy": "None", "dnsConfig": {"nameservers": ["127.0.0.53"]},
"schedulingGates": [{"name": "openshell.ai/bootstrap"}],
"securityContext": {
"runAsUser": 1000, "runAsGroup": 1000, "runAsNonRoot": True, "fsGroup": 1000,
"supplementalGroups": [], "supplementalGroupsPolicy": "Strict",
"seccompProfile": {"type": "RuntimeDefault"},
"sysctls": [{"name": "net.ipv4.ip_unprivileged_port_start", "value": "0"}],
},
"volumes": [
{"name": "openshell-runtime", "emptyDir": {"medium": "Memory"}},
{"name": "openshell-runtime-state", "emptyDir": {"medium": "Memory"}},
{"name": BOOT, "secret": {"secretName": "example-generation-secret", "defaultMode": 288}},
{"name": "openshell-pod-identity",
"downwardAPI": {"items": [{"path": "uid", "fieldRef": {"fieldPath": "metadata.uid"}}]}},
{"name": "openshell-run", "emptyDir": {"medium": "Memory"}},
],
"initContainers": [bootstrap],
"containers": [agent],
}
return {"apiVersion": "agents.x-k8s.io/v1beta1", "kind": "Sandbox",
"metadata": {"name": "gateway-shaped", "namespace": "openshell"},
"spec": {"podTemplate": {"metadata": {"labels": dict(LABELS)}, "spec": spec}}}
cases = {}
# --- the guide's own table (plus a safe control) ---
cases["a0-safe-control"] = sandbox("safe")
cases["a1-privileged"] = sandbox("a1", [with_security(privileged=True)])
cases["a2-wildcard-role"] = role("openshell-wildcard-demo")
cases["a3-allow-all-egress"] = netpol("openshell-sandbox-workloads", egress=[{}])
cases["a3b-original-fence"] = netpol("openshell-sandbox-workloads")
cases["a4-mutable-tag"] = sandbox("a4", [container(image="nicolaka/netshoot:v0.13")])
c5 = container()
del c5["resources"]
cases["a5-no-resources"] = sandbox("a5", [c5])
big = container()
big["resources"] = {"requests": {"cpu": "100m", "memory": "128Mi", "ephemeral-storage": "256Mi"},
"limits": {"cpu": "8", "memory": "16Gi", "ephemeral-storage": "40Gi"}}
cases["a6-oversized"] = sandbox("a6", [big])
cases["a7-envfrom-secret"] = sandbox("a7", [container(envFrom=[{"secretRef": {"name": "llm-api-key"}}])])
# --- near-misses (mine) ---
mount = [{"name": "key", "mountPath": "/run/key", "readOnly": True}]
cases["b1-secret-volume"] = sandbox(
"b1", [container(volumeMounts=mount)],
volumes=[{"name": "key", "secret": {"secretName": "llm-api-key"}}])
cases["b2-projected-secret"] = sandbox(
"b2", [container(volumeMounts=mount)],
volumes=[{"name": "key", "projected": {"sources": [{"secret": {"name": "llm-api-key"}}]}}])
cases["b3-csi-secrets-store"] = sandbox(
"b3", [container(volumeMounts=mount)],
volumes=[{"name": "key", "csi": {"driver": "secrets-store.csi.k8s.io", "readOnly": True,
"volumeAttributes": {"secretProviderClass": "llm-api-key"}}}])
cases["b4-env-secretkeyref"] = sandbox(
"b4", [container(env=[{"name": "API_KEY", "valueFrom": {"secretKeyRef": {"name": "llm-api-key", "key": "key"}}}])])
init_priv = container("setup")
init_priv["securityContext"]["privileged"] = True
cases["b5-init-privileged"] = sandbox("b5", initContainers=[init_priv])
init_big = container("setup")
init_big["resources"] = {"requests": {"cpu": "64", "memory": "512Gi", "ephemeral-storage": "1Ti"},
"limits": {"cpu": "64", "memory": "512Gi", "ephemeral-storage": "1Ti"}}
cases["b6-init-oversized"] = sandbox("b6", initContainers=[init_big])
side_big = container("sidecar", restartPolicy="Always")
side_big["resources"] = json.loads(json.dumps(init_big["resources"]))
cases["b6b-sidecar-oversized"] = sandbox("b6b", initContainers=[side_big])
init_bare = container("setup")
del init_bare["resources"]
cases["b7-init-no-resources"] = sandbox("b7", initContainers=[init_bare])
cases["b8-container-seccomp-unconfined"] = sandbox(
"b8", [with_security(seccompProfile={"type": "Unconfined"})])
cases["b9-second-networkpolicy"] = netpol("sandbox-extra-egress", egress=[{}])
pod = sandbox("b10")["spec"]["podTemplate"]["spec"]
pod["containers"] = [with_security(privileged=True)]
cases["b10-bare-pod-privileged"] = {"apiVersion": "v1", "kind": "Pod",
"metadata": {"name": "b10", "namespace": "openshell"}, "spec": pod}
cases["b11-role-other-name"] = role("sandbox-admin")
cases["b12-no-boundary-label"] = sandbox("b12", labels=False)
cases["g1-gateway-shaped"] = gateway_shaped()
for old in OUT.glob("*.json"):
old.unlink()
for name, obj in cases.items():
(OUT / f"{name}.json").write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
print(len(cases), "fixtures written to", OUT)
# make_patched.py
# make_patched.py: builds ./policies_patched from ./policies (the demo's seven) with five edits.
# Standard library only. Every edit is plain text surgery so the diff is easy to read.
import pathlib
import shutil
HERE = pathlib.Path(__file__).resolve().parent
SRC = HERE / "policies"
DST = HERE / "policies_patched"
if DST.exists():
shutil.rmtree(DST)
shutil.copytree(SRC, DST)
def read(name):
return (DST / name).read_text(encoding="utf-8")
def write(name, text):
(DST / name).write_text(text, encoding="utf-8", newline="\n")
def replace_once(text, old, new):
assert text.count(old) == 1, (old, text.count(old))
return text.replace(old, new)
# 1. credentials: no Secret-backed volume except the bootstrap volume, mounted only by the bootstrap init container
t = read("protect-sandbox-credentials.yaml")
t = replace_once(t, " validations:\n", """ - name: secretBackedVolumes
expression: >-
variables.podSpec.?volumes.orValue([]).filter(v,
has(v.secret) || has(v.csi) || has(v.flexVolume) ||
v.?projected.sources.orValue([]).exists(s, has(s.secret)))
- name: bootstrapMounts
expression: "'openshell-sandbox-bootstrap'"
validations:
""")
t = t.rstrip("\n") + """
- expression: >-
variables.secretBackedVolumes.all(v, v.name == variables.bootstrapMounts && has(v.secret))
message: Only the OpenShell bootstrap Secret volume may be Secret-backed.
- expression: >-
(variables.podSpec.containers + variables.podSpec.?ephemeralContainers.orValue([])).all(c,
!c.?volumeMounts.orValue([]).exists(m, m.name == variables.bootstrapMounts)) &&
variables.podSpec.?initContainers.orValue([]).all(c,
c.name == variables.bootstrapMounts ||
!c.?volumeMounts.orValue([]).exists(m, m.name == variables.bootstrapMounts))
message: Only the init container named openshell-sandbox-bootstrap may mount the bootstrap Secret.
"""
write("protect-sandbox-credentials.yaml", t)
# 2. pod security: a container-level seccomp profile must be RuntimeDefault or Localhost
t = read("enforce-sandbox-pod-security.yaml")
t = t.rstrip("\n") + """
- expression: >-
variables.allContainers.all(c,
c.?securityContext.seccompProfile.type.orValue('RuntimeDefault') in ['RuntimeDefault', 'Localhost'])
message: A container-level seccomp profile may only be RuntimeDefault or Localhost.
"""
write("enforce-sandbox-pod-security.yaml", t)
# 3. resource bounds: include init containers (and sidecar-style init containers)
t = read("enforce-sandbox-resource-bounds.yaml")
t = replace_once(t, "expression: object.spec.podTemplate.spec.containers\n",
"expression: object.spec.podTemplate.spec.containers + object.spec.podTemplate.spec.?initContainers.orValue([])\n")
write("enforce-sandbox-resource-bounds.yaml", t)
# 4. defaults: run the same two mutations over initContainers (the driver's init containers declare no resources)
t = read("default-sandbox-resources.yaml")
head, sep, mutations = t.partition(" mutations:\n")
assert sep
init_mutations = (mutations
.replace("variables.containerIndexes", "variables.initContainerIndexes")
.replace(".spec.containers[i]", ".spec.initContainers[i]")
.replace("spec/containers/", "spec/initContainers/")
.replace("# Create empty", "# (init containers) Create empty")
.replace("# Default each", "# (init containers) Default each"))
head = replace_once(head, " - name: defaultResources\n",
" - name: initContainerIndexes\n"
" expression: lists.range(object.spec.podTemplate.spec.?initContainers.orValue([]).size())\n"
" - name: defaultResources\n")
write("default-sandbox-resources.yaml", head + sep + mutations.rstrip("\n") + "\n" + init_mutations)
# 5. new: a Sandbox must carry the boundary labels that the workload NetworkPolicy selects
(DST / "require-boundary-labels.yaml").write_text("""apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: require-boundary-labels
spec:
validationActions: [Deny]
evaluation:
background:
enabled: true
matchConstraints:
resourceRules:
- apiGroups: ["agents.x-k8s.io"]
apiVersions: ["v1beta1"]
operations: ["CREATE", "UPDATE"]
resources: ["sandboxes"]
validations:
- expression: >-
object.spec.podTemplate.?metadata.labels['openshell.ai/boundary-role'].orValue('') == 'workload'
message: Sandbox Pods must carry openshell.ai/boundary-role=workload so the workload NetworkPolicy selects them.
""", encoding="utf-8", newline="\n")
print("wrote", len(list(DST.glob("*.yaml"))), "policies to", DST)
# run_cases.py
# run_cases.py: run every fixture in ./cases through `kyverno apply`, once with the demo's seven policies
# (./policies) and once with the patched set (./policies_patched, built by make_patched.py).
# Usage: python -I run_cases.py PATH/TO/kyverno[.exe]
# Standard library only.
import pathlib
import re
import subprocess
import sys
HERE = pathlib.Path(__file__).resolve().parent
KYVERNO = sys.argv[1]
CRD = HERE / "crds" / "sandbox-v1.0.5.yaml" # Agent Sandbox v1.0.5 release manifest
SETS = {"demo": HERE / "policies", "patched": HERE / "policies_patched"}
FAIL_RE = re.compile(r"policy (\S+) -> resource \S+ failed:\n((?:\d+ - .*\n?)+)")
SUMMARY_RE = re.compile(r"pass: (\d+), fail: (\d+), warn: (\d+), error: (\d+), skip: (\d+)")
SHORT = {
"enforce-sandbox-pod-security": "pod-security", "enforce-sandbox-image-hygiene": "image-hygiene",
"enforce-sandbox-resource-bounds": "resource-bounds", "protect-sandbox-credentials": "credentials",
"restrict-openshell-rbac": "rbac", "verify-openshell-network-boundary": "network-fence",
"require-boundary-labels": "boundary-labels",
}
def run(policies, case, tag):
mutated = HERE / "out" / "mutated" / tag
mutated.mkdir(parents=True, exist_ok=True)
mfile = mutated / f"{case.stem}.yaml"
if mfile.exists():
mfile.unlink()
p = subprocess.run([KYVERNO, "apply", str(policies), "--resource", str(case), "--crd-path", str(CRD),
"--remove-color", "-o", str(mfile)], capture_output=True, text=True,
encoding="utf-8", errors="replace")
text = (p.stdout or "") + (p.stderr or "")
denied = []
for policy, block in FAIL_RE.findall(text):
for line in block.strip().splitlines():
denied.append((policy, re.sub(r"^\d+ -\s+(?:validation\s+)?", "", line).strip()))
m = SUMMARY_RE.search(text)
counts = tuple(int(x) for x in m.groups()) if m else None
if counts is None:
verdict = "ERROR " + (text.strip().splitlines()[-1][:120] if text.strip() else "no output")
elif denied:
verdict = "DENY"
elif counts[0] + counts[1] == 0:
verdict = "no policy matched"
else:
verdict = "ADMIT"
return verdict, denied, mfile
def init_has_resources(mfile):
if not mfile.exists():
return None
lines = mfile.read_text(encoding="utf-8").splitlines()
for i, line in enumerate(lines):
if re.match(r"^ {6}initContainers:$", line):
block = []
for nxt in lines[i + 1:]:
if nxt.startswith(" -") or nxt.startswith(" "):
block.append(nxt)
else:
break
return any(b.strip() == "resources:" for b in block)
return None
rows = []
for case in sorted((HERE / "cases").glob("*.json")):
res = {tag: run(path, case, tag) for tag, path in SETS.items() if path.exists()}
rows.append((case.stem, res))
w = max(len(r[0]) for r in rows)
def cell(r):
verdict, denied, _ = r
if denied:
names = sorted({SHORT.get(p, p) for p, _ in denied})
return "DENY " + "+".join(names)
return verdict
print(f"{'case':<{w}} {'demo (7 policies)':<30} patched (8 policies)")
for name, res in rows:
print(f"{name:<{w}} {cell(res['demo']):<30} {cell(res['patched']) if 'patched' in res else '-'}")
print("\nmessages from the demo set:")
for name, res in rows:
for pol, msg in res["demo"][1]:
print(f" {name}: [{SHORT.get(pol, pol)}] {msg}")
print("\nmessages that only the patched set adds:")
for name, res in rows:
if "patched" not in res:
continue
old = {(p, m) for p, m in res["demo"][1]}
for pol, msg in res["patched"][1]:
if (pol, msg) not in old:
print(f" {name}: [{SHORT.get(pol, pol)}] {msg}")
print("\ninit containers carry a resources block after mutation (demo / patched):")
for name, res in rows:
d = init_has_resources(res["demo"][2])
p = init_has_resources(res["patched"][2]) if "patched" in res else None
if d is not None or p is not None:
print(f" {name}: {d} / {p}")
# effective_size.py
# effective_size.py: effective Pod CPU and memory request of a fixture, using the rule in the Kubernetes
# "Init Containers" and "Sidecar Containers" docs: the Pod's effective request is the higher of
# (sum of app containers + all sidecars) and (the largest request any init container needs at its start,
# which is its own request plus the sidecars started before it).
# A container with no limit counts as an unbounded limit. Standard library only.
import json
import pathlib
import sys
HERE = pathlib.Path(__file__).resolve().parent
UNITS = {"m": 0.001, "Ki": 2**10, "Mi": 2**20, "Gi": 2**30, "Ti": 2**40}
def qty(text):
text = str(text)
for suffix, factor in UNITS.items():
if text.endswith(suffix):
return float(text[: -len(suffix)]) * factor
return float(text)
def request(container, name):
return qty(container.get("resources", {}).get("requests", {}).get(name, 0))
def effective(spec, name):
apps = spec.get("containers", [])
inits = spec.get("initContainers", [])
sidecars_so_far = 0.0
init_peak = 0.0
for c in inits:
r = request(c, name)
init_peak = max(init_peak, r + sidecars_so_far)
if c.get("restartPolicy") == "Always":
sidecars_so_far += r
return max(sum(request(c, name) for c in apps) + sidecars_so_far, init_peak)
def unbounded(spec):
every = spec.get("containers", []) + spec.get("initContainers", [])
return [c["name"] for c in every if "cpu" not in c.get("resources", {}).get("limits", {})]
print(f"{'case':<26} {'effective CPU request':>22} {'effective memory request':>26} containers with no CPU limit")
for name in sys.argv[1:]:
spec = json.loads((HERE / "cases" / f"{name}.json").read_text(encoding="utf-8"))["spec"]["podTemplate"]["spec"]
cpu = effective(spec, "cpu")
mem = effective(spec, "memory") / 2**30
print(f"{name:<26} {cpu:>20.1f} {mem:>22.3f} Gi {', '.join(unbounded(spec)) or '-'}")
Sources
- Jim Bugwadia, OpenShell on Kubernetes: A guide for platform engineers, CNCF blog, November 11, 2026, and its demo repository nirmata/demo-openshell (policies,
scripts/lib.sh, README anddocs/dev). - NVIDIA OpenShell: the v0.1.3 and v0.1.8 source trees (Kubernetes driver, Helm chart, RFC 0018), the release notes, issue 1719, issue 3362 and pull request 4377.
- Kubernetes Agent Sandbox releases (the v1.0.5 and v1.0.10 manifests).
- Agent Substrate: the repository (
docs/security/security.md,docs/concepts/architecture.md,AGENTS.md, the worker entry points andworkerpool_types.go) and the CNCF sandbox applications 522 and 523. - Kubernetes documentation: init containers, sidecar containers, Secrets and the Pod API reference.
- Kyverno CLI v1.18.2 and v1.20.0, and the apply command documentation.








No Comment! Be the first one.