Where to run code your AI agent wrote
Your laptop, a container, a CI runner or a disposable VM? Where to run agent-generated code and untrusted MCP servers so a bad result stays contained.
Guzman Pintos
9 min read
A coding agent runs a lot of code you didn't write. It generates scripts, installs dependencies and connects to tools like MCP servers that someone else published. Most of the time the result is fine. The question is what happens the one time it isn't, whether that's a hallucinated package name that turns out to be squatted, a postinstall script that reads your environment, or an MCP server whose tool descriptions tell the model to do something you never asked for.
The answer depends mostly on where that code runs, and what it can see and reach from there.
What's in reach from each option
Your machine
This is the default for most agent setups, and it has the most in reach. The agent's shell runs as you, so anything it executes can read your SSH keys, cloud credentials, .env files, shell history and whatever tokens are in your environment. It is also on your network.
Installing a package counts as running code. npm runs a package's preinstall, install and postinstall scripts as part of npm install (npm docs), so "I only let it install things" doesn't limit much.
A container on your machine
A container is a real improvement over running directly on the host, if you set it up carefully. Docker's own security guide warns that "the default set of capabilities and mounts given to a container may provide incomplete isolation, either independently, or when used in combination with kernel vulnerabilities" (Docker docs). The common failure is convenience: you mount your home directory so the agent can see the repo, or you mount docker.sock so it can build images, and the boundary is gone.
A CI runner
A GitHub-hosted runner gives you a fresh machine per job, which is good. The catch is what else the job holds. GitHub creates a GITHUB_TOKEN at the start of every job, and any action can read it from the github.token context even if your workflow never passes it (GitHub docs). Add whatever repository secrets the workflow exposes, and untrusted code in that job runs next to credentials for your repository, with whatever permissions the workflow grants them.
Self-hosted runners add persistence. A runner that isn't ephemeral carries state from one job to the next; the self-hosted runner security guide covers why.
A disposable VM
A VM you create for one task and destroy afterwards has nothing of yours in reach unless you put it there. The downside is operational: you have to provision it, get the code in and the results out, and make sure it really gets torn down, including when the task fails. A sandbox is a disposable VM with that plumbing handled for you.
The pattern
Whatever you run it on, the pattern is the same:
- Start a fresh environment for each task.
- Decide what it can reach over the network.
- Put no long-lived secrets inside.
- Run the code with a timeout.
- Collect the output and the exit code.
- Destroy the environment, whether the task passed or failed.
In Tenki Sandbox, each session is an isolated Linux VM that starts from the same base image, and terminating it releases all its resources. The shortest version of the pattern is four CLI commands, from the recipes page:
tenki sandbox create --name scratch --cpu 2 --memory-mb 4096
tenki sandbox exec --session <session-id> --timeout 2m -c './untrusted-script.sh'
tenki sandbox read --session <session-id> --path /home/tenki/build.log --out ./build.log
tenki sandbox terminate --session <session-id>
exec streams stdout and stderr and ends with the exit code and duration, so a script or an agent can decide what to do next from the result. For a cap on the whole session rather than one command, add --max-duration at create time.
Cutting off the network
Every session has two network settings, fixed at create time: inbound (preview URLs) and outbound (the guest calling out). Both are on by default so that npm install and git clone work without extra flags (Sessions).
For code you don't trust, turn them off. With outbound off, the code can't phone home or send anything anywhere, and you still move files in and out through the SDK:
from tenki import Sandbox
with Sandbox.create(
name="untrusted-run",
allow_inbound=False,
allow_outbound=False,
max_duration=600,
) as sb:
sb.fs.upload("./agent-output.tar.gz", "/home/tenki/job.tar.gz")
result = sb.exec(
"bash", "-lc",
"cd /home/tenki && tar xzf job.tar.gz && ./run.sh > run.log 2>&1",
timeout=300,
)
sb.fs.download("/home/tenki/run.log", "./run.log")
print(result.exit_code, result.duration_ms)
The with block closes the session on exit. Install tenki from PyPI, not the old tenki-sandbox package, which is frozen at 0.4.0 (Sessions).
Outbound is all or nothing: the docs don't describe allowlisting specific domains. If the code needs to install packages from a registry, you have to leave outbound on, and then assume that anything inside the session can leave it. Step 3 of the pattern exists for this case.
Keep secrets out
Environment variables you pass with --env or env are plaintext configuration and show up in read APIs, and the docs say not to use them for long-lived credentials (Sessions). Keep your TENKI_API_KEY on the machine that drives the sandbox, not inside it.
If a task needs a private repository, the SDKs take a GitHub token at create time for the clone, which the SDK reference says works "without provisioning credentials inside the guest". For code you really don't trust, it's simpler to upload a tarball of exactly what it should see.
Checking an agent's work in a sandbox
Running the tests is the obvious check on an agent's change, and a sandbox keeps the project's toolchain and install scripts off your machine. Tests only tell you so much, though, because agents also write the tests. Mutation testing asks whether those tests would catch a bug: the tool changes your code in small ways and checks that some test fails each time.
Because it reruns tests against each mutant, mutation testing takes far longer than a single test run, which makes it a good fit for a bigger, short-lived VM rather than your laptop. For a JavaScript repo that already has a StrykerJS config:
tenki sandbox create --name agent-checks \
--cpu 4 --memory-mb 8192 --disk-size-gb 20 \
--max-duration 30m --allow-inbound=false
tenki sandbox exec --session agent-checks --timeout 25m -c '
git clone https://github.com/org/repo /home/tenki/repo &&
cd /home/tenki/repo &&
git checkout <agent-branch> &&
npm ci && npm test && npx stryker run
'
tenki sandbox read --session agent-checks \
--path /home/tenki/repo/reports/mutation.html --out ./mutation.html
tenki sandbox terminate --session agent-checks
Outbound stays on here because npm ci needs the registry, so this session holds nothing worth taking. If you set Stryker's thresholds.break, it exits with code 1 when the mutation score drops below it (Stryker docs), and the exec exit code becomes a pass/fail gate for the agent's change.
For the inner loop, where you rerun tests on every edit, a warm sandbox that only syncs what changed is faster; see Run your tests on a warm sandbox before you push.
Vetting an MCP server before you install it
To see what an MCP server does, you have to run it: its tool list and tool descriptions only exist once the process is up. That makes "scan it in CI" the wrong instinct, since the server would start inside a job that holds a GITHUB_TOKEN. Snyk's Agent Scan says as much in its README: scanning can execute commands and make outbound requests, and it recommends a sandbox, a VM or another disposable environment for configs you don't trust.
A minimal check is to start the server in a throwaway session and ask it for its tools with the MCP Inspector CLI:
tenki sandbox create --name mcp-check --max-duration 15m --allow-inbound=false
tenki sandbox exec --session mcp-check --timeout 5m -c '
npx -y @modelcontextprotocol/inspector --cli <server-command> \
--method tools/list --format json > /home/tenki/tools.json
'
tenki sandbox read --session mcp-check --path /home/tenki/tools.json --out ./tools.json
tenki sandbox terminate --session mcp-check
Read the descriptions in tools.json before any agent sees them. They're text the model will treat as instructions, so look for anything that tells the model to read files, call other tools or hide what it's doing. The base image ships Node.js 24, which covers the Inspector's Node requirement.
Letting the agent drive the sandbox
You don't have to wrap every run yourself. The Tenki MCP server gives an agent the same operations as tool calls, including tenki_run_code, which boots a session, runs a script and tears it down in one call. Two things to keep in mind:
- The server holds your API key and can spend credits.
TENKI_MCP_READONLY=1andTENKI_MCP_DISABLED_TOOLSnarrow what the agent can do. - Output from
tenki_run_code,tenki_execandtenki_read_filecomes from untrusted code. The agent should treat it as data, not as instructions.
When a sandbox doesn't help
Isolation does little when the code needs your credentials to do its job. If the task is "deploy this", the secret has to be somewhere, and isolation only limits how far a mistake travels after that.
It also can't contain code that needs the network when you can't narrow what it reaches. With outbound on, a session can reach the internet, so the protection you have left is keeping the session empty of anything worth stealing.
And it doesn't help if you don't clean up. A session that reaches RUNNING after your create call times out keeps running, and billing, until its max duration. Handle that error explicitly (Create and wait), and set --max-duration so a forgotten session ends on its own.
The recipes page has the full set of workflows, including running an agent against a repository, and Sessions covers every create option.


