Sandboxes
Run a data-analysis agent in a Chalk sandbox, then cut off its network, keep its API key out of reach, and limit what it can change.
An AI agent that writes and runs its own code is useful, and it is also a program you did not write, running on your infrastructure. In this tutorial you will build a small data-analysis agent for a fictional lender and run it in a sandbox. The agent answers questions about loan applications by writing Python, running it, and reading the results.
You will start with the agent unrestricted, then add one protection at a time and check that each one works. In this tutorial you will:
The code is in the
tutorials/locked-down-agent
folder of the chalk-ai/examples repository. Each step is one script, and each script builds
on the previous one, so you can read them side by side.
You need:
deploy.create and
deploy.redeploy permissions. If you created your Chalk team yourself, you already have
them. Otherwise, ask an admin for a role that includes them. See
Permissions.chalk router model list to see the models available to you.uv and the Chalk CLI.
Run chalk login from the folder you will work in.The agent’s model calls go through the Model Gateway and are billed to your provider connection. The tutorial makes about a dozen short requests.
git clone https://github.com/chalk-ai/examples.git
cd examples/tutorials/locked-down-agent
The agent authenticates to the Model Gateway with an API key. Create one, then store it as a
Chalk secret named AGENT_GATEWAY_KEY:
chalk router api-key create --description "Locked-down agent tutorial"
chalk secret set AGENT_GATEWAY_KEY
The first command prints the key once. The second command prompts for a value: paste the key.
config.pyOpen config.py and set three values for your environment:
API_HOST = "api.chalk.ai"
ENVIRONMENT_ID = "your-environment-id"
MODEL = "openai/gpt-4.1-mini"
API_HOST: the host from the API Server row of chalk config, without https://. The Model
Gateway is served from this host, under /v1/router.ENVIRONMENT_ID: the Env Id value from chalk whoami.MODEL: a chat model your gateway serves that supports tool calling.The agent is agent.py, and it runs inside the sandbox. It sends your question to the model
along with one tool, run_python. When the model asks to run a script, the agent runs it, sends
the output back, and repeats until the model replies with an answer:
for _ in range(8):
response = client.chat.completions.create(
model=os.environ["MODEL"], messages=messages, tools=TOOLS, temperature=0
)
message = response.choices[0].message
messages.append(message)
if not message.tool_calls:
print(f"\nAnswer: {message.content}")
return
for call in message.tool_calls:
code = json.loads(call.function.arguments)["code"]
output = run_python(code)
print(f"--- ran {len(code.splitlines())} lines of Python")
print(output[:500])
messages.append({"role": "tool", "tool_call_id": call.id, "content": output})
The client is the OpenAI Python client, pointed at the Model Gateway:
client = OpenAI(
base_url=f"https://{os.environ['API_HOST']}/v1/router",
api_key=os.environ[os.environ["KEY_SECRET"]],
default_headers={"X-Chalk-Env-Id": os.environ["ENVIRONMENT_ID"]},
)
Two details in agent.py make the agent more reliable. The system prompt lists the columns of
the data file, so the model does not guess them. And run_python returns a message when a script
prints nothing, so the model knows to add print() calls rather than assuming the script failed.
common.py holds what every step shares: the sandbox image, which installs openai and pandas
and adds agent.py; the upload of data/loans.csv to a volume named
loan-agent-data; and the short checks each step prints after the agent answers.
1_unrestricted.py uploads the data, starts a sandbox with the data mounted at /data, and
passes the gateway key to the sandbox as an environment variable:
import chalkcompute
import common
import config
common.upload_data()
sandbox = chalkcompute.Sandbox(
name="loan-agent",
image=common.IMAGE,
volumes=[(common.VOLUME, "/data")],
env={
**common.AGENT_ENV,
# The real gateway key, as an environment variable the agent can read.
config.KEY_SECRET: chalkcompute.Secret.from_chalk_env(config.KEY_SECRET),
},
).run()
try:
common.ask(sandbox, "Which loan purpose has the highest default rate?")
common.check_internet(sandbox)
common.check_key(sandbox)
finally:
sandbox.terminate()
Run it:
uv run 1_unrestricted.py
The first run builds the sandbox image, which takes a minute or two. Then the agent answers:
Question: Which loan purpose has the highest default rate?
--- ran 13 lines of Python
small_business 0.30386740331491713
Answer: The loan purpose with the highest default rate is "small_business" with a default rate of approximately 30.39%.
Request to pypi.org: 200
Key inside the sandbox: the real key (35 characters)
The agent’s wording can differ slightly from run to run. The number comes from the data, so it is the same each time.
The answer is right, and the last two lines show what is wrong with this setup. The sandbox can reach any site on the internet, so code the model writes could download anything or send your data anywhere. And the real gateway key is in the sandbox, where any script the agent runs can read it.
A network policy lists the hosts a sandbox can
reach, and blocks every other host. 2_network.py is Step 1 with one addition:
network_policy=chalkcompute.NetworkPolicy(allowed_hosts=[config.API_HOST]),
Run it:
uv run 2_network.py
--- ran 13 lines of Python
small_business
0.30386740331491713
Answer: The loan purpose with the highest default rate is "small_business," with a default rate of approximately 30.39%.
Request to pypi.org: URLError <urlopen error [Errno 104] Connection reset by peer>
Key inside the sandbox: the real key (35 characters)
The agent still works, because its model calls go to the one host the policy allows. The request
to pypi.org is now refused. The image installed pandas and openai while it was built, before
any policy applied, so the agent has the packages it needs without network access.
The key is still exposed. A script could not send it anywhere except the gateway, but it is still readable inside the sandbox, which the next step fixes.
add_credentials gives a secret to requests bound for specific hosts, without giving it to the
sandbox. The sandbox receives a placeholder in the secret’s environment variable. When a request
to an allowed host carries that placeholder in a header, Chalk replaces it with the real value on
the way out.
3_key.py removes the key from env and adds this instead:
.add_credentials(config.API_HOST, config.KEY_SECRET)
The agent does not change. It still sends the value of AGENT_GATEWAY_KEY as its API key. That
value is now the placeholder, and Chalk swaps in the real key between the sandbox and the gateway.
Run it:
uv run 3_key.py
--- ran 13 lines of Python
small_business 0.30386740331491713
Answer: The loan purpose with the highest default rate is "small_business" with a default rate of approximately 30.39%.
Request to pypi.org: URLError <urlopen error [Errno 104] Connection reset by peer>
Key inside the sandbox: a placeholder: __chalk-secret-d7d3326206db40fa93f5862d77a6abb3
Your placeholder has different characters at the end. If code in the sandbox prints or sends the placeholder, nothing is exposed: the swap happens only on requests to the gateway host.
The agent reads the data from a volume. In the earlier steps, the sandbox mounted the volume’s
latest version, and anything the agent wrote there would be saved to the volume when the sandbox
terminated. In 4_limits.py the sandbox mounts one specific version instead, which is an
immutable snapshot:
volume = common.upload_data()
# A specific version of a volume is an immutable snapshot.
snapshot = volume.at_version(volume.latest_version().id)
volumes=[snapshot.mount("/data")],
cpu="1",
memory="2Gi",
lifetime="900s", # Chalk terminates the sandbox after 15 minutes.
The step also sets resource limits. cpu and memory cap what the agent’s scripts can use, and
lifetime ends the sandbox after 15 minutes even if a script never finishes. The sandbox runs
under gVisor, which separates it from the host and from other sandboxes; see
Isolation.
After the agent answers, the script tries to add a row to the data file from inside the sandbox. After terminating the sandbox, it reads the saved data back.
Run it:
uv run 4_limits.py
--- ran 14 lines of Python
small_business
0.30386740331491713
Answer: The loan purpose with the highest default rate is "small_business" with a default rate of approximately 30.39%.
Adding a row to /data/loans.csv inside the sandbox: done
Request to pypi.org: URLError <urlopen error [Errno 104] Connection reset by peer>
Key inside the sandbox: a placeholder: __chalk-secret-470434cdb5f04772890e4fedaff8ceec
Row A99999 in the saved data: False
The write inside the sandbox succeeds, because the agent works on its own copy of the files. The change is never saved: a snapshot mount does not commit writes back to the volume, so the data the next sandbox reads is the data you uploaded.
Each step terminated its sandbox when it finished. 5_cleanup.py deletes the data volume:
uv run 5_cleanup.py
Deleted volume loan-agent-data.
The gateway key and the AGENT_GATEWAY_KEY secret remain. To remove them, run
chalk secret delete --name AGENT_GATEWAY_KEY, and revoke the key with chalk router api-key revoke,
using the key hash that chalk router api-key list shows.
The agent now runs with:
| Protection | Setting |
|---|---|
| Network access only to the Model Gateway | network_policy=NetworkPolicy(allowed_hosts=[...]) |
| No real credentials inside the sandbox | add_credentials(host, secret) |
| Data it can read but not change | A volume version, mounted with .mount(...) |
| Limits on resources and running time | cpu, memory, lifetime |
Each protection is a setting on the sandbox, so the agent code is the same in every step.
To give the agent approved tools, such as a search index or an internal API, without giving it the credentials for them, connect them through the MCP Gateway. See MCP Server Access.