The bug that never crashed: how I fuzzed an AI's own code sandbox and found it lying to its model
This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.
The bug that never crashed: how I fuzzed an AI's own code sandbox and found it lying to its model
The scariest bug I caught this month never threw a stack trace. It never paged anyone. It just quietly made an AI agent dumber, on repeat, and handed the blame to the model. Here is the whole story, because the way I found it turned out to matter more than any single fix.
Where it started
smolagents is Hugging Face's agent framework, 28k+ stars, and it has an unusual design: the agent writes Python as its reasoning, and a sandboxed interpreter (LocalPythonExecutor) runs that code. The model thinks by coding. So the sandbox is not a side feature. It is the surface the model lives on.
I went in looking for one bug to fix for this challenge. Every obvious open issue was already claimed or had a competing PR. So instead of reading the issue tracker, I did something almost stupid: I fed the sandbox ordinary, boring, valid Python and watched what it refused to run. The rule I gave myself: if it is valid Python and the sandbox rejects it, that is a bug. The model writes valid Python. If the sandbox chokes on it, the agent pays.
The first false rejection
config = { ** {"temperature": 0.7}, "top_p": 0.9 } - merging dicts with **. Every LLM writes this. The sandbox's answer: InterpreterError: NoneType is not supported. There is no None in that line. I stared at it, then went to the source.
In Python's AST, a ** spread entry inside a dict literal has None where the key would be, a marker that says "this is a spread." smolagents walked the keys and tried to evaluate that None as if it were an expression. So the spread marker got blamed on a value the developer never wrote. That is when it clicked. This was not a crash bug. It was a lying bug.
Why lying is worse than crashing
Here is the part that turned a one-off fix into an obsession. The error was handled. smolagents catches it and feeds it back to the model as guidance: "here is what went wrong, try again." Good design, normally. But the guidance was wrong. The message said NoneType, and the model's code had no None, so the model could not act on it. It did the only thing a faithful agent can do with a message that is already a lie: it retried the exact same valid code. And again. And again.
I only saw the loop because I had wired the demo to Sentry. One issue. Three events. Three identical failures on one line, each burning a real LLM call and a slot in the step budget, until the run gave up and produced a worse answer than it should have. Without something counting the events, you would never see the loop. You would just see an agent that "isn't very good," and you would go blame the model.
A red stack trace is mercy. It tells you where to look. The polite, well-handled, misleading error is the one that eats your afternoon, or in this case, eats the agent's entire budget while looking like helpful feedback.
Sentry's Seer read the same event and reached the same root cause I did, independently: None keys in ast.Dict fed to the evaluator, agent retries in a loop. That was the moment I trusted the pattern enough to go hunting.
The pattern held everywhere I looked
Same fuzzer, same rule, more boring valid Python. The sandbox kept lying:
10 ** 10 ** 8did not error at all. It froze the entire process, forever, and the execution timeout never fired, because a single big-integer operation runs as one uninterruptible C call that holds the GIL and never lets the timeout thread wake up. Thefaulthandlerdump showed the main thread stuck before the timer even armed.best, *rest = scoresfailed with "Cannot unpack tuple of wrong size." There was no wrong size. The sandbox simply never implemented starred unpacking, a feature Python shipped in 2008.a, b = "hi"was rejected outright. Strings unpack fine in real Python.[a, b] = [1, 2]silently assigned nothing. No error, no values. The quietest failure of all.
Four different corners of the language. One personality: confidently wrong, politely delivered, and invisible unless you were counting.
The fixes, briefly
Each one came down to matching CPython instead of guessing:
- Dict spread: evaluate pairwise, a
Nonekey means merge a mapping. And gate it onhasattr(value, "keys"), not theMappingABC, so duck-typed mappings work too. - The freeze: you cannot interrupt a GIL-holding C call, so refuse to start it. Estimate the result's bit length from the operands in O(1) and raise a real error above a cap, pointing the model at
pow(base, exp, mod). - Unpacking: accept any iterable, support one starred target that absorbs the surplus into a list, handle list-pattern targets, and use CPython's exact error messages so that when the model does pass the wrong count, it gets an actionable message instead of a dead end.
Four fixes, four PRs, sixty-plus new tests, all verified failing on main first.
The twist I did not expect
I opened the PRs. Minutes later, OpenAI's Codex reviewer commented on two of them, and it was right both times. On the big-int fix it caught that I had checked type(x) is int, which lets bool and int subclasses slip through the guard. On the dict fix it caught that my Mapping ABC check was stricter than CPython.
So the final scoreboard was: an AI wrote the buggy code, a different AI reviewed my AI-assisted fix and found the hole, and a third AI (Seer) had already confirmed the root cause. I was the one steering, but I spent a good chunk of this project as the human in a loop of machines checking each other. Fitting, for a bug about an agent that could not tell it was stuck.
What I actually learned
- Fuzz the boundary with valid input. Everyone fuzzes with garbage to find crashes. The higher-value bugs in an AI system are the false rejections: valid input the system refuses, because that is what silently degrades a model that is doing everything right.
- A handled error is not a safe error. If the message is wrong, "handled" just means the failure is quiet instead of loud. Quiet is worse.
- Instrument for absence and repetition. The tell for this whole bug class was not an exception. It was the same event, three times. An agent getting a different error each step is exploring. An agent getting the same error from the same input is stuck, and the stuckness is invisible to the agent because the message looks like feedback. Count your events.
The bugs are fixed. The lesson I am keeping is simpler than any of them: the dangerous failures in an agent stack are not the loud crashes. They are the polite, confident, wrong messages that let the model fail on repeat while everyone blames the model.
The evidence
- PR: dict unpacking (issue)
- PR: the GIL-holding freeze (issue)
- PR: starred and iterable unpacking (issue)
- PR: MCP tool serialization (issue)
Each one has its own full write-up with Sentry before/after evidence, if you want the deep dives. This was the story of how they all turned out to be the same bug wearing four different masks.
Comments
No comments yet. Start the discussion.