all posts

How to Give a smolagents Agent a Code Execution Sandbox

Ajay Kumar··9 min read

smolagents makes an unusual bet, and it is a good one: instead of emitting JSON tool calls, the agent writes Python. Want to call three tools and combine their results? That is a loop, not three round trips. The research behind code-as-action is convincing, and in practice it produces noticeably fewer steps per task.

The consequence is that the code executor is not a detail — it is the security model. Every single thing the agent does passes through it. In a JSON-tool framework, a bad tool call is bounded by what the tool does. Here, a bad action is arbitrary Python, and the only thing standing between it and your process is whatever executor you configured. That is worth thinking about for more than the thirty seconds most people spend on it.

What the default executor does, and where it stops

Out of the box, smolagents runs actions through its own restricted Python interpreter, in your process. It is not a naive eval — it walks the AST, allows only a permitted set of operations, and blocks imports outside an allow-list. That is genuinely more thoughtful than most frameworks manage, and it catches the obvious things.

But an interpreter-level allow-list is a filter, not a boundary. It runs in the same process, with the same file descriptors, the same environment variables — including your API keys — and the same network access as your application. The security question is 'can this filter be bypassed,' and the history of sandboxing Python inside Python is a long list of clever bypasses. The maintainers know this; the docs recommend a remote executor for anything untrusted. Take that advice literally.

If your agent reads anything a stranger can influence — a web page, an email, a support ticket, a PDF — then the code it writes is downstream of attacker-controlled text. In smolagents that means attacker-influenced Python, in your process, with your credentials in the environment. This is the single strongest argument for an out-of-process executor, and it applies to a much larger share of agents than people assume.

Moving execution out of your process

smolagents supports remote execution — running actions somewhere that is not your Python process. The pattern is the same whichever backend you use: hold a session, send code, get back stdout and any result, keep state between calls. Here is that pattern against a PandaStack microVM, using the persistent code context so variables and imports survive from one action to the next.

from pandastack import Sandbox


class MicroVMExecutor:
    """Runs agent actions inside a Firecracker microVM, not this process."""

    def __init__(self, ttl_seconds: int = 1800):
        self.sb = Sandbox.create(template="code-interpreter",
                                 ttl_seconds=ttl_seconds)
        # A persistent kernel: state survives between actions, like a notebook.
        self.ctx = self.sb.create_code_context(language="python")

    def __call__(self, code: str) -> str:
        ex = self.ctx.run_code(code, timeout_seconds=120)
        logs = ex.logs
        parts = [logs.get("stdout", ""), logs.get("stderr", "")]
        text = "\n".join(p for p in parts if p)
        return text[:8000] or "(no output)"

    def install(self, packages: list[str]) -> None:
        """Pre-install what the agent will need, once, at startup."""
        self.sb.exec(f"pip install {' '.join(packages)}", timeout_seconds=300)

    def close(self) -> None:
        self.ctx.close()
        self.sb.kill()

Two things are load-bearing here. The persistent code context is what makes multi-step agents work — a code agent that loses its variables between actions has to rebuild state every turn, which burns tokens and produces worse plans. And the truncation on the return value is not paranoia: an agent that calls df.to_string() on a large frame will otherwise fill your context window and your invoice in a single action.

The import list stops being a security control

When execution happens in-process, the import allow-list is your protection, so you keep it tight — and then spend your time fighting it, because the agent genuinely needs pandas, and then requests, and then something else. Every addition weakens the filter.

Once execution is in a VM, that tension disappears. The isolation is the VM boundary, so the import list goes back to being what it should have been all along: a hint about what is available, not a wall. Pre-install the libraries the agent will need at startup rather than letting it install them mid-run — installs are slow, they fail in ways models handle badly, and a pre-warmed environment makes runs reproducible.

One sandbox per agent, per task, or per user?

Per task is the right default. State should persist across the steps of one task — that is the whole point of a code agent — and it should not persist into the next one, because a file written during someone else's task showing up in yours is a data-leak bug wearing the costume of a caching optimisation.

Per user, holding a sandbox open across a session, is defensible for interactive assistants where continuity is the feature. If you do that, be deliberate: the sandbox now contains everything the user has ever asked about, and its lifetime is part of your data-retention policy whether you wrote that down or not.

Never share one sandbox between users. It sounds obvious written down, and it happens anyway, usually as a module-level global that seemed harmless in development and became multi-tenant in production.

Does a VM per task cost too much?

The instinct is that a VM per task is extravagant, and it is worth checking against numbers rather than intuition. On PandaStack there is no warm pool of idle VMs — every create restores a baked Firecracker snapshot, which lands around 179ms p50 and 203ms p99, so creating one per task is not a latency problem. Billing is per second at $0.054 per active vCPU-hour and $0.0162 per working-set GiB-hour, so a sandbox that spends most of a task waiting on the model bills close to nothing for that waiting.

In other words, the expensive thing about an agent run is the model, not the machine. Choosing a weaker isolation boundary to save on compute is optimising the wrong line item — a point worth making to whoever is asking why you cannot just run it in-process.

A short checklist before you ship

  • Execution is out of process, in something with its own kernel, for any agent that touches text you do not control.
  • The sandbox has no credentials it does not need. Do not forward your whole environment; pass the one token the task requires, if any.
  • Network is off unless the task needs it, and allow-listed when it does.
  • Every action has a timeout, and the sandbox has a TTL that outlives no conversation.
  • Outputs are truncated before they reach the model, and the agent is told they were truncated.
  • The sandbox is destroyed when the task ends, in a finally block, not left to the reaper.

Frequently asked questions

Is the built-in smolagents interpreter safe?

It is a careful filter and it stops casual mistakes, which is more than most frameworks offer by default. It is not a security boundary, because it runs in your process with your environment and your credentials, and in-process Python sandboxes have a long history of bypasses. The framework's own documentation points you at remote execution for untrusted code — that recommendation is the correct reading of its limits, not excessive caution.

Do I lose state if I move execution to a sandbox?

Only if you use a stateless execute call. Use a persistent code context — a long-lived kernel inside the sandbox — and variables, imports, and files survive between actions exactly as they do in-process. That continuity matters more for code agents than for tool-calling ones, because the agent's plan frequently assumes that what it computed in step two is still available in step five.

How do I stop the agent installing whatever it likes?

Pre-install what it needs and turn network access off. That is more effective than trying to police pip through prompting, and it makes runs reproducible: the same task gets the same environment every time, instead of depending on what PyPI served that afternoon. If a task genuinely needs a package you did not anticipate, that is a signal to update the template rather than to open the network permanently.

How much does running a microVM per task actually add?

Less than people expect on both axes. Creating one is a snapshot restore rather than a boot — about 179ms at p50 on our platform — so it does not show up next to model latency. And per-second billing means a sandbox idling while the model thinks costs almost nothing. Compared with the tokens a single agent run burns, the compute is a rounding error, which makes the weaker-isolation trade hard to justify on cost grounds.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.