all posts

Give Your AI Agent a Terminal — Not Yours

Ajay Kumar··8 min read

Sooner or later your AI agent needs a shell. Not a curated set of function-call tools — an actual terminal, where it can run `bash`, `npm install`, `apt-get`, `git clone`, edit files, and poke the network, because that's what "do this task in a real environment" actually requires. The moment you give a model that, the question stops being "how do I parse its output" and becomes "where does this run." If the answer is `subprocess.run` on your host, you've handed a non-deterministic language model a root-ish shell on your production box. This post is about the other answer: a per-agent Firecracker microVM, where the blast radius of anything the model types is one throwaway VM.

I'm Ajay — I built PandaStack, a Firecracker microVM sandbox platform. I'll show the SDK calls, but the important part is the trust model: why a subprocess is a loaded gun, why a container isn't enough for a shell that installs packages, and how you actually wire one disposable VM per agent so you can watch it type `sudo rm -rf /` and not flinch.

Why a host subprocess is a loaded gun

A PTY or `subprocess` on your host runs the agent's commands as your process, with your filesystem, your network, your credentials, and your uptime. Everything the model generates is non-deterministic — you can't review it before it runs, and a prompt injection three tool-calls deep can turn "set up the project" into something you didn't sanction. The classic failure modes aren't exotic:

  • A model-generated `rm -rf ~/project` that resolves a path wrong and eats the parent directory — or `rm -rf /` verbatim, which people genuinely fat-finger into agents.
  • The `curl example.com/install.sh | sh` pattern the model copied from a README — you now execute a remote script you never saw.
  • A fork bomb or a runaway `while true` that pins every core, or a 40GB allocation that OOM-kills your host along with the agent.
  • `env`, `cat ~/.aws/credentials`, or reading any secret that happens to be reachable from your process — then a `curl` to exfiltrate it.
None of these require a malicious model — a confused one is enough. "Helpful" and "has a shell on your host" is all it takes for an ordinary hallucinated command to become an incident.

A container is a polite suggestion to the kernel

The obvious next step is "run it in a container." That's better than a raw subprocess, and for a lot of internal tooling it's fine. But a container is namespaces and cgroups over one shared host kernel — it's a polite suggestion to the kernel, not a wall. For a shell that installs packages and pokes the network, that boundary is exactly where you're most exposed: container escapes are a known, recurring class of bug, and the whole Linux syscall surface is reachable from inside. When the agent does `apt-get install` something, runs it as root inside the container, and that binary reaches for a kernel vulnerability, the thing on the other side of the wall is your host and every other tenant sharing that kernel.

It's not that containers are useless — it's that a shared kernel is not a security boundary you want to bet a stranger's model output against. That's the same reason AWS Lambda and Fargate don't run untrusted multi-tenant code in bare containers; they run it in Firecracker microVMs.

A microVM: a full, disposable shell

A Firecracker microVM is a different category. Each one boots its own guest kernel under hardware virtualization (KVM), with its own real PTY, its own real filesystem, and its own network namespace. The agent gets a genuine terminal — everything a shell expects works, including `sudo`, package managers, and long-running processes — but it's a terminal into a VM that has nothing of yours in it. An escape would have to break out of the hypervisor itself, a far smaller and more heavily audited surface than the full syscall interface a container exposes.

Historically the objection to VMs was boot time: seconds per environment is a non-starter for per-agent or per-request use. PandaStack removes that by restoring a baked snapshot on every create instead of cold-booting — create is p50 179ms (p99 ~203ms; ~49ms for the restore step itself), against ~3s only for the very first cold boot of a template. So VM-grade isolation costs you milliseconds, and the mental shift is real: when the agent types `sudo rm -rf /`, you shrug, because the only thing it can destroy is a disposable VM you were going to throw away anyway.

  • Host subprocess — the agent's shell is your shell: your files, your network, your credentials, your uptime. One bad command is one bad day.
  • Container — better, but a shared host kernel; a package-installing, network-poking root shell is exactly the workload that surfaces escape bugs. Blast radius: the host.
  • microVM — own guest kernel, own PTY, own filesystem, own network namespace. Blast radius: one throwaway VM. Isolation is hardware-enforced, and cleanup is a delete.

The core loop: create, exec, stream

The pattern is: create a sandbox on the `agent` template, run each command the model emits with `exec`, and hand `stdout`, `stderr`, and `exit_code` straight back so the model can self-correct. Set `PANDASTACK_API_KEY` in your environment and the SDK picks it up. Always pass `timeout_seconds` on `exec` (your circuit breaker for a runaway command) and `ttl_seconds` on create (your backstop for a VM you forget to kill).

from pandastack import Sandbox

# One disposable VM for one agent session. The context manager kills it on exit.
with Sandbox.create(template="agent", ttl_seconds=1800) as sbx:
    # Whatever your agent decides to run this turn.
    commands = [
        "git clone --depth 1 https://github.com/some/repo /workspace/repo",
        "cd /workspace/repo && npm install",
        "cd /workspace/repo && npm run build",
    ]

    for cmd in commands:
        result = sbx.exec(cmd, timeout_seconds=120)
        # Feed the raw terminal output back to the model so it can react.
        print(f"$ {cmd}")
        print(result.stdout)
        if result.exit_code != 0:
            print("stderr:", result.stderr)
            # Hand exit_code + stderr to the model; let it decide the next step.
            break
# VM (and everything the agent did to it) is destroyed here.

`exec` returns an `ExecResult` with `stdout`, `stderr`, `exit_code`, and `duration_ms`. That's the whole feedback loop an agent needs: it runs a command, sees what a real terminal said, and picks the next one. When the model wants to write a file rather than shell it in — a config, a patch, a script — use `sbx.filesystem.write(path, content)` instead of fighting shell quoting.

from pandastack import Sandbox

with Sandbox.create(template="agent", ttl_seconds=600) as sbx:
    # The agent decided to write a script rather than pipe a heredoc through the shell.
    sbx.filesystem.write("/workspace/setup.sh", (
        "#!/usr/bin/env bash\n"
        "set -euo pipefail\n"
        "echo 'provisioning...'\n"
        "apt-get update -qq && apt-get install -y jq\n"
        "jq --version\n"
    ))
    r = sbx.exec("bash /workspace/setup.sh", timeout_seconds=180)
    print(r.stdout, r.stderr, sep="\n")
    # If a command genuinely wedges, don't wait for the TTL — kill it now.
    if r.exit_code != 0:
        sbx.kill()

For an interactive terminal — a human watching the agent work, or an xterm.js panel in your UI — the platform also exposes a streaming PTY over WebSocket (`/v1/sandboxes/{id}/exec/pty`), so you get a live, keystroke-level session into the same VM. `exec` is the right primitive for the autonomous loop; the PTY stream is for when a person wants to look over the agent's shoulder.

One VM per session vs. a fresh VM per command

How aggressively you recycle VMs is a trust dial. There are two sensible settings, and the right one depends on where your agent's instructions come from.

One VM per agent session

Give each agent session its own long-lived sandbox and run every command in that turn's plan against it. State accumulates naturally — a repo cloned in step one is still there in step five, installed packages persist, the working directory is stable. This is the right shape for a coding agent that iterates on a project, and it's safe as long as one VM only ever holds one trust domain. Never let two different end users' agents share a VM; the isolation boundary is the VM, so sharing one defeats it.

A fresh VM per command

For a stateless "run this one thing and tell me what happened" tool, or any time each command might come from a different tenant, create a new sandbox per command and destroy it after. Nothing leaks between runs, which is the correct default for fully untrusted or multi-tenant input. The 179ms create makes this practical at per-command granularity. If you want every command to start from a known-good, already-provisioned state, snapshot a configured sandbox once and fork it — a same-host fork is 400–750ms (cross-host 1.2–3.5s) and shares memory copy-on-write, so you pay setup once and get a clean disposable shell each time.

A useful default: one VM per session for a single-user coding agent; fresh-per-command (or fork-per-command) for a multi-tenant service where each request's provenance differs. When in doubt, recycle more aggressively — a wasted 179ms create is cheaper than leaked state.

Egress control and reaping

Isolation contains what a command can wreck; it doesn't by itself stop what a command can send. If your threat model includes exfiltration — the agent reading something it shouldn't and `curl`-ing it out — restrict outbound network at the network layer rather than trusting the model not to. Each sandbox runs in its own network namespace (PandaStack pre-allocates 16,384 /30 subnets per agent host), which is the right seam to attach egress policy: allowlist the registries and APIs the task legitimately needs and drop the rest. And don't put secrets the running code shouldn't see into the guest environment in the first place — a sandbox isolates execution, not the credentials you hand it.

Reaping is the other half. VMs cost memory while they run, so every one needs a way to die: the `with Sandbox.create(...) as sbx:` form kills non-persistent sandboxes on block exit, `ttl_seconds` on create reaps anything you forget, and `sbx.kill()` tears one down immediately when a command wedges or a session ends. Set the TTL even when you plan to kill explicitly — it's the backstop for the process that crashed before it got to `kill()`.

Put together, the model is simple: give the agent a real terminal, but make that terminal a disposable Firecracker microVM. It gets a genuine shell — PTY, filesystem, network — and you get to watch it do its worst to a VM that isn't yours, then delete it. That's the whole point of moving the shell off your host: the agent's blast radius becomes a throwaway.

Frequently asked questions

Why not just run an AI agent's shell commands in a subprocess on my host?

Because a subprocess runs the model's commands as your process — your files, network, credentials, and uptime are all in scope. Model-generated commands are non-deterministic and can't be reviewed before they run, so an ordinary hallucinated rm -rf, a curl|sh, or a fork bomb becomes a host incident. Running the shell inside a per-agent Firecracker microVM confines that blast radius to one disposable VM instead.

Isn't a container enough to isolate an agent's terminal?

For trusted internal tooling, often yes. But a container shares the host kernel, and a shell that installs packages, runs as root, and pokes the network is exactly the workload that surfaces container-escape bugs. For untrusted or multi-tenant agent shells, a Firecracker microVM boots its own guest kernel under hardware virtualization, so an escape has to break the hypervisor rather than the syscall interface — a much smaller surface. It's why Lambda and Fargate use microVMs, not bare containers.

How do I run shell commands in a PandaStack sandbox?

Create a sandbox on the agent template with Sandbox.create(template="agent", ttl_seconds=...), then call sbx.exec("your command", timeout_seconds=...), which returns an ExecResult with stdout, stderr, and exit_code you can feed back to the model. Use sbx.filesystem.write(path, content) to drop in files or scripts, and sbx.kill() (or the context manager) to reap the VM. For an interactive session, stream the PTY over the exec/pty WebSocket.

Should I use one VM per agent session or a fresh VM per command?

One long-lived VM per session lets state accumulate — cloned repos and installed packages persist across the turn — and is right for a single-user coding agent. A fresh sandbox per command (or a fork per command) leaks nothing between runs and is the safe default for multi-tenant or fully untrusted input, since creates are p50 179ms and a same-host fork is 400–750ms. The rule: never share one VM across two different trust domains.

Does the microVM stop the agent from exfiltrating data?

Isolation contains what a command can destroy, not by itself what it can send. If exfiltration is in your threat model, restrict outbound traffic at the network layer — each sandbox runs in its own network namespace, so you can allowlist only the registries and APIs the task needs and drop the rest. Also avoid putting secrets the running code shouldn't see into the guest environment, since a sandbox isolates execution, not the credentials you hand it.

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.