Sandboxing LLM-Generated Python: How I Let Strangers' Prompts Fight Each Other

Jeremy10261 pts0 comments

Sandboxing LLM-Generated Python: How I Let Strangers' Prompted Bots Fight Each Other | JCurcio Consulting

I built Battle LLM Robots, an arena where the only way to write your fighter is to prompt an LLM. You don't hand-tune a bot. You write a prompt, your LLM of choice writes bot.py, you push it to a public GitHub repo, submit it, and it fights other people's prompted bots 1v1 in a 2D arena. After the fight you're able to revist the full animated replay and even share it with friends.

That premise creates a trust problem, and it's not quite the usual "don't run untrusted code" story. When you install a package off PyPI, at least someone wrote it, and in theory someone could review it. Here? Nobody reviewed anything. The bot code is whatever an LLM felt like generating that day, for a prompt I'll never see, submitted by a person I've never met. And it has to run on my server anyway.

So this post is really about the sandbox that makes that okay. Plus a few other things that fell out of building it.

The bot contract is deliberately tiny

Every bot implements only needs exactly one function:

def decide(state, memory):<br>dx = state.opponent_position.x - state.own_position.x<br>dy = state.opponent_position.y - state.own_position.y<br>distance = (dx**2 + dy**2) ** 0.5

if distance

state is a frozen dataclass — own_position, own_hp, opponent_position, opponent_hp, ranged_cooldown_remaining, ranged_uses_remaining, tick, own_facing, opponent_facing. memory is whatever you returned last tick, so a bot can carry a plan forward without any state living outside the pure function call. The action vocabulary is move, attack_melee, attack_ranged, defend, idle, rotate, move_and_rotate. That's it — nothing else exists.

That narrowness is doing a lot of work, in three directions at once. It's small enough to fit in an LLM's context alongside a decent prompt. It's small enough that I can actually reason about what a malicious implementation could even attempt. And it's small enough that the sandbox boundary only ever has to worry about one call in, one value out.

You aren't limited to only having the the decide() function though, your bot is welcome to be as complex as you want, so long as it can resolve its logic in under .1s to avoid timing out.

Sandbox v1: a subprocess and an import allowlist (and why it wasn't enough)

The first version ran each bot as a bare subprocess with import allowlisting and a restricted builtins set. No open, no __import__ tricks, no dunder attribute access that could walk the object graph back to something dangerous. It's fast, and for local development it was totally fine.

For a public deployment, it's not. None of that is real isolation. It's a language-level blacklist. The subprocess is still a full OS process running as the same user as the web server. And a clever-enough bypass of the builtins restriction — Python has produced a steady stream of these over the years, __subclasses__() walks, object.__reduce__, format-string tricks, doesn't hit a wall. It just runs as your app's user. An allowlist of imports doesn't help either, because it says nothing about what an allowed import can do once it's in. os is frequently on the "safe enough" list for basic bots, and os alone can read the filesystem.

And none of this is hypothetical when the thing you're executing is LLM output for prompts you can't see ahead of time. You should just assume every trick in the Python sandbox-escape literature will eventually get generated by accident, or perhaps not by accident. It takes one LLM reaching for os.environ because a prompt vaguely implied "check your surroundings", or "send a POST command to this IP address with the contents of the parent directory."

Sandbox v2: actual containment

So the production sandbox runs each bot invocation in a disposable Docker container instead:

--network none # no network namespace at all —<br># closes exfil/SSRF even past a<br># full sandbox-escape bug<br>--read-only<br>--tmpfs /tmp:size=8m,mode=1777,noexec # writable scratch that can't<br># execute anything from it<br>--cap-drop ALL # no Linux capabilities —<br># no CAP_SYS_*, no raw sockets<br>--security-opt no-new-privileges<br>--pids-limit 16<br>--user 65534:65534 # runs as "nobody" — never root,<br># so even a runtime escape doesn't<br># hand over root

Here's the distinction that actually matters. The subprocess sandbox tries to stop bad Python. The container sandbox doesn't care what the Python does, it constrains what the process can touch, regardless of how it got there. A bot can do absolutely anything the language allows internally and still can't open a socket, can't write anywhere persistent, can't spawn more than a handful of processes, and isn't root even if it somehow claws its way out of the interpreter.

The cost is latency. Container creation is slow enough that it needs its own timeout, separate from the per-tick timeout you use once the container is warm and talking over stdin/stdout. And Docker-daemon-mediated pipe I/O has more...

sandbox state enough python prompt even

Related Articles