Wednesday, November 4, 2026 Edition No. 228
Search
Review · Infrastructure · 25 min read

OpenShell Sandbox Lifecycle and Isolation: Hands-On on Amazon EKS

By Fabio Douek

Jump to section
Explain (TLDR) like I am...
What is this?

Every robot gets its own little room with a grown-up at the door. I tested what happens when the robot falls asleep, when I switch the room off and on again, and when I take the grown-up away.

The robot's toy box survives everything, but its scribbles on the whiteboard get wiped each time. Without the grown-up, the room locks itself. The robot really can't open the doors it isn't allowed to, though all the rooms still sit on the same floor. And a room nobody remembers keeps its lights on until someone turns them off.

This post records how OpenShell 0.1.6 behaves on Amazon EKS when sandboxes restart, stop, lose a component, or cannot be scheduled, and which controls apply inside a sandbox. Every result is the author's own test on his own cluster.

For due diligence: persistent data survives restarts, but memory and running processes do not; losing either half of a sandbox stops or fails it by design rather than leaving it unguarded, and only a manual start brings it back; and the agent shares the host kernel, which a published Red Hat test has shown to be a real exposure.

I examined a sandbox's life cycle on Amazon EKS: restarts, stops, the loss of either of its two Pods, and an unavailable zone, then checked each isolation layer from inside. The containment reflexes were consistent: when the supervisor dies, the patient is sedated, not left to wander.

Residual risk: the workload and the node share one kernel, so a kernel exploit bypasses every layer above it. User namespaces reduce the damage of a breakout; a separate kernel per sandbox was not available in this release on these nodes.

It helps to know exactly what you lose when something goes wrong. Here the answer is reassuringly consistent: files in the workspace stay, everything held in memory goes, and when the trusted half of a sandbox disappears, the untrusted half is stopped, not set free.

The harder conversation is about the shared kernel. Every safeguard tested here holds, but they all sit on one kernel with the node. Naming that limit early saves a team from trusting the sandbox more than it has earned.

Picture a band where every song is a new take. When a song ends, the restart policy decides whether to play it again; each new take gets a fresh mix and fresh musicians, while the recorded tracks in the workspace carry over from take to take.

If the sound engineer walks out, the band stops rather than playing unmonitored. The players can't touch gear outside their corner of the stage, but they all stand on the same stage as the house rig, and that floor is the one thing nobody has replaced yet.

The story here is predictability. A sandbox restarts, stops and starts in seconds, keeps its workspace, and fails closed when part of it goes missing. The isolation checks inside the sandbox pass one after another with clear, explainable denials.

The honest caveat buyers should hear: sandboxes share the node's kernel in this release, and a workspace volume ties each sandbox to one availability zone. Both are manageable, and both belong in the pitch rather than the fine print.

GitHub See the source code here: openshell-lifecycle

Overview

OpenShell, a CNCF Sandbox project since 2026-09-26, splits every agent sandbox into an untrusted workload and a trusted supervisor. Part 1 explained that split, and part 2 installed OpenShell 0.1.6 on Amazon EKS 1.37. This post, part 3 of 6, uses that cluster to answer two questions.

The first is about a sandbox’s life. What happens when its main process exits, when you stop and start it, when one of its two Pods disappears, and when the node its volume needs is gone? On Kubernetes, each of these touches Pods, volumes and an availability zone, and the docs cover only part of it.

The second is about isolation. From inside a sandbox, I checked what the workload can and can’t do with the default policy: files, syscalls, privileges, credentials, network, and the kernel. Then I turned on user namespaces.

All sandboxes here run on runc. Part 2’s attempt to run them in Kata VMs failed, so the workload shares the node’s kernel, and that’s the last thing this post looks at. Part 4 covers network policy and credentials, part 5 AWS credentials, and part 6 a stress test.

The Lifecycle at a Glance

On Kubernetes, a sandbox’s lifetime spans several generations. A generation is one run of a pair of Pods, with its own tokens and certificates. The workspace volume is the only thing that outlives them all:

supervisor Pod workload Pod /tmp, processes workspace GENERATION 1 GENERATION 2 STOPPED GENERATION 3 os-supervisor-<id> default--<name> lost at the end of the generation new Pod, new UID new Pod, new UID empty again no PodsPVC kept new Pod, new UID new Pod, new UID empty again /sandbox · 2 GiB EBS volume in one availability zone · kept until delete create main process exits restart policy on-failure or always stop start delete Each generation gets fresh tokens and certificates. A deleted workload Pod is recreated but never runs; a deleted supervisor Pod stops the sandbox (Test 3).
One sandbox on the Kubernetes driver, as measured in Tests 1 to 3. The supervisor and workload Pods are replaced together; only the workspace volume carries over.
EventPodsGeneration/sandbox/tmp, processes, memory
Main process exits, policy neverstay running (checked after exit 3); phase Error (nonzero) or Completed (zero)samekeptout of reach: exec is refused until start
Main process exits, on-failure or alwaysboth replacednewkeptlost
stop, then startremoved, then a new pairnewkeptlost
Supervisor Pod deletedboth removed after about a minutestoppedkeptlost
Workload Pod deletedreplacement never runs; phase Errorsame until stop and startkeptlost
suspendnot supported in 0.1.6
deleteremoveddeletedlost

The sandbox keeps its name and ID through all of it. If you’ve used Kubernetes, the surprise is that OpenShell, not the kubelet or the controller, decides when Pods come back. A workload Pod deleted by hand is recreated by the controller, but it never runs.

Setup

Everything runs on the cluster and gateway from part 2: two m7i.2xlarge nodes in two availability zones, OpenShell 0.1.6 with the Kubernetes driver, and the CLI connected through a kubectl port-forward on 127.0.0.1:17680. If you scaled the nodes down, bring them back first:

cd my2centsai-blog-samples/openshell-eks
./09_scale.sh up

The scripts in openshell-lifecycle are plain bash. They source part 2’s env.sh through a small lib.sh, which also defines a few helpers:

os() { openshell -g osh-eks "$@"; }   # -g before the subcommand, never after a -- command
in_sb() { local n="$1"; shift; os sandbox exec "$n" --no-login-shell --no-tty -- "$@"; }
# One line per change: phase, restart count, last exit code, seconds to next restart, generation.
sandbox_state() {
  os sandbox get "$1" -o json 2>/dev/null | jq -r '[.phase, (.restart_count|tostring), (.exit_code // "-" | tostring),
    (if (.next_restart_at_ms // 0) > 0 then "\(((.next_restart_at_ms / 1000) - now) | floor)s" else "-" end),
    (.annotations["internal.openshell.ai/runtime-generation"] // "-" | .[0:8])] | join(" ")'
}

The os wrapper’s comment is from experience. My first version appended -g osh-eks at the end, which put it after -- and turned it into an argument of the sandbox command.

watch_sandbox polls sandbox_state once a second and prints a line whenever the phase, restart count, exit code or generation changes. The generation comes from the annotation internal.openshell.ai/runtime-generation, shortened to eight characters. Each script deletes its sandboxes in a trap, and 99_cleanup.sh also uncordons nodes and switches user namespaces back off, in case a run was interrupted.

Testing

Test 1: Restart policies and generations

The command after -- in openshell sandbox create is the sandbox’s main process. The restart policy decides what happens when it exits. The policy belongs to the gateway, not to Kubernetes; the Pods themselves have restartPolicy: Never. I gave each policy a command that sleeps 5 seconds and exits:

os sandbox create --name rp-never --restart-policy never --detach --no-tty -- sh -c 'sleep 5; exit 3' >/dev/null 2>&1
watch_sandbox rp-never 45 '^(Completed|Error)$'
Output: three restart policies
$ openshell sandbox create --name rp-never --restart-policy never -- sh -c 'sleep 5; exit 3'
       t  phase        restarts exit  next restart generation
    0.6s  Ready        0        -     -            1c611af4
    5.3s  Error        0        3     -            1c611af4

$ openshell sandbox create --name rp-onfail --restart-policy on-failure -- sh -c 'sleep 5; exit 3'
       t  phase        restarts exit  next restart generation
    0.5s  Ready        0        -     -            94c9c2fe
    5.2s  Starting     1        3     -            d05122fc
   25.2s  Ready        1        -     -            d05122fc
   29.8s  Starting     2        3     0s           d05122fc
   31.4s  Starting     2        3     -            0a15a471
   40.6s  Ready        2        -     -            0a15a471
   45.3s  Starting     3        3     1s           0a15a471
   48.4s  Starting     3        3     -            a81ca285
   62.1s  Ready        3        -     -            a81ca285
   66.7s  Starting     4        3     3s           a81ca285
   71.5s  Starting     4        3     -            884a1227

$ openshell sandbox create --name rp-always --restart-policy always -- sh -c 'sleep 5; exit 0'
       t  phase        restarts exit  next restart generation
    0.5s  Ready        0        -     -            232fb351
    5.1s  Starting     1        0     -1s          232fb351
    6.6s  Starting     1        0     -            da5b8a76
   23.6s  Ready        1        -     -            da5b8a76
   28.2s  Starting     2        0     0s           da5b8a76
   29.7s  Starting     2        0     -            a809c4d9
   42.1s  Ready        2        -     -            a809c4d9

What the three runs show:

  • never (the default): exit code 3 put the sandbox in Error and kept it there, with the same generation. A separate run with exit 0 ended in Completed. After the exit 3, both Pods kept running, but exec was refused (“not ready (phase: Error)”). The gateway accepts start from Completed or from an Error caused by the main process, which starts a new generation.
  • on-failure: every nonzero exit started a new generation. The next restart column shows the backoff growing. The docs describe it as 1 second, doubling up to 3 minutes, and reset by 10 seconds of uptime. My process lived only 5 seconds, so it never reset.
  • always: the same, for exit 0.

From exit to Ready again took 11 to 20 seconds in these runs, at most 4 seconds of it backoff. A restart isn’t a process restart: a separate check showed that each restart replaces both Pods. The supervisor Pod and the workload Pod get new UIDs, and the workspace volume is mounted again in the new workload Pod. In that check, with a command that ran for 12 seconds, the sandbox was Ready again about 6 seconds after each exit. I don’t know why the two differ that much.

A new generation is more than a counter. OpenShell’s tokens and TLS certificates are bound to a sandbox and a generation, so every restart issues fresh ones. A credential leaked from one generation is useless in the next. The flip side is that anything in memory, including an agent’s conversation state, is gone.

Then stop, start and suspend:

$ openshell sandbox create --name life   (then stop, start, suspend)
  ready, generation 6b2ca65f
  stop:  Stopped after 8.7 s
  supervisor pods while stopped: 0
  start: Ready after 12.7 s, generation dff0118f

$ openshell sandbox suspend life
Error:   × code: 'The system is not in a state required for the operation's
  │ execution', message: "compute driver does not support sandbox suspend"

And a one-shot sandbox, os sandbox create --name oneshot --no-keep --no-tty -- echo "hello from a one-shot sandbox":

hello from a one-shot sandbox
  oneshot gone after 20.3 s: not listed

stop took 8.7 seconds and removed both Pods. The Agent Sandbox Sandbox resource stays, set to operatingMode: Suspended, and so does the workspace PVC. start brought it back in 12.7 seconds with a new generation.

suspend is in the CLI and the API, but no built-in driver implements it in 0.1.6. The gateway checks the driver’s capabilities first and refuses with FAILED_PRECONDITION, whose standard description is the odd first line above. There is also no idle timeout: a sandbox you forget about runs, and bills, until you stop or delete it.

--no-keep works as advertised: the command ran, printed its line, and the sandbox was gone 20.3 seconds after the create call, including the create itself and the delete.

Test 2: What survives, and where the volume lives

02_persistence.sh writes a file to the workspace, a file to /tmp, and starts a background process, then stops and starts the sandbox:

in_sb "$NAME" sh -c 'echo kept > /sandbox/keep.txt; echo lost > /tmp/gone.txt; nohup sleep 3600 >/dev/null 2>&1 & echo "  background sleep pid $!"'
# then, simplified: os sandbox stop "$NAME"; os sandbox start "$NAME"; wait for Ready; read the files back
$ openshell sandbox stop persist && openshell sandbox start persist
  stopped after 7.5 s
  ready again after 12.5 s
  /sandbox/keep.txt: kept
  /tmp/gone.txt:     missing
  sleep process:     gone

/sandbox is the workspace: a 2 GiB EBS volume (/dev/nvme2n1 inside the sandbox). /tmp is on the container’s root filesystem, which goes with the Pod. So the rule for agents is simple: anything that must survive a restart goes under /sandbox.

The volume is the catch. An EBS volume lives in one availability zone, and part 2’s node group has one node in us-east-1a and one in us-east-1b. To see what that means, the script stops the sandbox, cordons the node in the volume’s zone, and starts it again:

$ openshell sandbox stop persist; kubectl cordon ip-10-70-20-30.ec2.internal; openshell sandbox start persist
  the workspace volume is an EBS volume in us-east-1a; the nodes:
    ip-10-70-20-30.ec2.internal  us-east-1a
    ip-10-70-40-237.ec2.internal  us-east-1b
  +122.4s workload pod: Pending   openshell: Starting
  0/2 nodes are available: 1 node(s) didn't match PersistentVolume's node affinity, 1 node(s) were unschedulable. preemption: 0/2 nodes are available: 2
  supervisor pod on: ip-10-70-40-237.ec2.internal

$ kubectl uncordon ip-10-70-20-30.ec2.internal
       t  phase        restarts exit  next restart generation
    0.5s  Starting     0        -     -            f000503a
   13.0s  Ready        0        -     -            f000503a
  usable 14.3 s after uncordon; /sandbox/keep.txt: kept

For two minutes, the workload Pod sat in Pending, and OpenShell’s phase stayed Starting. The scheduler’s event is where I saw why. As soon as the node was schedulable again, the sandbox was Ready in 13 seconds with its file.

The supervisor Pod went to the other zone. The supervisor Pod has no affinity to its workload, so the two halves of one sandbox can run in different availability zones. All of the agent’s network traffic passes through its supervisor, so in that case all of it crosses zones, and AWS charges for data that crosses zones.

What to take from it:

  • A sandbox is pinned to its volume’s zone for life. If you run nodes in several zones, keep enough capacity in each, or a zone without a free node leaves its sandboxes in Starting (for up to 30 minutes, per the docs’ 1,800-second start deadline, before they go to Error).
  • If cross-zone traffic matters, a single-zone node group (or a node group per zone) keeps both halves together.

Test 3: Losing a Pod

The two halves of a sandbox are separate Pods, and either can disappear: a node failure, an eviction, an operator with kubectl delete. 03_supervisor_loss.sh deletes each one in turn.

The supervisor first. The sandbox README says the workload freezes when its supervisor disconnects, and is terminated if no supervisor reconnects within a deadline (30 seconds by default). The architecture docs add that a new supervisor can’t take over a running sandbox, even with valid credentials; the exception is the shared supervisor pool, which uses a takeover grant signed by the gateway.

$ kubectl delete pod os-supervisor-be28e52f-...   (the supervisor)
       t  phase        restarts exit  next restart generation
    0.5s  Ready        0        -     -            bb3450ce
  exec while the supervisor is gone: refused
   31.1s  Provisioning 0        -     -            bb3450ce
   65.3s  Stopped      0        -     -            bb3450ce
  (no pods)

$ openshell sandbox start sup
  started again: Ready after 11.7 s, generation 2fb3eded
  /sandbox/marker: written-before

exec was refused within seconds, since every session goes through the supervisor. At 31 seconds, matching the 30-second deadline, the phase changed to Provisioning, which per the docs is also what a sandbox shows until its supervisor reconnects. At 65 seconds it was Stopped, with no Pods left. Nothing restarted by itself. One start later, it was Ready in 11.7 seconds with a new generation and the workspace intact.

That’s the behaviour I’d want. Per the docs, the workload was frozen in the meantime, and then it was removed. The cost is a stop and a lost session. Neither the gateway nor the Agent Sandbox controller brought the sandbox back without a start.

Then the workload.

$ kubectl delete pod default--sup   (the workload)
       t  phase        restarts exit  next restart generation
    0.5s  Ready        0        -     -            2fb3eded
   30.3s  Error        0        -     -            2fb3eded
  default--sup                                         workload     Pending
  os-supervisor-be28e52f-...                           supervisor   Running

$ openshell sandbox stop sup && openshell sandbox start sup
  started again: Ready after 36.1 s, generation 0a2f7d82
  /sandbox/marker: written-before

The Agent Sandbox controller noticed the missing Pod and recreated it, but the replacement never ran. A separate check showed what holds it: the scheduling gate openshell.ai/bootstrap. OpenShell removes that gate only after it validates the Pod during its own bootstrap, so a Pod the controller recreates by itself never runs. The Pod stays SchedulingGated, and after 30 seconds the gateway marks the sandbox Error. A plain start is refused in Error; stop and then start brought it back in 36 seconds with the workspace intact.

Both cases fail closed, by design: OpenShell doesn’t replace a lost Pod while the agent keeps running. Recovery is manual and keeps the workspace: start after a supervisor loss, stop and then start after a workload loss. That second path exists since 0.1.5 (#4231, which fixed #3308, where an errored sandbox refused both commands). I simulated both losses with kubectl delete and didn’t fail a real node. I also ran them with the default restart policy, never; the docs describe restart policies for exits of the main process only, and I didn’t test on-failure or always here. For supervisor loss, the experimental shared supervisor pool (part 6) is the exception: per the docs, the gateway moves a failed member’s sandboxes to live members without restarting them. I didn’t test that either. So if your agents must come back unattended, something outside OpenShell has to watch for Error and Stopped and start them again. I watched for the stuck-in-Provisioning state reported in #4371 and didn’t hit it.

Test 4: Inside the workload

04_isolation.sh runs a list of checks in one sandbox with the default policy, which allows no network destinations at all. Each line shows a check, its exit code and its output:

check() {  # label, shell command run inside the sandbox
  local out rc
  out="$(in_sb "$NAME" sh -c "$2" 2>&1)"; rc=$?
  printf '  %-34s exit %-3s %s\n' "$1" "$rc" "$(tr '\n' ' ' <<<"$out" | cut -c1-90)"
}
check "write /var/tmp/x (not in policy)" "echo x > /var/tmp/x && echo written"
check "unshare a user namespace" "unshare -U true && echo unshared"
Output: every check
$ who am I
  id                                 exit 0   uid=10001 gid=10001 groups=10001
  capabilities, seccomp              exit 0   CapEff: 0000000000000000 NoNewPrivs: 1 Seccomp: 2
  kernel (shared with the node)      exit 0   6.18.56-125.164.amzn2023.x86_64
  node kernel:                       6.18.56-125.164.amzn2023.x86_64

$ files (Landlock: read-write /sandbox and /tmp, read-only system paths, nothing else)
  write /sandbox/ok                  exit 0   written
  write /tmp/ok                      exit 0   written
  write /var/tmp/x (not in policy)   exit 2   sh: 1: cannot create /var/tmp/x: Permission denied
  list /var/tmp (not in policy)      exit 2   ls: cannot open directory '/var/tmp': Permission denied
  write /etc/x (read-only)           exit 2   sh: 1: cannot create /etc/x: Permission denied
  read /etc/hostname (read-only)     exit 0   default--iso

$ processes and syscalls (seccomp, no privileges)
  unshare a user namespace           exit 1   unshare: unshare failed: Operation not permitted
  raw packet socket                  exit 1   Traceback (most recent call last):   File "<string>", line 1, in <module>   File "/usr/lib
  mount a tmpfs                      exit 32  mount: /tmp/m: must be superuser to use mount.        dmesg(1) may have more information a
  processes visible                  exit 0    PID USER COMMAND  1 10001 openshell-sandb  63 10001 bash  95 10001 sh  96 10001 ps  97 10

$ credentials and network
  service account token              exit 2   ls: cannot access '/var/run/secrets/kubernetes.io/serviceaccount': No such file or directo
  env: proxy or token variables      exit 0   0
  DNS for pypi.org                   exit 0   198.18.0.2      pypi.org
  https://pypi.org                   exit 0   curl: (7) Failed to connect to pypi.org port 443 after 1 ms: Couldn't connect to server 00
  instance metadata 169.254.169.254  exit 0   curl: (7) Failed to connect to 169.254.169.254 port 80 after 0 ms: Couldn't connect to ser

Layer by layer:

LayerCheckResult
IdentityidUID and GID 10001, whatever the image’s USER says
Privileges/proc/self/statusno effective capabilities, NoNewPrivs: 1
seccompSeccomp: 2filter mode (6 filters stacked in part 2’s run)
Landlockwrite /sandbox, /tmpallowed
Landlockwrite /etcdenied (read-only path); reading /etc/hostname works
Landlockwrite or list /var/tmpdenied: a path the policy doesn’t name can’t even be read
syscallsunshare -UOperation not permitted
syscallsraw AF_PACKET socketPermissionError: [Errno 1]
syscallsraw ICMP socketOSError: [Errno 93] Protocol not supported
syscallsmount -t tmpfsmust be superuser
ProcessespsPID 1 is OpenShell’s runtime, /.openshell/runtime/openshell-sandbox, as UID 10001
CredentialsServiceAccount tokenno token mounted
Credentialsenvironmentno proxy, token or secret variables
NetworkDNS for pypi.org198.18.0.2, a synthetic answer from OpenShell’s policy DNS
NetworkHTTPS to pypi.orgrefused within a millisecond
Networkinstance metadata 169.254.169.254refused

A few things stand out.

Landlock is allow-list, including reads. The default filesystem policy grants read-write on the workspace and /tmp, read-only on system paths such as /usr, /lib and /etc, and nothing else. /var/tmp isn’t in the list, so the agent can’t even list it. That’s stricter than a read-only root filesystem, and it applies to every process the agent starts.

The runtime is PID 1 and the agent is its child. OpenShell’s runtime starts as the container’s entrypoint, sets up Landlock and seccomp for the processes it starts, and intercepts their network connections. It does that before it starts the agent. It is not the trusted half: it shares the boundary with the agent, runs as the same UID, and, per the architecture docs, never makes policy decisions.

The network has two locks. The workload Pod’s NetworkPolicy allows no egress except to its own supervisor. Inside the Pod, the runtime intercepts every connect() and hands it to the supervisor, which decides; with no policy rules, it refuses all of them. DNS still answers, with an address from 198.18.0.0/15 that means nothing outside the sandbox. The metadata endpoint was refused too, and OpenShell’s docs say link-local addresses are always blocked, whatever the policy says. Part 4 opens the network one rule at a time.

Nothing to steal. No ServiceAccount token, no proxy variables, no credentials in the environment. The supervisor has a projected token for the gateway; the workload doesn’t. Part 4 and part 5 show how the agent uses credentials it gets only as placeholders, and where that has limits.

The kernel is the node’s. uname -r inside the sandbox and the node’s kernel version are the same string. Every layer above is enforced by that kernel, so a kernel bug sits underneath all of them. That isn’t hypothetical: Red Hat published a test in which a kernel page-cache exploit (CVE-2026-31431) run from a pod protected only by OpenShell compromised the host. Their explanation: the supervisor and proxy “share the same kernel and page cache as the workload, so a kernel-level attack could potentially corrupt the enforcement mechanism, not just bypass it.” I didn’t run an exploit; the point is that runc gives you OpenShell’s layers on a shared kernel, nothing more.

A VM per sandbox is the usual answer, and OpenShell supports one through a Kubernetes RuntimeClass. On these nodes, Kata itself ran fine, but an OpenShell 0.1.6 sandbox under Kata failed while seeding its workspace, and the VM went down for a reason I haven’t found (part 2, Test 7). Until that works, the closest thing on EKS is the next test.

Test 5: User namespaces

OpenShell can run workload Pods with hostUsers: false, so UIDs inside the Pod map to an unprivileged range on the node. It’s off by default. The chart value is server.enableUserNamespaces:

# what 05_user_namespaces.sh runs, simplified
helm upgrade openshell "${INSTALL}/charts/helm-chart-0.1.6.tgz" -n openshell --reuse-values \
  --set server.enableUserNamespaces=true --wait --timeout 10m
kubectl -n openshell rollout restart statefulset/openshell
$ helm upgrade openshell --reuse-values --set server.enableUserNamespaces=true
  sandbox: Ready
  workload Pod hostUsers: false

$ inside the sandbox
  id: uid=10001 gid=10001 groups=10001
  /proc/self/uid_map:  0 1439956992 65536
  server.enableUserNamespaces back to false

Inside, nothing looks different: still UID 10001. The UID map says the Pod’s UIDs 0 to 65535 are node UIDs from 1,439,956,992 up, so the agent’s UID 10001 is 1,439,966,993 on the node, a user that owns nothing there. If something did break out of the container, it would arrive as that user, not as UID 10001 shared with every other sandbox on the node.

The sandbox started and exec worked with no other change, on EKS 1.37’s containerd and kernel 6.18. I didn’t rerun the other tests with it on. Switching it on and off restarts the gateway each time, so the script restarts the port-forward too.

User namespaces don’t help against a kernel exploit like the one above: the kernel is still shared. They make a container escape land as an unprivileged stranger instead of a known UID. In my test on EKS 1.37 they cost nothing visible, so I’d turn them on.

Results Summary

Result
neverExit 3 → Error, exit 0 → Completed; Pods stay (checked after exit 3), exec refused until start
on-failure / alwaysRestart with growing backoff; each restart replaces both Pods and starts a new generation
Exit to Ready again11 to 20 s in Test 1; about 6 s in a separate check
stop / start7.5 to 8.7 s / 11.7 to 12.7 s; Pods removed, workspace kept, new generation
suspendNot supported by any built-in driver in 0.1.6
--no-keepGone 20.3 s after the create call
/sandbox across stop and startKept (EBS volume)
/tmp, processes, memoryLost
Start with the volume’s zone cordonedWorkload Pending, sandbox Starting for the two minutes I waited; Ready 13 s after uncordon
Supervisor and workload placementCan land in different zones
Supervisor Pod deletedexec refused; stopped at 65 s; start → Ready in 11.7 s
Workload Pod deletedReplacement gated forever; Error at 30 s; stop + start → Ready in 36 s
Inside the workloadUID 10001, no capabilities, seccomp filter, Landlock allow-list, no token, no network
KernelSame as the node’s
User namespacesWorked: hostUsers: false, UID 10001 → 1,439,966,993 on the node

Cleanup

./99_cleanup.sh            # sandboxes from these tests, uncordon, user namespaces off
cd ../openshell-eks
./09_scale.sh down         # zero nodes between sessions

99_cleanup.sh deletes any sandbox these scripts created, uncordons any cordoned node, and turns server.enableUserNamespaces back off if it’s still on. The cluster itself goes with part 2’s 99_teardown.sh at the end of the series.

Verdict

OpenShell’s lifecycle on Kubernetes is predictable once you see the rule behind it: the gateway owns the sandbox, and a Pod is only ever a disposable run of it. Restarts, stops and starts all create a fresh Pod pair with fresh credentials; the workspace volume is the only thing that carries over. When a Pod disappears, the sandbox stops or fails rather than running half-guarded. That’s the right default for a security boundary.

The isolation inside the workload is solid for what it is. Every check I ran was refused for a clear reason, and Landlock’s allow-list is tighter than I expected.

Three things to plan for:

  • Nothing heals itself, by design. A lost Pod leaves the sandbox Stopped or Error; start brings it back with its workspace, after a stop if it’s in Error. Unattended recovery needs something outside OpenShell watching for those phases. There’s no idle timeout and no suspend, so a forgotten sandbox keeps its Pods and its volume.
  • Zones matter. The workspace pins a sandbox to one zone, a sandbox waiting for capacity sits in Starting (for up to 30 minutes), and its two halves can land in different zones and pay for cross-zone traffic.
  • The kernel is shared. Every layer tested here sits on the node’s kernel. User namespaces worked out of the box and are worth turning on. A VM per sandbox, the real fix, didn’t work with OpenShell 0.1.6 on these nodes.

For an agent that runs untrusted code on a shared cluster, that last point is the one to weigh. Next, part 4 gives the sandbox a network: policies, binary-scoped rules, L7 inspection and credentials the agent gets only as placeholders.

Comments