Runbook: caging the AI agent — bubblewrap on the client, Tetragon on the cluster
Contents
Operational companion to The contractor with the master key. That post explains the why and the where: the threat model, the five isolation families, which domain uses each. This one is the how, with commands. If you have not read it, read it first: here I take for granted what the blast radius is, why
bwrapruns without root and what Tetragon watches. The procedure comes in two independent tracks, client and cluster, because, as the sibling post argues, the control extrapolates but the primitive gets rewritten.
TL;DR
Two reproducible procedures. Client (workstation): install ai-jail (it wraps bubblewrap), generate the per-project .ai-jail, audit with --dry-run, fix the allowlists with --bootstrap, use --lockdown for anything you do not trust, and leave the agent without git push permission. Cluster (RKE2 with Cilium + Tetragon): put the pod baseline in place (unprivileged securityContext, seccomp: RuntimeDefault, default-deny NetworkPolicy), put the untrusted agent’s pod in a microVM with runtimeClassName: kata, and deploy the Tetragon TracingPolicy files in two phases: observe with action: Post to raise the baseline, then promote to action: Sigkill on tcp_connect (egress) and security_file_open (secret paths). The golden rule of the Tetragon phase: adopt first, block later; never put a Sigkill into production without having seen the events in observation mode first.
The flow of the two tracks
Track A — Client (the developer’s workstation)
A0 — Install ai-jail and bubblewrap
ai-jail wraps the sandbox; on Linux it needs bubblewrap separately, on macOS it needs no extra dependency.
# ai-jail (macOS and Linux)
brew tap akitaonrails/tap && brew install ai-jail
# or, with cargo:
cargo install ai-jail
# or, with mise:
mise use -g ubi:akitaonrails/ai-jail
# bubblewrap on Linux (pick your distro)
sudo pacman -S bubblewrap # Arch
sudo apt install bubblewrap # Debian / Ubuntu
sudo dnf install bubblewrap # Fedora
Check the binary is there and that bwrap runs without root (it must not ask for sudo):
ai-jail --version
bwrap --ro-bind / / --unshare-all echo "bwrap ok without root"
If bwrap fails asking for privileges, your kernel has unprivileged user namespaces disabled; enable them (sysctl kernel.unprivileged_userns_clone=1 on older Debian/Ubuntu) before continuing.
A1 — The per-project .ai-jail file
On the first launch inside the project, ai-jail creates a .ai-jail (TOML) that is committable to the repo: any colleague who clones inherits the same policy.
cd ~/Projects/mi-app
ai-jail claude # creates .ai-jail and launches Claude Code inside the sandbox
The generated file:
# .ai-jail — sandbox configuration (commit it to the repo)
command = ["claude"]
rw_maps = [] # extra writable directories
ro_maps = [] # extra read-only directories
Before trusting the sandbox, audit it. --dry-run --verbose prints every mount point, every isolation flag and the full bwrap command, without executing anything:
ai-jail --dry-run --verbose claude
Read the output and confirm three things: that $HOME is mounted as a tmpfs (not the real one), that ~/.ssh, ~/.aws and ~/.gnupg do not appear among the mounts, and that the only writable directory is the project one. If you need an extra directory:
ai-jail --rw-map ~/Projects/shared-lib claude # extra, writable
ai-jail --map /opt/datasets claude # extra, read-only
Other agents, same binary:
ai-jail codex
ai-jail opencode
ai-jail bash # bare shell to debug the sandbox
ai-jail -- python script.py # any command
A2 — Permission allowlists with –bootstrap
--bootstrap generates each agent’s permission configuration, with sensible allow/deny/ask, and takes a backup before overwriting:
ai-jail --bootstrap
What it produces, in summary:
| Agent | File | Base policy |
|---|---|---|
| Claude Code | ~/.claude/settings.json | allow: git status/diff/log, ls, grep, cargo, npm, python, docker compose · ask: git push, rm, docker run · deny: rm -rf, sudo, chmod 777, git push --force |
| Codex | ~/.codex/config.toml | approval_policy = "on-request" |
| OpenCode | ~/.config/opencode/opencode.json | permissions for bash, edit, write |
The operational key: git push is under ask, not allow, and git push --force under deny. The agent can commit, branch and rebase locally as much as it wants; none of that touches the remote. (If you use the Claude Code /sandbox, also set "allowUnsandboxedCommands": false to close the dangerouslyDisableSandbox escape hatch, which is opt-out out of the box.)
A3 — Lockdown for anything you do not trust
To audit third-party code or run an agent over a project you do not know, --lockdown goes further: project mounted read-only, GPU/Docker/display disabled, --rw-map/--map ignored, $HOME a pure tmpfs with no host dotfiles, network cut with --unshare-net and the environment wiped with --clearenv.
ai-jail --lockdown bash
It is the most restrictive sandbox possible short of a VM. Use it as your mental default for anything that is not your own code on your own machine.
A4 — The safety net: git with no push
It is not a flag, it is a property of the environment that changes the risk arithmetic. If the project is in git with a remote, and the agent does not have push permission, the worst case, that it corrupts every file in the project, is reverted with:
git checkout . # back to the last commit
# and if it touched .git (unlikely): delete the dir and re-clone
The remote was never touched. Sandbox for the filesystem + git for the code + manual push is already a reasonable level for daily use: ai-jail protects your data and the system, git protects the code, and the decision to publish is still yours.
Track B — Cluster (RKE2 with Cilium + Tetragon)
The untrusted agent, or the inference that executes generated code, runs as a pod. The same principle as on the client, different primitives. We assume a generic RKE2 cluster with Cilium as CNI and Tetragon already deployed (the eBPF agent DaemonSet on every node).
B0 — The pod baseline
Before any eBPF, the standard kit. Unprivileged securityContext, read-only root, default seccomp:
apiVersion: v1
kind: Pod
metadata:
name: ai-agent
namespace: agentes
labels:
app: ai-agent
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: agent
image: registry.interno/ai-agent:pinned
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- { name: work, mountPath: /work } # the only writable one
volumes:
- name: work
emptyDir: {}
And the default egress cut, the cluster twin of --unshare-net. A default-deny egress NetworkPolicy in the namespace, opening only DNS and the bare essentials:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: agentes
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
B1 — Kata RuntimeClass: the untrusted pod in its own microVM
For genuinely untrusted code, take it out of the shared kernel. With Kata deployed there is a RuntimeClass:
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: kata
handler: kata
And the pod asks for it with one line, runtimeClassName: kata, running in its own microVM with a dedicated kernel instead of sharing the node’s:
spec:
runtimeClassName: kata # ← the pod runs in a microVM, not on the node's kernel
# ...the rest the same as B0
It is the cluster twin of isolation by construction: a kernel exploit inside the pod does not reach the node.
B2 — Tetragon, observation phase (Post)
Now the layer that separates a platform with runtime visibility from one without. Observe first, never kill from the outset. A TracingPolicyNamespaced, scoped to the namespace and to the agent’s label, that reports (does not kill) three things: process executions, network connections and opens of sensitive paths. action: Post only emits the event.
apiVersion: cilium.io/v1alpha1
kind: TracingPolicyNamespaced
metadata:
name: agente-observa
namespace: agentes
spec:
podSelector:
matchLabels:
app: ai-agent
kprobes:
# --- outbound connections ---
- call: "tcp_connect"
syscall: false
args:
- index: 0
type: "sock"
selectors:
- matchActions:
- action: Post
# --- opens of sensitive files ---
- call: "security_file_open"
syscall: false
args:
- index: 0
type: "file"
selectors:
- matchArgs:
- index: 0
operator: "Prefix"
values:
- "/var/run/secrets"
- "/work/.git/config"
matchActions:
- action: Post
(Process executions need no kprobe: Tetragon emits process_exec/process_exit natively.) Deploy it and watch the events live from the node’s Tetragon pod:
kubectl apply -f agente-observa.yaml
# readable events, filtered by namespace:
kubectl exec -n kube-system ds/tetragon -c tetragon -- \
tetra getevents -o compact --namespace agentes
Leave this running for a typical working day of the agent. Note which destinations it really connects to (your internal registry, your HF mirror, your vLLM endpoint) and which paths it opens. That is your baseline: the list of what is legitimate. Without this step, a Sigkill kills good work and generates an availability incident, precisely what ENS asks you to avoid.
B3 — Tetragon, enforcement phase (Sigkill)
With the baseline in hand, promote to blocking. Two rules. The first: kill any connection whose destination is not on the allowlist — NotDAddr inverts the match: it fires for everything that is not those networks. The second: kill any attempt to open a secrets path.
apiVersion: cilium.io/v1alpha1
kind: TracingPolicyNamespaced
metadata:
name: agente-enforce
namespace: agentes
spec:
podSelector:
matchLabels:
app: ai-agent
kprobes:
# --- egress: kill everything that is NOT on the allowlist ---
- call: "tcp_connect"
syscall: false
args:
- index: 0
type: "sock"
selectors:
- matchArgs:
- index: 0
operator: "NotDAddr"
values:
- "127.0.0.1"
- "10.0.0.0/8" # internal cluster network
- "172.16.10.20" # internal registry (example)
matchActions:
- action: Sigkill
# --- reading secrets: kill the process ---
- call: "security_file_open"
syscall: false
args:
- index: 0
type: "file"
selectors:
- matchArgs:
- index: 0
operator: "Prefix"
values:
- "/var/run/secrets/kubernetes.io/serviceaccount/token"
- "/work/.ssh"
matchActions:
- action: Sigkill
kubectl apply -f agente-enforce.yaml
Now the agent can do whatever it likes inside the pod, but the instant it tries to connect to a disallowed destination or read the service account token, Tetragon kills it in the kernel, before the packet leaves or the read returns bytes. It is the cluster twin of the curl blocklist and the unmounted ~/.ssh, but applied at runtime and over any binary, not just the ones you know about.
Operational warning. Enforcement with
Sigkillrequires a recent kernel with eBPF support for the action (5.10+ is safe). Deployagente-enforcein a test namespace first, and keepagente-observaactive in parallel: if the block fires, thePostevent tells you exactly what triggered it. Adopt first, block later.
The client ↔ cluster equivalence table
The same vector, the two primitives. This is “extrapolating the technology” made explicit:
| Threat vector | Client (workstation) | Cluster (RKE2) |
|---|---|---|
$HOME / writable root | $HOME as ephemeral tmpfs (bwrap) | readOnlyRootFilesystem: true + emptyDir |
| Arbitrary egress | curl/wget blocklist · --unshare-net | default-deny NetworkPolicy + Tetragon NotDAddr→Sigkill |
| Reading secrets | ~/.ssh/~/.aws/~/.gnupg not mounted | secrets outside the pod + Tetragon security_file_open→Sigkill |
| Kernel escape | Landlock (2nd VFS barrier) | runtimeClassName: kata (microVM, own kernel) |
| No escape hatch | process inside bwrap, no way out | no privileged, drop ALL, allowPrivilegeEscalation:false |
| Damage to the code | git remote with no push → git checkout . | GitOps + PR review, the agent does not apply to main |
| Visibility | --dry-run --verbose (static, pre-run) | Tetragon tetra getevents (dynamic, at runtime) |
Checklist of gotchas
- Do not put in a
Sigkillwithout passing throughPost. The observation baseline is not optional: it is what separates “blocking a C2” from “killing your own fine-tuning job”. - The
.ai-jailgets committed; secrets do not. The TOML is policy, not credentials. Check you are not putting paths holding sensitive data intorw_maps. readOnlyRootFilesystembreaks apps that write to/tmp. Mount anemptyDirat/tmpas well as the work one.- A NetworkPolicy with no DNS rule leaves the pod blind. Open port 53 to
kube-systemor nothing resolves. - Kata is not free. It adds startup latency and not every workload with special devices (GPU passthrough) fits; reserve it for the untrusted, not for everything.
- The Claude Code
/sandboxdoes not cover MCP or hooks unless you enablesandbox-runtime. If your agent uses MCP servers, assume they run with full permissions until you do. NotDAddrwith literal IPs ages badly. Document the allowlist and review it when the registry or the inference endpoint changes; consider stable internal CIDRs instead of loose IPs.
See also
Hardening and secrets in the sovereign LLM stack: defence in depth — the hardening of the whole cluster beyond agent isolation.
The contractor with the master key: isolating AI agents from workstation to cluster — the landscape this runbook executes: threat model, the five isolation families and why client and cluster use different primitives.
The kitchen door the maître never checked: Cilium eBPF and DRANET — the Cilium eBPF layer on which Tetragon hooks its kprobes; the datapath you already have in the cluster.
Technical controls: ENS × ISO 42001 × EU AI Act — Tetragon events as technical evidence for
op.mon/op.exp; enforcement as a protection measure.Guardrails and safety in LLMs — mitigation on the content plane; this runbook, on the execution plane.
Seven phases of deploying an on-premise LLM platform — where runtime hardening fits in the deployment sequence (F4 identity/policies, F5 platform).
References
- ai-jail (Fabio Akita), GPL-3.0: https://github.com/akitaonrails/ai-jail
- bubblewrap: https://github.com/containers/bubblewrap
- Landlock LSM: https://landlock.io
- Tetragon — TracingPolicy: https://tetragon.io/docs/concepts/tracing-policy/
- Tetragon — enforcement (Sigkill/Override): https://tetragon.io/docs/concepts/enforcement/
- Kata Containers — Kubernetes RuntimeClass: https://katacontainers.io
- Kubernetes — Pod Security & seccomp: https://kubernetes.io/docs/tutorials/security/seccomp/
- Kubernetes — Network Policies: https://kubernetes.io/docs/concepts/services-networking/network-policies/
- Cilium: https://cilium.io