---
title: "Runbook: caging the AI agent — bubblewrap on the client, Tetragon on the cluster"
url: https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/
markdown: https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/index.md
lang: en
date: 2026-06-09
lastmod: 2026-09-13
author: "José Manuel Román"
description: "Operational procedure for isolating AI agents in both domains, with commands to copy and paste. On the client (workstation): install ai-jail + bubblewrap, the per-project .ai-jail file, the --bootstrap of permission allowlists for Claude Code / Codex / OpenCode, the --lockdown mode and the safety net of git with no push. On the cluster (RKE2): the pod baseline (securityContext, seccomp, default-deny NetworkPolicy), the Kata RuntimeClass for untrusted pods, and the Tetragon TracingPolicy files in two phases — first observe (Post) to build a baseline, then promote to enforcement (Sigkill) on tcp_connect and security_file_open. It closes with the client↔cluster equivalence table and a checklist of gotchas."
tags: ["security","sandbox","runbook","operational","ai-jail","bubblewrap","landlock","tetragon","ebpf","kata","runtimeclass","networkpolicy","kubernetes","rke2","on-premise"]
translations:
  es: https://blog.lo0.es/posts/runbook-aislar-agentes-ia-bubblewrap-tetragon/index.md
---

# Runbook: caging the AI agent — bubblewrap on the client, Tetragon on the cluster


> **Operational** companion to [The contractor with the master key](https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/index.md). 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 `bwrap` runs 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

> **Diagram:** Two operational tracks: client (install, configure, bootstrap, lockdown) and cluster (baseline, RuntimeClass, observe, enforce) — [see the HTML version](https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/)

---

# 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.

```bash
# 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`):

```bash
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.

```bash
cd ~/Projects/mi-app
ai-jail claude        # creates .ai-jail and launches Claude Code inside the sandbox
```

The generated file:

```toml
# .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:

```bash
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:

```bash
ai-jail --rw-map ~/Projects/shared-lib claude   # extra, writable
ai-jail --map /opt/datasets claude              # extra, read-only
```

Other agents, same binary:

```bash
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:

```bash
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`.

```bash
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:

```bash
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:

```yaml
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:

```yaml
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`:

```yaml
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:

```yaml
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.

```yaml
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:

```bash
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**.

```yaml
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
```

```bash
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 `Sigkill` requires a recent kernel with eBPF support for the action (5.10+ is safe). Deploy `agente-enforce` in a test namespace first, and keep `agente-observa` active in parallel: if the block fires, the `Post` event 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 `Sigkill` without passing through `Post`.** The observation baseline is not optional: it is what separates "blocking a C2" from "killing your own fine-tuning job".
- **The `.ai-jail` gets committed; secrets do not.** The TOML is policy, not credentials. Check you are not putting paths holding sensitive data into `rw_maps`.
- **`readOnlyRootFilesystem` breaks apps that write to `/tmp`.** Mount an `emptyDir` at `/tmp` as well as the work one.
- **A NetworkPolicy with no DNS rule leaves the pod blind.** Open port 53 to `kube-system` or 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 `/sandbox` does not cover MCP or hooks** unless you enable `sandbox-runtime`. If your agent uses MCP servers, assume they run with full permissions until you do.
- **`NotDAddr` with 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](https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/index.md) — the hardening of the whole cluster beyond agent isolation.

- [The contractor with the master key: isolating AI agents from workstation to cluster](https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/index.md) — 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](https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/index.md) — 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](https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/index.md) — Tetragon events as technical evidence for `op.mon`/`op.exp`; enforcement as a protection measure.
- [Guardrails and safety in LLMs](https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/index.md) — mitigation on the content plane; this runbook, on the execution plane.
- [Seven phases of deploying an on-premise LLM platform](https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/index.md) — 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>

