Running Untrusted Code Safely: Sandboxing smolagents (smolagents, Module 5)
This is Module 5 of smolagents Mastery, a free 15-module course that takes you from your first code agent to a production-grade one. Start at Module 1 or browse the full syllabus.
The Quill you built in Module 4 works. You hand it a CSV, it writes pandas, it answers. On a tame question, that loop is a joy to watch — the model reasons, writes a few lines of analysis, prints a chart path, and calls it a day.
Here is the part nobody mentions in the tutorials: that line of Python the model just wrote, and that your machine just executed — you never read it. On an innocent analysis, fine. But Quill is about to learn to fetch context off the web. The day it lands on a page with a hidden instruction, a “helpful” model can write import os; os.system(...) — and that runs in your process, with your permissions, on your files. That is the bill that comes due for code-as-action: you handed the LLM the maximum possible power to act on your system.
This module is where you stop paying that bill blind. You will understand exactly why running LLM-generated Python is the structural risk of a CodeAgent, learn what the local interpreter does and does not protect, and put Quill behind a real sandbox boundary that you control.
In this module
You’ll learn
- Explain the four-vector threat model of running LLM-generated Python — and why prompt injection through web and tool output is the realistic attack on a
CodeAgent.- Trace how the
LocalPythonExecutor(an AST allow-list interpreter, notexec/eval) blocks imports, caps operations, and times out — and why it is explicitly “not a security sandbox.”- Choose an executor (
local/docker/e2b/modal/blaxel) based on how much you trust the model and the inputs.- Distinguish Approach 1 (snippet-in-sandbox) from Approach 2 (whole-agent-in-sandbox) — and explain why multi-agents only works in Approach 2.
- Lock down a
CodeAgent: minimaladditional_authorized_imports, a remote executor, Docker hardening flags, and deterministic cleanup with the context manager.You’ll build — Quill now runs inside a Docker (or E2B) sandbox:
quill/sandbox.pypicks the executor fromQUILL_EXECUTOR, its imports are locked to pandas/numpy/matplotlib, and you’ll watch it block a dangerous import and hit a loop cap — then clean up automatically.Theory areas covered
- T5 — Secure code execution & sandboxing — 12% of the course. The heaviest area in the security quarter; this module owns it (co-owned with the Module 15 capstone).
- T2 — code-as-action (reinforced): why the risk exists.
Prerequisites — Modules 1–4 (the
CodeAgent, the ReAct loop, Quill’s data tools,make_model()). For the lab: Docker installed (the recommended path) or an E2B account (E2B_API_KEY).HF_TOKENconfigured (Module 1).
Where you are in the series
- ✅ Modules 1–4 — your first
CodeAgent, the ReAct loop, Quill’s data toolbox, the model layer (make_model()). - 👉 Module 5 — Sandboxing. You are here.
- ⬜ Modules 6–15 — memory, planning, reliability, MCP, multi-agents, multimodal, RAG, deployment, telemetry, and the capstone.
Quill before this module: it runs in your local process — a CodeAgent whose generated Python executes in the reader’s own Python interpreter, with no security boundary.
Quill after this module: it runs behind a sandbox boundary you control, chosen by QUILL_EXECUTOR, with its imports locked to a least-privilege list.
The executor policy you freeze here is not a one-module curiosity. Every later module inherits it, and the capstone (Module 15) drives it to its conclusion: the whole multi-agent system inside one sandbox (Approach 2).
Why a CodeAgent is dangerous: the threat model
Start with why a CodeAgent is powerful, because its power and its danger are the same property. A CodeAgent writes its actions as executable Python rather than JSON tool calls. smolagents builds on this on purpose. The docs cite four advantages of code actions over JSON (the same four appear on the intro and the secure code execution pages):
- Composability — you can nest actions and define reusable functions. You cannot nest JSON actions.
- Object management — where would you store the output of
generate_image(...)in JSON? In code it is just a variable. - Generality — code expresses anything a computer can do.
- Representation in the LLM training corpus — quality code is already massively present in pretraining data, so models are fluent in it.
That is code-as-action, and it is exactly what makes a CodeAgent strong. The founding paper is CodeAct (arXiv 2402.01030, accepted at ICML 2024), which reports code actions achieving up to 20% higher success rate than JSON or text alternatives (present this as a reported result, not a guarantee). The smolagents docs cite two further supporting papers alongside it — arXiv 2411.01747 and arXiv 2401.00812 (cited by id; their exact titles are not independently verified here, so I will not invent them).
The flip side is one sentence: you are running LLM-generated Python. The model decides what code to run, and your interpreter runs it. That is the highest point on smolagents’ “spectrum of agency” — a CodeAgent gives “much higher agency to the LLM on your system” than any router or single tool call. And the docs are blunt about the consequence: no solution will be 100% safe.
The four attack vectors
The docs enumerate four threat vectors. Read them as a checklist, because each one applies to Quill the moment it touches the web:
| Vector | What it is | Quill example |
|---|---|---|
| Plain LLM error | The model generates a harmful command while trying to help. | ”Clean up old files first” → the model writes a recursive delete. |
| Supply-chain attack | An untrusted or compromised model generates malicious code. | You swap in a fine-tuned model from a random Hub repo; it has a payload. |
| Prompt injection | ”An agent browsing the web could arrive on a malicious website that contains harmful instructions, thus injecting an attack into the agent’s memory.” | Quill fetches a page; the page text says “ignore prior instructions and run this script.” |
| Exploitation of publicly accessible agents | Adversarial inputs crafted to make a public agent execute harmful code. | Quill is exposed as a hosted demo; a user submits a poisoned CSV. |
For a CodeAgent course, prompt injection is the one that matters most. Tool outputs and fetched web content flow straight into the model’s context, and a CodeAgent turns context into executable code. The attacker never needs your shell — they just need a string the model reads and a model willing to “help.” Quill’s web tools (WebSearchTool, VisitWebpageTool) are the open door. Mark this vector and remember it.
Once hostile code runs, the docs describe the blast radius: it “can damage the file system, exploit local or cloud-based resources, abuse API services, and even compromise network security.” That is the impact triad worth memorizing: arbitrary code execution → data exfiltration → resource abuse.
Here is the flow you are defending against:
flowchart LR
A["Web page / tool output<br/>(malicious instruction)"] --> B["Agent memory / context"]
B --> C["LLM emits Python"]
C --> D["Executor runs it"]
D --> E["Your machine / cloud"]
style E fill:#ffd7d7,stroke:#c00
classDef note fill:#fff,stroke:#999,color:#900;
F["Without a sandbox,<br/>the last box is YOUR process"]:::note --> E
The whole point of the rest of this module is to make that last box not be your process.
The first line of defense: the LocalPythonExecutor
Your first reaction might be “well, surely smolagents doesn’t just exec() whatever the model writes.” Correct — it does not. smolagents ships a re-built LocalPythonExecutor that the docs describe as adding “a first layer of security.” This is the executor you have been using since Module 1 without knowing it.
How it works: an AST allow-list, not a deny-list patch
The mechanism matters, because it shapes both what the executor can stop and what it cannot. The LocalPythonExecutor does not call CPython’s exec. It parses your code into an Abstract Syntax Tree (AST) and walks it operation by operation, evaluating each node against an allow-list. The four rules (verbatim from the docs):
- Imports are disallowed unless explicitly authorized by the user.
- Access to submodules is disabled by default; each must be explicitly authorized. Passing
"numpy.*"allowsnumpyand its subpackages (numpy.random, …). The docs flag a caveat worth pinning: “some seemingly innocuous packages likerandomcan give access to potentially harmful submodules, as inrandom._os.” - The total count of elementary operations is capped to prevent infinite loops and resource bloat.
- Any operation not defined in the custom interpreter raises an error.
Rule 4 is the design philosophy: this is an allow-list interpreter, not a deny-list filter stapled on top of exec. A deny-list is a losing game — you have to enumerate every dangerous thing. An allow-list flips the burden: nothing runs unless the interpreter explicitly knows how to run it safely.
The exact constants (the internals)
This is the level the tutorials skip. As of smolagents 1.26.0, here are the values that govern the local executor:
| Thing | Value (as of smolagents 1.26.0) | Where it lives |
|---|---|---|
BASE_BUILTIN_MODULES | 11 stdlib modules, always allowed | utils.py (not local_python_executor.py) |
MAX_OPERATIONS | 10_000_000 elementary ops | local_python_executor.py |
MAX_WHILE_ITERATIONS | 1_000_000 per while loop | local_python_executor.py |
MAX_EXECUTION_TIME_SECONDS | 30 (wall-clock) | local_python_executor.py |
DEFAULT_MAX_LEN_OUTPUT | 50_000 chars of captured output | local_python_executor.py |
| Error type | InterpreterError(ValueError) | local_python_executor.py |
The 11 always-on modules are: collections, datetime, itertools, math, queue, random, re, stat, statistics, time, unicodedata. A common confusion (and a great trick question): they live in utils.py, not in local_python_executor.py.
On the deny side, the executor hard-codes a DANGEROUS_MODULES list — builtins, io, multiprocessing, os, pathlib, pty, shutil, socket, subprocess, sys — and a DANGEROUS_FUNCTIONS list including builtins.eval, builtins.exec, builtins.compile, builtins.__import__, os.system, os.popen, and posix.system. Safe builtins are provided positively through BASE_PYTHON_TOOLS (a dict of vetted callables: print, len, range, sum, isinstance, math helpers, and so on).
When you run something the interpreter refuses — an unauthorized import, a capped loop, an undefined operation — it raises InterpreterError, a subclass of ValueError. You will see this exact exception in the lab. Because it subclasses ValueError, the agent’s loop captures it as a step error and can self-correct instead of crashing your process.
additional_authorized_imports vs authorized_imports
Two names, two meanings — and the exam-of-life loves the confusion:
additional_authorized_importsis the argument you pass (toCodeAgent(...)and toLocalPythonExecutor(...)). It is additive toBASE_BUILTIN_MODULES.authorized_importsis the computed, effective list —BASE_BUILTIN_MODULES ∪ additional_authorized_imports— exposed asagent.authorized_imports.
So if you pass additional_authorized_imports=["pandas", "numpy.*"], the effective agent.authorized_imports also contains statistics, datetime, math, and the other base modules — but never os, socket, or subprocess.
There is one value you should know exists and never use in production code: the wildcard "*". Passing "*" authorizes every import. The agent logs a caution — “Caution: you set an authorization for all imports, meaning your agent can decide to import any package it deems necessary…” — and the executor docstring warns “Use this at your own risk!” This is the only time "*" appears in this course, and it appears as a counter-example. Quill’s import list is never "*".
”It is not a security sandbox”
Here is the sentence to tattoo somewhere. The docs carry a prominent warning:
“It’s important to understand that no local python sandbox can ever be completely secure. While our interpreter provides significant safety improvements over the standard Python interpreter, it is still possible for a determined attacker or a fine-tuned malicious LLM to find vulnerabilities and potentially harm your environment.”
And the LocalPythonExecutor class docstring (as of smolagents 1.26.0) says it outright: “It is not a security sandbox.”
A concrete escape the docs give: authorize Pillow for legitimate image work, and the model can “generate code that creates thousands of large image files to fill your hard drive.” No banned import, no eval — just an allowed library used adversarially. The conclusion the docs reach, and the one this module is built on:
“The only way to run LLM-generated code with truly robust security isolation is to use remote execution options like E2B or Docker.”
⚠️ Common misconception: “The local interpreter is a sandbox, so I’m safe.”
No. The AST allow-list is an attack-surface reduction, not a security boundary — the library itself says “it is not a security sandbox.” It will stop
import osand a runaway loop, which is genuinely useful. It will not stop a determined attacker, a malicious fine-tuned model, or the abuse of a perfectly legitimate authorized library. The only robust isolation is remote execution (Docker/E2B). Treatlocalas “low risk, not zero,” and reach for a remote executor the moment your inputs or model are not fully trusted. This is the number-one trap of the module.
Real isolation: remote executors and the two approaches
If the local interpreter is not a sandbox, what is? A separate execution environment — a container or a microVM — where the model’s Python runs outside your process. smolagents gives you this through one CodeAgent parameter: executor_type.
executor_type and the dispatch
As of smolagents 1.26.0, the valid set is exactly:
executor_type: Literal["local", "blaxel", "e2b", "modal", "docker"] = "local"
There is no "wasm" value. The Wasm/Pyodide executor existed from v1.20.0 to v1.25.0 and was removed in v1.26.0 (PR #2321). Many tutorials, blog posts, and cached search snippets still show executor_type="wasm" — they are all wrong now. The valid set is {local, blaxel, e2b, modal, docker}.
Internally, CodeAgent builds the executor in create_python_executor(): "local" returns a LocalPythonExecutor; any other value returns the matching remote class, constructed with additional_authorized_imports, the logger, and your executor_kwargs.
The structural constraint: remote rules out multi-agents (in Approach 1)
There is one line in that dispatch you need to internalize, because it decides the entire shape of multi-agent isolation. If executor_type is remote and the agent has managed_agents, the library raises:
Exception("Managed agents are not yet supported with remote code execution.")
That is why you cannot simply set executor_type="docker" on a manager agent and call it a day. Model calls in this mode stay local (you do not ship secrets to the sandbox), but a managed sub-agent would need model credentials inside the sandbox — and the snippet-in-sandbox design deliberately does not transfer secrets across the boundary. You will build a research team in Module 10 — and to isolate it, you will have to put the whole team inside one sandbox, not each snippet. Hold that thought.
The remote executor classes
All remote executors subclass RemotePythonExecutor(PythonExecutor), whose constructor is:
def __init__(self, additional_imports: list[str], logger, allow_pickle: bool = False)
Keep allow_pickle=False (the default, as of smolagents 1.26.0). With False, variables move across the boundary via a safe JSON serializer (SafeSerializer, with a "safe:" prefix). Setting allow_pickle=True falls back to pickle (the "pickle:" prefix), and the docs warn: “Pickle deserialization can execute arbitrary code. Only set allow_pickle=True if you fully trust the execution environment.” In other words, the unsafe option is gated behind a flag whose default keeps you safe.
The four backends, as of smolagents 1.26.0:
| Backend | What it is | Install |
|---|---|---|
DockerExecutor | A local container via a Jupyter Kernel Gateway. Free, runs on your hardware. Quill’s default sandbox. | pip install 'smolagents[docker]' (docker>=7.1.0 + websocket-client) + Docker installed |
E2BExecutor | A cloud microVM (Firecracker). The lab’s swap. | pip install 'smolagents[e2b]' (e2b-code-interpreter>=1.0.3) + an e2b.dev account |
ModalExecutor | A serverless cloud sandbox. Mentioned, not required. | pip install 'smolagents[modal]' + a Modal account |
BlaxelExecutor | Fast-hibernation microVMs (resume “in under 25ms”). Mentioned, not required. | pip install 'smolagents[blaxel]' + a Blaxel account |
The recommended way to use any of them is the context-manager form, which guarantees teardown:
from smolagents import InferenceClientModel, CodeAgent
with CodeAgent(model=InferenceClientModel(), tools=[], executor_type="docker") as agent:
agent.run("Compute the 100th Fibonacci number.")
The with block runs agent.cleanup() on exit, which for Docker/E2B stops and removes the container or sandbox — even if the run raises.
State, tools, and imports across the boundary
In remote mode, a persistent kernel keeps variables alive between steps (an E2B session, or a Jupyter Kernel Gateway kernel for Docker/Modal/Blaxel) — so a variable defined in step 1 is still there in step 3. Behind the scenes, send_tools() pushes the agent’s tools into the sandbox, send_variables() moves variables (JSON-serialized via SafeSerializer), and install_packages() runs pip install for the packages named in additional_imports (the executor’s first constructor argument). You rarely call these directly; the agent does it for you at the start of each run.
Approach 1 vs Approach 2 — the core distinction
This is the conceptual heart of the module, and the diagram the capstone reuses. There are two ways to put a CodeAgent behind a sandbox:
- Approach 1 — snippet-in-sandbox. Set
executor_type="docker"(ore2b/modal/blaxel). The agent, model, and tools stay local; only the generated Python snippets travel to the sandbox, and only the output returns. Simplest possible thing (one parameter). Model calls stay local, so secrets never enter the sandbox. Does not support multi-agents. - Approach 2 — whole-agent-in-sandbox. You create the sandbox by hand (e.g.
e2b_code_interpreter.Sandbox()or a Docker container) and run the entireCodeAgent/manager inside it. Supports multi-agents, gives complete isolation — but it is more manual and may require passing secrets (likeHF_TOKEN) into the sandbox.
| Approach 1 (snippet-in-sandbox) | Approach 2 (whole-agent-in-sandbox) | |
|---|---|---|
| What runs in the sandbox | Only generated Python snippets | The entire agent system (model orchestration, tools, sub-agents) |
| Model calls | Local | Inside the sandbox |
| Secrets in sandbox? | No | Yes (must be passed in) |
| Multi-agent supported? | No (raises an Exception) | Yes |
| Setup effort | One parameter (executor_type=) | Manual sandbox creation + bootstrap |
| When to use | Single agent, untrusted code, no secrets in the box | Multi-agent teams that need full isolation |
flowchart TB
subgraph A1["Approach 1 — snippet-in-sandbox"]
direction LR
L1["LLM + Agent + Tools<br/>(local)"] -->|code snippet| S1["Sandbox:<br/>runs snippet"]
S1 -->|output| L1
N1["no managed_agents allowed"]
end
subgraph A2["Approach 2 — whole-agent-in-sandbox"]
direction TB
S2["Sandbox"]
S2 --- INNER["LLM-driven Agent<br/>+ sub-agents + Tools"]
N2["multi-agent OK · secrets must enter the box"]
end
Approach 1 is what you build in this module’s lab. Approach 2 is the capstone (Module 15). The distinction is the single most useful thing to carry forward from this module.
Locking it down: hardening, cleanup, and choosing wisely
You have isolation. Now make it tight — and make sure you can reason about how a run ends and how the sandbox is torn down.
final_answer crosses the boundary (the internals)
A run ends when the model calls final_answer(...). How does that value get from inside a remote sandbox back to your local process? Through a deliberately unusual exception.
final_answer(...) is implemented via a FinalAnswerException that inherits from BaseException — not Exception:
class FinalAnswerException(BaseException): # local_python_executor.py
...
That inheritance is the whole point. Because it is a BaseException, a generic except Exception: in the agent’s own error-handling code can never accidentally swallow the final answer and mistake it for an error. The “we’re done” signal is structurally distinct from “something broke.”
- Locally:
final_answer(x)raisesFinalAnswerException(x);evaluate_python_codecatches it and returns the value withis_final_answer=True. - Remotely:
RemotePythonExecutor._patch_final_answer_with_exception(...)patchesFinalAnswerTool.forward()so that, inside the sandbox, callingfinal_answerraises a sandbox-sideFinalAnswerExceptioncarrying the serialized value. The host catches it by name and_deserialize_final_answer(...)reconstructs the value locally.
The unit returned everywhere is the dataclass CodeOutput, with fields output, logs, and is_final_answer: bool. You will not write this code, but knowing it exists explains why a remote run “just works” the way a local one does.
Docker hardening flags
For any remote deployment, do not run a vanilla container. The docs show the hardening flags to teach — pass them through executor_kwargs:
from smolagents import CodeAgent
with CodeAgent(
tools=[], model=model, executor_type="docker",
executor_kwargs={"container_run_kwargs": {
"mem_limit": "512m",
"cpu_quota": 50000,
"pids_limit": 100,
"security_opt": ["no-new-privileges"],
"cap_drop": ["ALL"],
}},
) as agent:
...
And in the Dockerfile, run as a non-root user: USER nobody. The docs organize the practice into four axes — keep them as your checklist for any sandbox (they apply to Blaxel, E2B, and Docker alike):
| Axis | What to do |
|---|---|
| Resource management | Cap memory and CPU, set execution timeouts, monitor usage. |
| Security | Minimal privileges, disable network access when not needed, secrets via env vars — never hard-coded. |
| Environment | Minimal dependencies, pinned versions, base images kept up to date (CVEs). |
| Cleanup | Always tear down — “especially for Docker containers, to avoid having dangling containers eating up resources.” |
Deterministic cleanup
Cleanup is not optional housekeeping; it is a resource and cost control. Use the context manager:
with build_quill() as agent: # cleanup() runs on exit — stops + removes the container
result = agent.run(task)
with build_quill(...) as agent: tears the sandbox down immediately. If you genuinely cannot use with, call agent.cleanup() yourself in a finally. Skip it and you leave dangling containers eating disk and (for cloud SaaS) money. The lab formalizes this in quill/sandbox.py.
The decision hierarchy
When do you pick which executor? Here is the recommendation, with an opinion attached:
- Trusted model and inputs →
local. It is “low risk, not zero” — instant and free, no isolation. Fine for a script you wrote analyzing data you trust. - Untrusted model/inputs, or a publicly exposed agent → a remote executor (
docker/e2b/modal/blaxel). The moment a web page or a user can influence the code, isolate it. - Multi-agents + isolation → the whole system in the sandbox (Approach 2, the capstone).
| Executor | Isolation strength | Where it runs | Latency / startup | Cost | Multi-agent (Approach 1)? | Setup |
|---|---|---|---|---|---|---|
local | Weakest — AST allow-list, not a security boundary | Your process | Lowest | Free | Yes (default) | None |
docker | Strong (container) | Local container | Image build + container start | Free (your hardware) | No | Docker installed |
e2b | Strong (cloud microVM) | E2B cloud | Network + VM spin-up | Paid SaaS | No | E2B account/key |
modal | Strong (serverless) | Modal cloud | Network + sandbox create | Paid SaaS | No | Modal account |
blaxel | Strong (microVM) | Blaxel cloud | <25ms from hibernation | Paid SaaS | No | Blaxel account |
(Isolation and latency characterizations are the docs’ own framing, as of smolagents 1.26.0.)
Quill’s frozen import lock
This is where the least-privilege principle becomes code. A data-analysis agent needs pandas, numpy, matplotlib, json, statistics — and nothing else. So Quill’s import list is frozen to exactly that:
QUILL_AUTHORIZED_IMPORTS = ["pandas", "numpy", "matplotlib.*", "json", "statistics"]
Never "*". Least privilege beats convenience every time: a data snippet has no business importing os, socket, or subprocess, so it simply cannot. If Quill is prompt-injected into writing import os, the import fails before any harm is done — you will see exactly that in a moment. The lab freezes this list in one place so it can never silently drift.
Build it: Quill behind a sandbox boundary
Goal: give Quill a single, frozen place — quill/sandbox.py — that decides where its generated Python runs (QUILL_EXECUTOR in {local, docker, e2b}) and what it may import (a least-privilege list, never "*"), wire it into build_quill(), run Quill inside a Docker container, and watch the first layer of defense block a dangerous import and cut off a runaway loop.
Observable result: uv run python -m quill.demo_sandbox --attack shows Quill’s import lock refusing import os and a while True loop with an InterpreterError — offline, no Docker or token needed. QUILL_EXECUTOR=docker uv run python -m quill.demo_sandbox runs Quill’s Python inside a container, then cleans it up (no dangling container).
The full, tested code lives at smolagents-course-labs/module-05. Its offline tests pass with no network and no token.
Step 1 — Set up
Copy the Module 4 state (the cumulative rule: M5 code must still pass the M1–M5 smoke tests), then add the [docker] extra:
uv venv --python 3.11
uv pip install "smolagents[toolkit,litellm,openai,docker]==1.26.0" \
"huggingface_hub>=1.0,<2" "pandas>=2.2.3" matplotlib
cp module-05/.env.example module-05/.env # add your HF token; never commit .env
docker info # confirm the Docker daemon is running
Step 2 — Write quill/sandbox.py (the frozen contract)
This file does exactly one thing — decide the executor and the import lock — and it does not build the agent. The whole point is that “change Quill’s isolation/import policy” becomes a one-file edit, and the lock can never drift to "*" somewhere downstream.
import os
DEFAULT_EXECUTOR = "local"
SUPPORTED_EXECUTORS = ("local", "docker", "e2b") # NOTE: no "wasm" (removed in 1.26.0)
# Quill's frozen least-privilege import list — NEVER the "*" wildcard.
QUILL_AUTHORIZED_IMPORTS = ["pandas", "numpy", "matplotlib.*", "json", "statistics"]
def resolve_executor() -> tuple[str, list[str]]:
executor_type = os.environ.get("QUILL_EXECUTOR", DEFAULT_EXECUTOR).strip().lower()
if executor_type not in SUPPORTED_EXECUTORS:
raise ValueError(
f"Unknown QUILL_EXECUTOR {executor_type!r}. "
f"Supported executors: {', '.join(SUPPORTED_EXECUTORS)}. "
"(Note: 'wasm' was removed from smolagents in 1.26.0 — it is not a valid value.)"
)
return executor_type, list(QUILL_AUTHORIZED_IMPORTS) # a copy, so the constant can't drift
Two design choices to notice. First, resolve_executor() fails loud on an unknown value — a typo, or a stale QUILL_EXECUTOR=wasm from an old tutorial, never silently drops the sandbox. Second, it returns a copy of the import list, so a caller cannot mutate the frozen constant by accident. "matplotlib.*" authorizes the package and its submodules (matplotlib.pyplot), exactly like "numpy.*".
Step 3 — Wire it into quill/agent.py
build_quill stays in quill/agent.py (the frozen construction entry point) and now calls resolve_executor():
from .sandbox import resolve_executor
def build_quill(model: Model | None = None) -> CodeAgent:
executor_type, authorized_imports = resolve_executor()
return CodeAgent(
tools=[load_dataset, profile_dataframe, save_chart(),
WebSearchTool(), VisitWebpageTool()],
model=model or make_model(role="analyst"),
executor_type=executor_type, # WHERE the code runs (M5)
additional_authorized_imports=authorized_imports, # WHAT it can import (M5)
max_steps=8,
)
Do not duplicate the import list anywhere, and do not move build_quill into sandbox.py. We deliberately do not pass managed_agents — multi-agents is Module 10, and a remote executor plus managed_agents would raise that Exception you saw earlier. That combination needs Approach 2 (Module 15).
Step 4 — Demo the isolation
quill/demo_sandbox.py’s --attack mode feeds two hostile snippets straight to the agent’s executor — the same executor it uses every step — so the demo shows the sandbox verdict without any model call:
from smolagents.local_python_executor import InterpreterError
def _run_snippet(agent, label, code):
try:
agent.python_executor(code) # the SAME executor the agent uses each step
print(">>> NOT blocked (unexpected).")
except InterpreterError as exc:
print(f">>> BLOCKED by the executor: InterpreterError: {exc}")
# Attack 1 — blocked: 'os' is not in authorized imports.
_run_snippet(agent, "import os", "import os\nos.system('echo pwned')\n")
# Attack 2 — blocked: the executor caps While-loop iterations (MAX_WHILE_ITERATIONS = 1_000_000).
_run_snippet(agent, "runaway loop", "x = 0\nwhile True:\n x += 1\n")
Run it (offline — no Docker, no token):
uv run python -m quill.demo_sandbox --attack
[demo] executor='local' authorized_imports=['pandas', 'numpy', 'matplotlib.*', 'json', 'statistics']
--- Attack 1: import os and run a shell command (data-exfil / arbitrary code) ---
import os
os.system('echo pwned > /tmp/quill_pwned.txt')
>>> BLOCKED by the executor: InterpreterError: ... Import of os is not allowed. ...
--- Attack 2: a runaway loop (resource abuse / denial of service) ---
x = 0
while True:
x += 1
>>> BLOCKED by the executor: InterpreterError: ... Maximum number of 1000000 iterations in While loop exceeded
Both attacks are stopped by the first layer of defense — before any sandbox boundary, before any harm. The import os never succeeds, so os.system(...) never runs; the loop is cut off at the cap. Remember the lesson from earlier, though: this layer is a surface-area reduction, not a security sandbox. For untrusted inputs, you still run QUILL_EXECUTOR=docker.
Step 5 — Run it in Docker, then clean up
Everything goes through the context manager so a remote sandbox is torn down even if the run raises:
QUILL_EXECUTOR=docker uv run python -m quill.demo_sandbox
docker ps -a # no dangling jupyter-kernel container afterward
with build_quill() as agent: # cleanup() runs on exit (stops + removes the container)
result = agent.run(build_task("data/sales.csv", "How many rows?"))
A real Approach-1 caveat you will hit here. Under a remote executor, smolagents sends the agent’s custom tools into the sandbox via
send_tools(). Quill’s@tooldata tools reference a module-level helper (_read_table), which the remote-serialization path (SimpleTool.to_dict()) rejects — soagent.run()raisesValueError: SimpleTool validation failed for load_dataset: Name '_read_table' is undefined. The container starts and the import lock is active; a bareCodeAgent(no custom tools) runs fine in Docker. Making the data tools self-contained for remote sending is out of scope for this module — the point here is the sandbox boundary, which works.
Step 6 — Run the tests
The offline tests run with no network and no token; the sandbox tests skip cleanly if Docker is absent, and live tests skip without HF_TOKEN:
uv run pytest module-05/tests/ # offline
QUILL_EXECUTOR=docker uv run pytest -m sandbox module-05/tests/ # the Docker runs
63 passed, 5 skipped in 4.44s
The offline suite verifies the contract: resolve_executor() defaults to local, validates QUILL_EXECUTOR (and rejects a stale wasm), returns the frozen import list (never "*"); build_quill() wires the executor and the lock; import os and import subprocess raise InterpreterError; the runaway loop hits the cap; and pandas/numpy/json still import.
Try it yourself (not graded):
- Swap to E2B. Sign up at e2b.dev (free tier), put
E2B_API_KEYin.env,uv pip install "smolagents[e2b]==1.26.0", then run withQUILL_EXECUTOR=e2band compare the spin-up time against Docker. - Starve a snippet of memory. Add
executor_kwargs={"container_run_kwargs": {"mem_limit": "256m", "cap_drop": ["ALL"]}}to adockerrun and watch a deliberately memory-hungry snippet get killed by the limit.
What this lab deliberately does not do: no multi-agents (Module 10) — a single CodeAgent only; we only state the constraint (remote + managed_agents raises, so multi-agent isolation needs Approach 2). No full Approach 2 (the manual sandbox-around-everything is Module 15). No step callbacks or multi-turn memory (Module 6), no telemetry (Module 14), no executor_type="wasm" (it does not exist in 1.26.0).
In production
Two things change the moment this leaves the lab: latency and cost. A remote sandbox adds spin-up time (a container or VM plus the network round-trip), and for E2B/Modal/Blaxel it adds a SaaS bill.
localis free and instant — and isolates nothing. So choose with intent rather than reflex.For any remote deployment, I pin the base image version and patch it on a schedule (CVEs land in base images), cap memory/CPU/PIDs and the execution timeout, and cut the sandbox’s network when the work doesn’t need it — a data-analysis snippet that never calls the web should not be able to exfiltrate. Concretely: I cap the executor at 512 MB and
cap_drop=["ALL"]— a data snippet has no business opening a socket. And I clean up every run through the context manager; dangling containers quietly eat disk and, on cloud SaaS, money.The other production reality is the one Module 10 will force: Approach 1 isolates snippets but cannot isolate a team. The capstone (Module 15) runs the entire multi-agent system inside a single sandbox (Approach 2) — the only topology that combines real isolation with multi-agents.
Concept check
Why this matters. This module owns the heaviest security area in the course (T5, 12%). The three things to walk away able to do: explain why the local executor is not a sandbox (it is a surface-area reduction, and the library says so), choose an executor by how much you trust the model and inputs, and explain why multi-agents forces Approach 2. This executor policy is the foundation every later module reuses, all the way to the capstone — so getting it right here pays off ten more times.
Quiz. Five scenario questions; answers and explanations are grouped after all five.
1. A teammate says: “We use the LocalPythonExecutor, so the agent’s generated code is isolated from our system.” What is the most accurate correction?
- A. Correct — the AST interpreter is a full security sandbox.
- B. Wrong — the AST allow-list reduces the attack surface but is “not a security sandbox”; an authorized library can still be abused (e.g. Pillow filling the disk). Robust isolation needs a remote executor.
- C. Wrong —
localruns nothing; you must setexecutor_type="docker"for any execution at all. - D. Correct, as long as you also set
allow_pickle=False.
2. You’re deploying Quill as a public web demo that accepts arbitrary user CSVs and questions, and it browses the web for context. Which executor choice fits?
- A.
local— it’s instant and the model is reliable. - B.
localwithadditional_authorized_imports="*"for flexibility. - C. A remote executor (
docker/e2b) — the inputs and fetched content are untrusted and the agent is publicly exposed. - D. It doesn’t matter; smolagents sandboxes everything by default.
3. You set executor_type="docker" on a manager agent that has a managed_agents=[researcher]. What happens, and what’s the fix?
- A. It works fine; both agents run in the same container.
- B. It raises
Exception("Managed agents are not yet supported with remote code execution."); the fix is Approach 2 — run the whole system inside one sandbox. - C. The sub-agent silently runs locally without isolation.
- D. You must set
executor_type="wasm"for multi-agent support.
4. Quill visits web pages to gather context before analyzing data. Which threat vector dominates, and what’s the right defense?
- A. Supply-chain attack; defense = verify the model checksum.
- B. Plain LLM error; defense = a better prompt.
- C. Prompt injection via web/tool content; defense = remote isolation plus a locked import list — not “the model is trustworthy.”
- D. None; reading web pages cannot affect generated code.
5. Quill’s analysis only needs pandas, numpy, and matplotlib. A colleague suggests additional_authorized_imports="*" “to save time.” Why is that a bad choice, and what should you pass?
- A.
"*"is fine; it’s the recommended default. - B.
"*"authorizes every import (the library warns “Use this at your own risk!”); pass the minimal least-privilege list["pandas", "numpy", "matplotlib.*", "json", "statistics"]instead. - C.
"*"is required for pandas to import at all. - D. Pass
["os", "subprocess"]so the agent can manage files.
Answers.
1 — B. The local interpreter is an attack-surface reduction, not a boundary. The docs and the class docstring both say it “is not a security sandbox,” and an allowed library (Pillow) can be abused without any banned import. Real isolation = remote executor. (A and D overstate it; C is false — local does execute code.)
2 — C. Untrusted inputs plus public exposure is precisely the “use a remote sandbox” case. local is “low risk, not zero” — wrong for a public, arbitrary-input agent. "*" makes it worse, not safer.
3 — B. create_python_executor raises that exact exception when a remote executor meets managed_agents. The fix is Approach 2: build the sandbox manually and run the whole agent system inside it (the capstone). "wasm" does not exist in 1.26.0.
4 — C. Tool outputs and fetched web content enter the model’s context, and a CodeAgent turns context into executable code — prompt injection is the realistic vector. Defend with isolation and a locked import list; “trust the model” is not a control.
5 — B. "*" authorizes any import and triggers the library’s caution. Least privilege wins: pass exactly what a data analysis needs. A data snippet has no business importing os or subprocess.
Common pitfalls.
executor_type="wasm"no longer exists. It was removed in 1.26.0 (PR #2321). Countless tutorials and cached snippets still show it — all wrong. The valid set is{local, e2b, docker, modal, blaxel}.- “The local interpreter is a sandbox.” It is a surface-area reduction; the library itself says “it is not a security sandbox.” Robust isolation = a remote executor.
- Forgetting cleanup. Without
with ... as agent:(oragent.cleanup()), Docker containers stay around as “dangling” containers — eating disk, and for cloud SaaS, money.
Key takeaways
- Prompt injection is the realistic attack on a
CodeAgent: tool outputs and web content enter the context and can steer the model into emitting malicious Python. The four vectors are plain LLM error, supply-chain, prompt injection, and exploitation of public agents. - The
LocalPythonExecutorreduces the attack surface but “is not a security sandbox.” It is an AST allow-list interpreter that blocks unauthorized imports and caps operations (MAX_OPERATIONS=10_000_000,MAX_WHILE_ITERATIONS=1_000_000, a 30 s timeout) — useful, but not a boundary. - Robust isolation = a remote executor. As of smolagents 1.26.0 the valid set is
{local, e2b, docker, modal, blaxel}; Docker is free and local, E2B/Modal/Blaxel are cloud SaaS. - Approach 1 (snippet-in-sandbox) ≠ Approach 2 (whole-agent-in-sandbox). Only Approach 2 supports multi-agents — a remote executor plus
managed_agentsraises anException. - Least privilege beats the wildcard. Lock
additional_authorized_importsto exactly what you need — Quill uses["pandas", "numpy", "matplotlib.*", "json", "statistics"], never"*". executor_type="wasm"is gone (removed in 1.26.0) — ignore any tutorial that still shows it.- Always clean up via
with build_quill(...) as agent:(oragent.cleanup()) — no dangling containers.
Quill runs safely now — but it forgets everything between questions. Module 6 opens up its memory: multi-turn runs, step callbacks, and replay — so Quill can carry context across a conversation and you can inspect exactly what happened on every step.
Want the full smolagents concept-check bank and the next module in your inbox? Subscribe here — it’s free, like everything in this series.
Links: Lab code (module-05) · Module 4 — Models and Providers · Module 6 — Memory, State, and Inspecting Runs · Course index
References
- Secure code execution (smolagents docs): https://huggingface.co/docs/smolagents/main/en/tutorials/secure_code_execution
- Python code executors (API reference): https://huggingface.co/docs/smolagents/main/reference/python_executors
local_python_executor.py(source, constants +LocalPythonExecutor): https://github.com/huggingface/smolagents/blob/main/src/smolagents/local_python_executor.pyremote_executors.py(source,RemotePythonExecutor+ backends): https://github.com/huggingface/smolagents/blob/main/src/smolagents/remote_executors.py- v1.26.0 release notes (Wasm removal, PR #2321): https://github.com/huggingface/smolagents/releases/tag/v1.26.0
- CodeAct — Executable Code Actions Elicit Better LLM Agents (arXiv 2402.01030): https://arxiv.org/abs/2402.01030