all posts

Running AI Pentest Agents in Disposable MicroVMs

Ajay Kumar··9 min read

A security-testing agent is the one class of AI agent whose entire job is to do the things every other agent is sandboxed to prevent. You point it at a target, and it runs recon, port scans, vulnerability scanners, and exploit proof-of-concepts — offensive tooling, generated and chained at runtime by a model, against systems you may only half-control. That is a wonderful capability and an operational hazard in the same breath. The failure mode isn't hypothetical or exotic: an autonomous agent that mistypes a CIDR and fires `nmap` at `10.0.0.0/8` — your entire internal RFC1918 space — instead of the `10.0.5.0/24` you scoped is a Tuesday. This post is about giving that agent a real place to work: a hardware-isolated, network-controlled, disposable microVM where the offensive tooling is real, the isolation is real, and the blast radius is one VM you were going to delete anyway.

The problem: the agent literally runs offensive tooling

Most agent-sandboxing arguments are about untrusted input — a coding agent that read a poisoned README, a data agent parsing a hostile file. A pentest agent is different in a way that matters: the danger is the intended behavior, not an accident of prompt injection. It is supposed to run `nmap`, `nuclei`, `sqlmap`, `metasploit`, a `curl` loop probing an endpoint, a Python exploit PoC it just wrote. Even a perfectly-behaved agent, executing exactly the plan you gave it, is running code that scans networks, fuzzes services, and pops shells. Then layer the usual agent reality on top: the model improvises, chains tools it wasn't explicitly told to, and occasionally gets a subnet mask wrong or a target list confused.

So enumerate what goes wrong when this agent runs somewhere thin — a laptop, a shared CI runner, a container on your VPC:

  • It scans the wrong network. A fat-fingered CIDR, a target parsed out of ambiguous scope text, or a helpful `--script` flag turns 'scan the client's DMZ' into 'scan our own internal /8.' Now you're the one with a suspicious traffic spike and an incident channel.
  • It pivots into YOUR infra. If the agent's host can reach your VPC, your database, your cloud metadata endpoint at 169.254.169.254, then an SSRF probe or a wrong target isn't a client finding — it's a self-inflicted breach against the box running the agent.
  • It runs a real exploit that really works. A PoC that pops a shell, drops a payload, or wipes a disk does exactly what it says. On a shared-kernel container, 'the exploit worked' can mean the guest owns the host and every other tenant on it.
  • It leaves a mess. Half-installed tooling, a Metasploit database, a running listener, credentials cached on disk, a `nc` backdoor the agent opened 'to test.' The next task inherits all of it unless the environment is thrown away.
The binding constraint for a pentest agent isn't compute — it's containment plus a controlled network. You are deliberately running scanners and exploits, so the two questions that decide whether this is a tool or a liability are: can this tooling escape the machine, and can this tooling reach a network it wasn't scoped to hit?

Why a shared-kernel container is the wrong place for this

The reflexive move is to drop the agent in a Docker container and feel done. For a coding agent running your own trusted build, that's fine. For an agent whose literal function is to execute exploits, it's the wrong boundary — and not because you tuned it badly, but because of what a container is. A container is Linux namespaces and cgroups wrapped around a process that still shares the host's single kernel with everything else on the box. That shared kernel is a large, complex, privileged attack surface — and a pentest agent is, by design, in the business of finding and firing kernel and container-escape exploits. You are running a tool whose job is to break out of things, inside the one boundary specifically known to be breakable from the inside.

You can harden the container — seccomp, user namespaces, dropped capabilities, a read-only rootfs, gVisor or Kata in front. Do all of it; it's real defense in depth. But it's mitigation stacked on a boundary that was never meant to contain adversarial, escape-seeking code. The right boundary for code you're actively using to attack things is a separate kernel behind a hardware virtualization boundary the CPU enforces (KVM): a microVM. A working exploit inside a Firecracker guest owns a throwaway kernel that can't see the host's kernel at all, and has to defeat the far smaller hypervisor boundary to get anywhere. That's the whole reason AWS runs Lambda on Firecracker rather than trusting containers between tenants — and a security agent is more adversarial than a random Lambda, not less.

You are running tools whose entire purpose is to escape boundaries. Don't put them behind the one boundary that's famous for being escapable — a shared kernel. Give them a kernel of their own to wreck.

The network is the control that actually matters

Isolation stops an exploit from reaching the host. But for a pentest agent the more consequential control is the network: what the scanner and the exploit are allowed to touch. A perfectly isolated VM that can still route to your internal /8 is exactly the accident above waiting to happen. The microVM model gives you the enforcement point for free, because every sandbox gets its own Linux network namespace with its own TAP device and its own NAT rules — PandaStack pre-allocates 16,384 /30 subnets per agent, one per sandbox, so the agent's network is a genuinely separate segment, not a shared bridge you're praying about. That per-VM network is where you set the posture, and for offensive tooling the posture should be strict by default:

  • Default-deny, then allowlist the scope: the agent should reach only the explicit target IPs/CIDRs and ports you scoped for this engagement — nothing else. If the model fat-fingers `10.0.0.0/8`, the packets to everything outside scope simply don't leave the namespace.
  • Block your own internal plane hard: deny RFC1918 (10/8, 172.16/12, 192.168/16) and link-local 169.254.0.0/16 (which covers the cloud metadata endpoint at 169.254.169.254) unless a range is explicitly in-scope. A scanner should never be able to reach your database, control plane, or IAM credentials by accident.
  • Allowlist tool sources separately from targets: the agent needs egress to fetch `nuclei` templates, `pip install` a library, or clone an exploit repo. Scope that to package registries and known template hosts, and keep it distinct from the target allowlist so 'install tooling' can't become 'exfiltrate to the internet.'
  • Rate-limit and cap: bound packets-per-second and total egress so a runaway scan can't turn into a DoS on the client (or your bill), and so an exfil attempt out of a compromised tool is throttled, not a firehose.
  • Log the flows: because the whole network is one /30 segment, you get a clean per-engagement record of exactly what the agent talked to — which is also your evidence that the scan stayed in scope.

Here's the shape of that policy as an iptables sketch inside the sandbox's namespace — default-deny egress, punch a hole for the scoped target and for the tooling registries, and drop everything internal. In production you'd render this from the engagement's scope file rather than hand-writing it, but the intent is the point:

#!/usr/bin/env bash
# Egress posture for a pentest sandbox: default-deny, allowlist scope.
# Runs inside the sandbox's own network namespace (its own segment).
set -euo pipefail

# 1. Default: drop all outbound. Nothing leaves unless we say so.
iptables -P OUTPUT DROP
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# 2. Block our own internal plane + cloud metadata FIRST, always.
#    Even if a target range overlaps, the scanner can't hit these.
for CIDR in 169.254.0.0/16 127.0.0.0/8; do
  iptables -A OUTPUT -d "$CIDR" -j DROP
done

# 3. Allowlist the SCOPED target only (from the engagement scope file).
SCOPE_CIDR="203.0.113.0/24"   # the client's in-scope range, NOT 10.0.0.0/8
iptables -A OUTPUT -d "$SCOPE_CIDR" -j ACCEPT

# 4. Allowlist tooling sources (nuclei templates, pip, exploit repos)
#    to a small set of registries, kept separate from targets.
for HOST in pypi.org files.pythonhosted.org github.com raw.githubusercontent.com; do
  iptables -A OUTPUT -p tcp -d "$HOST" --dport 443 -j ACCEPT
done

# Everything else: dropped by policy. A mistyped CIDR goes nowhere.

The pattern: scope in, microVM, run the tooling, capture, destroy

The control loop for a pentest agent is the familiar disposable-sandbox loop with the network posture baked in. Your orchestrator takes an engagement (a scope, a target list, an objective) and does five things:

  1. Provision an isolated sandbox for this engagement (or this run), on a template pre-loaded with the offensive toolkit — scanners, recon utilities, a runtime for PoCs.
  2. Apply the network posture: default-deny egress, allowlist only the scoped targets and the tooling registries, block your internal plane. This is the guardrail against the fat-fingered scan.
  3. Deliver the plan: write the agent's task, the scope file, and any target list into the guest as plain files. The model reads them there and decides which tool to run.
  4. Exec the tooling with hard timeouts, capturing exit code, stdout, and stderr for every command so you can tell a clean scan from a crash from a hang — and so the agent can read results and pick its next move.
  5. Capture findings and destroy: pull the scan output and PoC results out of the VM, then delete it. The listener, the Metasploit db, the cached creds, the half-exploited state — all gone with the VM.

Because the VM is disposable, you never scrub between runs. If an exploit drops a payload, opens a backdoor, or the agent leaves `nc -lvp 4444` running, it does so inside a VM that's about to be deleted. The next engagement starts from a clean, known-good baked snapshot with none of the previous run's contamination.

A scoped recon run in Python

Here's a single guarded run with the Python SDK: create a hardware-isolated sandbox for the engagement, deliver the scope, run the scanner against only the in-scope target with a hard timeout, read the findings back out, and tear it down. Set PANDASTACK_API_KEY in the environment first. The `metadata` tag attributes the VM to an engagement for your audit trail, and `ttl_seconds` is a backstop so a scan that hangs kills its own VM even if your code forgets to.

import json
from pandastack import PandaStack

ps = PandaStack()  # reads PANDASTACK_API_KEY, base url https://api.pandastack.ai

def recon_run(engagement_id: str, target_cidr: str) -> dict:
    # 1. Fresh, hardware-isolated microVM for THIS engagement. Every create
    #    restores a baked snapshot on demand (~179ms p50), so a VM-per-run is
    #    cheap. A working exploit here owns a throwaway kernel, not the host.
    sb = ps.sandboxes.create(
        template="base",
        ttl_seconds=900,                       # hard cap: VM self-destructs after 15m
        metadata={"engagement": engagement_id, "kind": "pentest-recon"},
    )
    try:
        # 2. Deliver the scope as a plain file so the run is auditable and the
        #    target is explicit — no ambiguity for the model to fat-finger.
        scope = {"target": target_cidr, "in_scope_only": True}
        sb.filesystem.write("/scope.json", json.dumps(scope))

        # 3. Run the scanner against ONLY the scoped target, with a hard
        #    timeout. Egress policy (default-deny + allowlist) already means
        #    packets to anything outside scope never leave the namespace.
        result = sb.exec(
            f"nmap -sV -oX /out.xml {target_cidr}",
            timeout_seconds=600,
        )

        # 4. Capture findings. This is the ONLY thing that escapes the VM —
        #    the scan output. Everything else dies with the sandbox.
        if result.exit_code != 0:
            return {"ok": False, "stderr": result.stderr[-2000:]}
        findings = sb.filesystem.read("/out.xml")
        return {"ok": True, "report": findings}

    finally:
        # 5. Destroy. No listener, no cached creds, no PoC state survives.
        sb.delete()

The two safety rails are load-bearing, not decoration. The `timeout_seconds` on `exec` is a circuit breaker for a scanner that hangs on an unresponsive host or an agent that decides to loop; the `ttl_seconds` on create is a backstop so a VM you forget to reap reaps itself. The offensive tool gets a wall, a controlled network, and a clock — then it gets thrown away. If you want to watch a long scan stream live instead of blocking, the SDK exposes a streaming exec that emits stdout, stderr, and exit events.

Snapshot and fork: branching attack paths from one warm state

The interesting part of an autonomous pentest is rarely a single command — it's the tree of choices after initial access. The agent gets a foothold, then wants to try three different privilege-escalation paths, or the same exploit against a dozen hosts, or a risky payload it might want to walk back. This is exactly what copy-on-write forks are for. You drive the agent to a configured, post-foothold state, snapshot it, and then fork that snapshot N times to branch the attack tree — each branch a fully-isolated VM that starts from the identical warm state but can diverge independently. If one branch's PoC destroys the guest, you don't care; the parent snapshot is untouched and the other branches march on.

The economics make this practical rather than theoretical. A same-host fork lands in roughly 400–750ms and shares the parent's memory copy-on-write (a cross-host fork is 1.2–3.5s), so fanning out ten exploit branches from one warm foothold is a sub-second-per-branch operation, not ten cold boots. Memory is MAP_PRIVATE copy-on-write and the rootfs is a reflink clone, so the tenth branch doesn't copy gigabytes — it shares the baked pages until it writes. Branch, try, discard the losers, keep the winner. It's the same best-of-N pattern coding agents use for fixes, pointed at attack paths instead.

Forking a post-foothold snapshot gives you a tree-of-thought for offense: explore multiple exploit or escalation paths in parallel from one warm state, each in its own throwaway kernel, and only the branch you keep ever leaves a mark. A destructive payload on a dead branch costs you a fork you were going to delete.

Ephemeral-per-run vs. persistent-per-engagement vs. shared container

There are three shapes for where a pentest agent's tooling runs, and the right one depends on the engagement's shape and your appetite for cleanup. Laid side by side:

  • Isolation strength — Ephemeral-per-run: strongest; a clean guest kernel per run, nothing survives an exploit. Persistent-per-engagement: strong; a real kernel boundary, but foothold state and tooling persist across the engagement. Shared container: weakest; a shared host kernel is the only wall — the exact boundary offensive tooling is built to break.
  • Network control — Ephemeral-per-run: a fresh per-VM namespace and egress policy per run, hardest to leak scope across. Persistent-per-engagement: one scoped namespace for the engagement's life, still fully isolated from your plane. Shared container: shares the host's network reality, so a mistyped CIDR can hit whatever the host can route to — the fat-finger disaster.
  • State between runs — Ephemeral-per-run: none by construction; every run is a clean slate, no leftover backdoors or cached creds. Persistent-per-engagement: preserved (a foothold, a loaded Metasploit db, warm tooling) — a feature mid-engagement, a liability afterward. Shared container: bleeds across runs and tenants unless you're meticulous, which is the failure mode.
  • Cleanup burden — Ephemeral-per-run: zero; delete the VM and the mess is gone. Persistent-per-engagement: you must scrub or snapshot-reset deliberately. Shared container: manual, error-prone, and one missed listener from a lingering backdoor.
  • Best fit — Ephemeral-per-run: parallel scans, best-of-N exploit branches, one-shot recon, and the highest-blast-radius payloads. Persistent-per-engagement: a multi-stage engagement that needs to hold a foothold and pivot over time. Shared container: honestly, never — not for tooling whose job is to escape.

In practice, run ephemeral-per-run as the default (maximal isolation, zero cleanup, fork to branch), and promote to a persistent-per-engagement VM only for the multi-stage work that genuinely needs to hold state — a foothold you're pivoting from over hours. Even then, snapshot that persistent VM so you can fork-and-explore off it and roll back a branch that went sideways. The shared container tier does not appear in this plan, because there is no version of 'run exploits behind a shared kernel' that ends well.

When you don't need a microVM per run

This is real infrastructure with real edges — you own the scope-to-egress rendering, snapshot hygiene, and the plumbing to get findings out. Be honest about whether your threat model calls for it.

  • You're doing passive-only recon. If the agent only reads public OSINT, WHOIS, and certificate transparency — no packets to a target, no exploits — the containment argument softens; the risk is mostly your own egress hygiene.
  • You already have a hardened, network-segmented pentest rig. If your engagements run on a physically or VLAN-isolated jump host with strict egress and per-engagement wipe, you have most of this; the microVM mainly buys you speed, disposability, and parallel forks.
  • The tooling is fully declarative and sandboxed by the vendor. If you're calling a managed scanning API that already isolates and scopes on its side, you don't also need to host the tool — though you lose the byte-level control of the runtime and the network plane.

Reach for per-run microVMs when the agent actively runs scanners and exploits, when a mistyped scope could hit your own network, when a working PoC could compromise the host, or when you want to fork attack paths in parallel. In those cases the microVM gives you what a container can't — a real kernel boundary and a real per-VM network per run — at snapshot-restore speed (a ~179ms p50 create, a ~3s cold boot that happens once per template and is then amortized away). An AI running `nmap` against your internal /8 by accident should cost you one deleted VM and a dry log line, not an incident review. Match the tool to the threat model: a security agent is offensive tooling with a model's judgment attached, and you should give it a room it can't burn down.

Frequently asked questions

Why can't I just run my AI pentest agent in a Docker container?

Because a container shares the host's single Linux kernel, and a pentest agent's entire job is to run tooling that finds and fires kernel and container-escape exploits — you'd be putting escape-seeking code behind the one boundary famous for being escapable from the inside. Hardening (seccomp, user namespaces, dropped capabilities, gVisor) helps but doesn't remove the sharing. For code you're actively using to attack things, use a microVM: a separate guest kernel behind a hardware virtualization boundary (KVM), so even a working exploit owns only a throwaway kernel that can't see the host. That's why AWS runs Lambda on Firecracker rather than trusting containers between tenants.

How do I stop the agent from scanning my internal network by accident?

Make the network a property of the environment, not a hope about the command. Each PandaStack sandbox gets its own Linux network namespace out of a pool of 16,384 pre-allocated /30 subnets, so you enforce egress there: default-deny outbound, allowlist only the scoped target IPs/CIDRs and ports, and block your internal RFC1918 ranges (10/8, 172.16/12, 192.168/16) plus link-local 169.254.0.0/16 (the cloud metadata endpoint). If the model fat-fingers a CIDR like 10.0.0.0/8, the out-of-scope packets never leave the namespace — a scoping mistake becomes a dropped packet instead of an incident.

Can I run real exploits and destructive payloads safely in one of these sandboxes?

Yes — that's the point of a disposable, hardware-isolated VM. A PoC that pops a shell, drops a payload, or wipes the disk does so inside a Firecracker microVM with its own guest kernel, no host credentials, and a scoped network. The worst case is a dead throwaway VM. Because you snapshot and fork, you can even branch multiple exploit or privilege-escalation paths from one post-foothold state (a same-host fork is ~400–750ms, copy-on-write), try them in parallel, and discard the destructive branches — only the branch you keep ever leaves a mark, and the parent snapshot is untouched.

Isn't spinning up a fresh VM per scan or per engagement too slow?

No, because it's a snapshot restore, not a cold boot. PandaStack creates a sandbox in about 179ms at p50 (203ms p99) by restoring a baked snapshot on demand, with memory copy-on-write and a reflink-cloned rootfs, so the Nth toolkit VM doesn't copy gigabytes. The genuine ~3-second cold boot happens once per template and is amortized away. To branch attack paths from a warm foothold, a same-host fork lands in ~400–750ms (cross-host 1.2–3.5s). At those numbers a fresh, isolated, network-scoped VM per run is the cheap default, not a luxury — and ephemeral teardown means zero cleanup between engagements.

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.