TL;DR
Give an agent a tool that runs code, and the blast radius stops being the tool’s — it’s the pod’s: the ServiceAccount token on disk and every network path the pod can open. Policy decides who may call the tool; it can’t shrink what the running code reaches. That’s the fence’s job, from outside the process: NetworkPolicy, no mounted token, and a gVisor sandbox.
In the lab a poisoned note makes the agent run a script that steals its token and ships it off-cluster. The fence stops it — but not evenly. Dropping the token is rock-solid. NetworkPolicy on k3d with a service mesh was racy. gVisor wouldn’t install on an Apple-Silicon laptop. The blast-radius report is what tells you which layer actually held, instead of the checkbox that says it should.
Originally published at portfolio.hagzag.com.
The tool I’d been avoiding adding
Part 3 ended with a pod that skipped the gateway and deleted a bucket. The permit was checked at the gate; nothing forced traffic through the gate. So Part 4 builds the fence — and I built it around the worst tool a system like this can have: one that runs code.
run_script is a new MCP tool, in its own sandbox-runner pod, behind the same gateway and the same permit checks as everything else. It runs whatever the model hands it. That’s not a strawman — a “run a quick diagnostic”, “reshape this data”, “call this API for me” tool is exactly what teams reach for. And the moment it exists, the interesting question isn’t “who may call it”. It’s “when it runs, what can it touch”.
The answer, by default: the pod’s ServiceAccount token, sitting at a well-known path, and the whole cluster network.

Break: the poisoned note runs a script
Same attack as the whole series — a hostile instruction hidden in the migration inventory — but this time the note doesn’t say “delete a bucket”. It says “run the diagnostics script”. The naive agent does. First, the Part 3 bypass still works with no fence in place:
forge=bypass target=http://mcp-tools:8000/mcp result='{"result": "deleted prod-archive"}'
Then the exfil. The executor calls run_script; the gateway allows it (the human holds scripts:run) and brokers it to the runner:
gateway=ALLOW tool=run_script sub=tester act='executor-agent via planner-agent' caller=executor-agent
gateway=broker tool=run_script upstream=http://sandbox-runner:8000/mcp sub=tester
runner=run_script language=python bytes=772 sub=tester
The script reads /var/run/secrets/kubernetes.io/serviceaccount/token and POSTs it to a sink pod in a separate internet namespace — a stand-in for anywhere data shouldn’t go. The sink logs what landed:
sink=received bytes=1230 src=10.42.0.20 preview=eyJhbGciOiJSUzI1NiIsImtpZCI6IjVJU0tGTGRTaEkz...
That preview is a real JWT. 1230 bytes of ServiceAccount token, off the cluster. Everything up to here was authorized: valid permit, allowed tool, right identity. The tool did exactly what it’s for. That’s the point — you cannot policy your way out of this. The permit was correct.
The fence is three cuts, from outside the process
The sandbox isn’t the interpreter — you can’t exec() your way to safety when the code is chosen by an attacker. The pod is the sandbox, and you shrink it from the outside:
- NetworkPolicy — the gateway becomes the only road to
mcp-tools(closing Part 3’s bypass), and the runner may egress only DNS and the mesh control plane. The sink and the kube API drop off its map. - No token —
automountServiceAccountToken: false. The app loses its token; Linkerd keeps its own projected identity token, so the mesh still works. - gVisor — the runner runs under
runsc, a user-space kernel, so a container escape lands in gVisor rather than on the host.
task part4:fence
Now replay the attack. The bypass is dead:
forge=bypass target=http://mcp-tools:8000/mcp result='refused: Server returned an error response'
And the exfil? The same run_script runs — it’s still allowed, this was never a policy fix — but it finds nothing worth taking. At break the script’s output was 44 bytes (it read a token and sent it). After the fence it’s longer — 62 bytes — because the interesting lines are now error strings:
# break: read_token bytes=1230 | kube_api=200 | exfil=200
# fenced: read_token=FileNotFoundError | kube_api=... | exfil=...
The sink logs nothing new. There was no token to read, and nowhere to send it.
What actually held — and what didn’t
Here’s where a demo would show three green checks and move on. The real cluster didn’t, and the report is what caught it.
Dropping the token: rock-solid. sa_token: yes → no, every run. This is the cut I trust most, and not by accident: it’s the same lesson as the rest of the series. A credential that isn’t there can’t be stolen, the same way a short-lived permit beats a long-lived key. If you do one thing to a code-running tool, it’s this.
Blocking the bypass with ingress policy: reliable enough. The direct-to-mcp-tools call is refused every run, and the report shows the rogue pod BLOCKED from mcp-tools.
Blocking egress with NetworkPolicy: racy. This is the honest part. On one run the exfil’s send failed (exfil=URLError, nothing at the sink); on the next, the runner still reached the sink 30 seconds after the policy was applied. Two things fight here: kube-router (k3s’s policy controller) doesn’t program a freshly-created pod’s rules instantly — there’s a pod-startup window — and a service mesh and a NetworkPolicy controller are both rewriting the pod’s iptables. On k3d with Linkerd, egress enforcement came out non-deterministic. I’m not going to pretend a flaky control is a fence. For egress you actually trust, use a CNI whose policy engine owns the dataplane (Cilium, Calico) or an egress gateway the mesh doesn’t contend with.
gVisor: not on this laptop. runsc doesn’t ship in a form the installer could place on a k3d node running under Docker Desktop on Apple Silicon, so the runner stayed on runc. The RuntimeClass and the install path are in the repo; on a Linux node with the right kernel they work. On my machine, they didn’t, and the lab says so rather than pretending.

The report is the point
Every one of those findings came from the same thing: a probe that runs from each pod, before and after, and reports what it can actually reach.
| pod | runtime | sa_token | mcp-tools | kube-api | sink |
| sandbox-runner | runc | no | BLOCKED | REACH | REACH |
| rogue | runc | yes | BLOCKED | REACH | REACH |
One subtlety cost me a run to notice: under a mesh, a plain TCP connect always succeeds, because it lands on the pod’s own sidecar proxy before it ever leaves. The proxy accepts you locally and only then fails to reach the real upstream. So the probe has to make a real request and require bytes back — otherwise every target reads as reachable and the report is a comforting lie. That’s the difference between a blast-radius report and a checkbox: the report can catch its own tooling being fooled.
The road ahead
- The Safe Highway: mTLS, and its limits.
- License Plates: attested workload identity, and signed payloads.
- Driving Permits: delegation, scopes, TTL, policy at the gateway.
- The Closed Track (this post): shrink the blast radius — no token, egress policy, sandbox — and measure which layer holds.
- The Black Box: the audit chain. Replay this exfil and name the human, the agent and the task that led to it.
The lab is practice/part4 in agentic-highway-labs. task part4:all runs it on k3d; task part4:break steals the token; task part4:fence closes it. The report is the deliverable — read it, don’t trust the manifest.
Conclusion
A tool that runs code is a tool that inherits a pod. You can’t shrink its blast radius from inside the process, and you can’t policy the attacker’s code away once the call is authorized. You shrink the pod: take away the credential, wall off the network, sandbox the runtime. Then — and this is the part the checkbox skips — you measure, because on a real cluster some of those walls won’t be where the YAML says they are. The credential you removed is the one that was never going to move.
Discussion