Run Model-Written Code Safely with AgentCore Code Interpreter
In Part 1 we gave the model add, subtract, multiply and divide. That works until someone asks a question you did not anticipate. You cannot write a tool per question — so write one tool that runs code.
In Part 1 we gave the agent four tools — add, subtract, multiply and divide — because a language model predicting the next token is not a calculator.
That works right up to the first question you did not anticipate.
"What is the 90th percentile of these forty numbers?" needs a percentile tool. "Which region grew fastest between Q1 and Q2?" needs a growth_rate tool. "Now weight it by units sold" needs another one. You are writing a function for every question a user might think of, and you will lose that race.
So stop. Write one tool that runs code, and let the model write the code.
The obvious implementation is the wrong one
The one-line version of that tool is:
@tool
def run_python(code: str) -> str:
return exec(code) # please do not
Think about what that actually does. The code was written by a language model. It runs inside your process — with your AWS credentials, your filesystem, your network, your environment variables. A prompt injection on a web page the agent read earlier is now arbitrary code execution on your machine.
And the usual reflex — "I'll sandbox it" — is worse than it sounds. Sandboxing Python from inside Python is a problem people have been failing to solve properly for decades. Every __builtins__ filter has a documented escape.
The correct answer is not to make exec safe. It is to run the code somewhere that is not yours.
What we're building
An agent with exactly one tool. It answers questions about a 48-row sales CSV by writing Python, and something else runs it:
--- code the model wrote, call 1 ---
import csv
from collections import defaultdict
revenue_by_region_quarter = defaultdict(float)
with open('sales.csv', 'r') as file:
for row in csv.DictReader(file):
key = (row['region'], row['quarter'])
revenue_by_region_quarter[key] += float(row['revenue'])
...
--- what the sandbox printed ---
east, Q1: 210000.0
east, Q2: 231000.0
...
west, Q1: 64000.0
west, Q2: 83200.0
The region with the largest percentage growth in revenue
from Q1 to Q2 is west with a growth of 30.00%
answer : The region with the largest percentage growth in revenue from Q1 to Q2
is the west region with a growth of 30.00%.
took : 14.1s
The project is agentcore-code-interpreter. It has the smallest amount of infrastructure of anything in this series, for a reason we will get to.
AgentCore Code Interpreter
It is a sandbox you rent by the second: an isolated container with Python in it, started by an API call and destroyed by another. AWS runs it; you never see the host.
Three API operations do everything:
| Operation | What it does |
|---|---|
StartCodeInterpreterSession |
A sandbox now exists, and is billing |
InvokeCodeInterpreter |
Run something in it |
StopCodeInterpreterSession |
It is gone, filesystem included |
The middle one is a single operation with a name argument, so the same call runs code, writes files or lists a directory depending on what you ask for. executeCode and writeFiles are two names for the same door.
There is nothing to create
Here is the part that surprised me. Every AWS account already has a sandbox:
SYSTEM_CODE_INTERPRETER = "aws.codeinterpreter.v1"
No resource to declare, no container to build, no VPC, no warm pool. You only declare your own Code Interpreter to change its defaults — a longer session timeout, or a network mode that reaches into a VPC — and reading a CSV needs neither.
The agent does not need AgentCore Runtime either. Code Interpreter is an ordinary AWS API, so this agent runs on your laptop and still gets a real sandbox in AWS. Deploy it to Runtime when you want it to be a service; that is Part 1's job, not this one's.
So the entire Terraform for this part is a permission:
actions = [
"bedrock-agentcore:StartCodeInterpreterSession",
"bedrock-agentcore:InvokeCodeInterpreter",
"bedrock-agentcore:StopCodeInterpreterSession",
"bedrock-agentcore:GetCodeInterpreterSession",
"bedrock-agentcore:ListCodeInterpreterSessions",
]
One detail worth copying. aws.codeinterpreter.v1 is owned by AWS, not by you, so its ARN sits under the aws account segment rather than your account id. The example policy in the AWS documentation shows only the account-scoped form, which is right for a custom interpreter and easy to paste when you are using the built-in one:
resources = [
"arn:aws:bedrock-agentcore:${var.aws_region}:aws:code-interpreter/*",
"arn:aws:bedrock-agentcore:${var.aws_region}:${local.account_id}:code-interpreter/*",
"arn:aws:bedrock-agentcore:${var.aws_region}:${local.account_id}:code-interpreter-custom/*",
]
The session wrapper
# src/code_agent/sandbox.py
class Sandbox:
def start(self) -> str:
response = self._client.start_code_interpreter_session(
codeInterpreterIdentifier=self.identifier,
name="code-agent-session",
sessionTimeoutSeconds=self.session_timeout_seconds,
)
self.session_id = response["sessionId"]
return self.session_id
def run_code(self, code: str, language: str = "python") -> Result:
return self._invoke("executeCode", {"language": language, "code": code})
def write_file(self, path: str, text: str) -> Result:
return self._invoke("writeFiles", {"content": [{"path": path, "text": text}]})
def __enter__(self) -> "Sandbox":
self.start()
return self
def __exit__(self, exc_type, exc, tb) -> None:
# Stop even if the body raised. An exception is exactly when you are
# most likely to leak a session.
self.stop()
The context manager is not tidiness. A session you forget about keeps running until its timeout expires and you are billed for all of it — and the moment you are most likely to forget is when something threw an exception. There is a test for it:
def test_the_session_is_stopped_even_when_the_body_raises(self):
sandbox, client = make()
with pytest.raises(ValueError):
with sandbox:
raise ValueError("boom")
assert client.stopped == 1
Silence is not success
One small thing in the response parsing that matters more than it looks:
text = "\n".join(p for p in parts if p).strip()
if not text:
text = "(no output - the code ran but printed nothing)"
Code that runs cleanly but prints nothing returns an empty string. A model reading an empty tool result concludes something broke and starts debugging working code. Say what happened instead.
The tool
# src/code_agent/tools.py
@tool
def run_python(code: str) -> str:
"""Run Python code in a sandbox and return everything it printed.
Use this for any calculation, aggregation or data lookup. The sandbox keeps
its state between calls, so variables and imports persist. Only output that
the code explicitly prints comes back.
"""
result = _sandbox.run_code(code)
text = result.text
if len(text) > MAX_OUTPUT_CHARS:
dropped = len(text) - MAX_OUTPUT_CHARS
text = f"{text[:MAX_OUTPUT_CHARS]}\n... [{dropped} more characters truncated]"
if result.is_error:
return f"Error: the code raised.\n{text}"
return text
Two decisions in there.
The traceback goes back to the model. A NameError is not a failure of the tool — it is information the model needs to fix its own code, and it usually does, on the next turn. Same principle as Part 2: return errors as data.
Output is truncated. A runaway loop printing a million lines should cost you a truncated tool result, not a context window.
Sessions have a memory
A session is a running sandbox that remembers. Variables, imports and files survive from one call to the next, which is what lets an agent work in steps: load the data, look at it, then compute. scripts/session_state.py shows it, and shows the other half too:
--- session one ---
session id: 01KZVYMQM669TNVHTWX0KNG9C3
1. set a variable
set
2. read it back in a separate call
1234
3. write a file
Successfully wrote all 1 files
4. read the file back
written in session one
--- session one has been stopped ---
--- session two ---
session id: 01KZVYMV2BCNGVVSK2G6J3S898
5. look for the variable <- error
NameError: name 'total' is not defined
6. look for the file <- error
FileNotFoundError: [Errno 2] No such file or directory: 'note.txt'
Two sessions, two sandboxes, nothing shared. That is why the agent writes its CSV into the sandbox at the start of a run, and why a run should reuse one session rather than open a new one per tool call.
Look closely at one of those tracebacks:
Cell In[2], line 1
----> 1 print(total)
Cell In[2]. It is a Jupyter kernel underneath, which is exactly why state persists between calls.
Proving it is not your machine
"Runs in a secure isolated environment" is easy to read, nod at, and never check. So scripts/prove_isolation.py runs the same probe twice — once in this process, once in the sandbox — and prints both:
your machine the sandbox
------------------------------ ------------------------------
* hostname Mugilans-MacBook-Pro.local localhost
* user mugilanragupathi genesis1ptools
* cwd /Users/.../agentcore-code-inte /opt/amazon/genesis1p-tools/var
* python 3.13.1 3.12.13
aws_env_vars none none
* reads_aws_creds True False
* sees_this_repo True False
The last two rows are the product. The sandbox cannot read your credentials and cannot see your files.
The aws_env_vars row is worth a moment, because it is the row that proves nothing. My first version of this script only checked environment variables, saw none on both sides, and would have let me claim a difference that was not there — credentials normally live in ~/.aws/credentials, not the environment. That is the row below it, and that one differs. Measure the thing that would actually hurt you.
What the sandbox does not fix
While building this, the agent answered the question with "north, 146.43%".
The sandbox had done nothing wrong. The session started, the code ran, it printed a number, the number came back. The number was wrong, because the model had written this:
if quarter == 'Q1':
data[region]['Q1'] = revenue # assigns, does not add
There are three product rows per region and quarter. Assigning instead of adding keeps only the last one. The real answer is west at 30%; north's 146% is an artifact of looking at one product out of three.
Nothing in the infrastructure can catch that. Code Interpreter guarantees that the code you wrote is the code that ran — it does not guarantee the code is right.
The fix was three lines in the system prompt:
- Before comparing regions or quarters, ADD UP revenue and units across the
products. Assigning a row's value instead of adding it keeps only the last
product and silently gives a wrong answer.
- Sanity-check the numbers you print. A quarter-on-quarter change of more than
about 50% means you have probably aggregated wrongly - print the totals you
computed and look at them before answering.
With that, the same question returns west at 30.00%, and prints the totals so you can check them yourself.
Two things follow, and they are why cli.py prints the code before it prints the answer:
- An agent that shows its working can be checked. One that only shows its answer cannot.
- The weak link is the reasoning, not the runtime. Every part of this series so far has been infrastructure. This is the part where the infrastructure was perfect and the answer was still wrong.
There is a test guarding it. The dataset's product mix varies by region, so code that forgets to aggregate picks the wrong region:
assert winner(summed) == "west"
assert winner(last) == "north"
The first version of that CSV had a constant product split, which meant the buggy code produced the right answer by accident — a fixture that teaches nothing. A test fixture should punish the most likely mistake.
Run it
uv sync --extra dev
uv run pytest -q # offline, no AWS, no cost
uv run agent-ask
uv run agent-ask "What was the best-selling product by units in Q4?"
What did we learn?
- You cannot write a tool per question. Part 1's four arithmetic tools do not scale to arbitrary analysis. One tool that runs code does.
exec()is not the implementation. Model-written code in your process means your credentials, your filesystem, your network. Do not sandbox Python from inside Python — run it somewhere else.- There is nothing to create.
aws.codeinterpreter.v1exists in every account. The entire Terraform for this part is an IAM policy, and the agent does not need AgentCore Runtime at all. - Sessions are stateful and billed. Variables and files persist between calls; a new session is a new machine. Wrap it in a context manager so an exception cannot leak one.
- Hand tracebacks back to the model. A
NameErroris information, not a failure. Truncate runaway output. - Empty output is not an error, so say so. Otherwise the model debugs code that worked.
- Check the isolation claim yourself, and check the thing that would actually hurt you rather than the thing that is easy to measure.
- The sandbox guarantees the code ran, not that it was right. The most expensive bug in this project was a
=that should have been a+=, and the fix was in the prompt, not the infrastructure.