all posts

AI Contract Review Agents: Isolating Privileged Documents in MicroVMs

Ajay Kumar··9 min read

Somewhere in the last two years, "upload the contract to an LLM and ask it questions" turned into "build an agent that reviews every incoming NDA, redlines it against the playbook, extracts obligations into a tracker, and flags anything a partner needs to see before the other side's five o'clock deadline." That second thing is a real system with a real tool-execution loop, and the documents flowing through it are the kind of material that ends up in the first paragraph of a bar complaint if it goes to the wrong place: merger agreements before a public announcement, litigation-hold material, NDAs naming counterparties who very much do not want to be named to each other, and scanned exhibits that someone's paralegal faxed in from 2003.

I'm Ajay — I build PandaStack, an open-source Firecracker microVM platform, so I have an obvious interest in where you draw the isolation boundary around code that touches this kind of data. This post is about the part of legal-AI architecture that doesn't show up in the demo: why extracting clauses, running OCR on scanned exhibits, and diffing a contract against a playbook template means your agent is executing code, not just prompting a model; why running that code in a shared container fleet is a real leakage risk between clients even without anyone doing anything wrong; and how per-matter microVM isolation, default-deny egress, and short-lived sandboxes turn "we promise your data didn't mix with another client's" from a policy statement into something you can actually verify.

"Just call the model with the PDF" is the easy 20% of the job

The pitch-deck version of AI contract review is a single API call: send the PDF, get back a summary. The production version has to do things a model call alone cannot, because a modern LLM API is stateless, doesn't reliably return byte-accurate positions inside a document, and can't diff two documents against each other with the kind of precision a redline needs. So the pipeline behind the demo is doing real, deterministic, imperative work — parsing PDF layout to preserve clause structure, falling back to OCR when the PDF is a scanned image with no text layer, running a structural diff between the incoming contract and a playbook template, and cross-referencing extracted parties and defined terms against the firm's document management system. None of that is a model call. It's code, executed against a specific client's document, usually inside whatever container the agent's tool-execution loop happens to be running in at the time.

The tool loop runs code against a document you did not write

This is the detail that gets missed when a legal-tech team reasons about their system as "an LLM with some RAG on top." The agent's tool calls are, at the bottom, shelling out to pdfplumber or PyPDF2, invoking an OCR binary, running `diff`-family tooling against a playbook clause library, or executing a small Python script the agent itself generated to reconcile two exhibit lists. That is arbitrary code execution against attacker-adjacent input — not because opposing counsel is hostile (usually), but because a malformed PDF, a pathological OCR input, or a bug in a hastily-written extraction script behaves exactly like untrusted input regardless of anyone's intent. And it's running against a specific matter's confidential material, which changes what "blast radius" means if something goes wrong.

# Representative slice of what an agent tool call actually executes when it
# "reads" a contract. This is the part that never makes it into the demo.
import re
import difflib
import pdfplumber

PLAYBOOK_CLAUSES = {
    "limitation_of_liability": "In no event shall either party's aggregate "
        "liability exceed the fees paid in the twelve (12) months preceding "
        "the claim.",
    "governing_law": "This Agreement shall be governed by the laws of the "
        "State of Delaware, without regard to conflict-of-laws principles.",
    "confidentiality_term": "The obligations of confidentiality shall survive "
        "termination of this Agreement for a period of five (5) years.",
}

def extract_text(pdf_path: str) -> str:
    """Text-layer extraction, with an OCR fallback for scanned exhibits.
    The fallback path is where most of the risk lives: it means an image
    decoder and an OCR engine are chewing on bytes from a document nobody
    on the eng team has read."""
    with pdfplumber.open(pdf_path) as pdf:
        text = "\n".join(page.extract_text() or "" for page in pdf.pages)
    if len(text.strip()) < 200:
        text = run_ocr_fallback(pdf_path)  # shells out to an OCR binary
    return text

def find_clause(text: str, heading_pattern: str) -> str | None:
    match = re.search(
        rf"{heading_pattern}\.?\s*(.+?)(?=\n\s*\d+\.\s|\Z)",
        text, re.IGNORECASE | re.DOTALL,
    )
    return match.group(1).strip() if match else None

def diff_against_playbook(clause_key: str, found_text: str | None) -> dict:
    """Structural deviation check. This is the step that actually earns the
    fee reduction the sales deck promises -- and the step most teams bolt
    on as a raw string diff running wherever the agent process happens to
    live, with the counterparty's draft sitting in a temp file next to it."""
    if found_text is None:
        return {"clause": clause_key, "status": "MISSING", "deviation": None}

    baseline = PLAYBOOK_CLAUSES[clause_key]
    ratio = difflib.SequenceMatcher(None, baseline, found_text).ratio()
    return {
        "clause": clause_key,
        "status": "MATCH" if ratio > 0.85 else "DEVIATION",
        "similarity": round(ratio, 3),
        "redline": list(difflib.unified_diff(
            baseline.split(),
            found_text.split(),
            lineterm="",
        )),
    }

def review_contract(pdf_path: str) -> list[dict]:
    text = extract_text(pdf_path)
    return [
        diff_against_playbook(key, find_clause(text, key.replace("_", " ")))
        for key in PLAYBOOK_CLAUSES
    ]

Read that for what it is: a PDF parser, an OCR fallback, a regex-based clause extractor, and a diff engine, all running against one client's confidential draft. Every one of those libraries has a CVE history. None of them were written with "parse a hostile or malformed document" as a design goal, because most of the time nobody hands a PDF library a hostile document — until the one time a scanned exhibit or a PDF exported from a weird DMS trips something nobody tested.

A shared container fleet is a cross-tenant leakage risk, not a hypothetical one

Multi-tenant legal-tech vendors process contracts for dozens or hundreds of different law firms, and those firms represent counterparties who are, not infrequently, on opposite sides of the same deal. The isolation requirement here isn't abstract data-security hygiene — it's the literal thing attorney-client privilege and conflicts-of-interest rules exist to protect. If your document-processing worker pool is a shared fleet of containers picking jobs off a queue, you are relying on every temp file, every cache, and every process boundary being cleaned up correctly, every single time, forever, across every framework and library your extraction pipeline depends on. That's a lot of "every."

Where the residue actually accumulates

  • Temp files from PDF and OCR tooling — pdfplumber, poppler-utils, and most OCR engines write intermediate files (rendered page images, hOCR output, extracted text layers) to a shared /tmp unless you go out of your way to sandbox it, and "clean up on completion" doesn't run when the worker crashes mid-job on client B's malformed exhibit.
  • Shared package/dependency caches — a pip or npm cache shared across worker processes is usually benign, but a worker that generates and executes a small script per document (which agentic pipelines increasingly do) can leave that script, and any data it captured via a stack trace or a debug print, sitting in a shared cache or working directory the next job reuses.
  • In-process memory reuse — long-lived worker processes that handle many documents in sequence keep decoded PDF buffers, extracted text, and diff results in memory longer than the request that produced them; a memory-disclosure bug, a verbose crash handler, or a debugging endpoint left on in staging turns that into cross-tenant exposure.
  • Filesystem cross-contamination between jobs on the same host — two documents processed back-to-back on the same container, under time pressure, with a bug in a cleanup routine, is how one firm's exhibit ends up readable from a working directory a different firm's job opens next.
  • Application logs and APM traces — a stack trace that includes a file path, a snippet of extracted text, or a request payload for "debugging" is a disclosure the moment it's shipped to a third-party logging vendor your firm's engagement letter never contemplated.
None of this requires a bug in your business logic. It requires a bug in someone else's PDF library, OCR engine, or crash reporter — software you depend on but did not write, running against a document you are contractually and ethically obligated to keep confidential. "Our extraction code has no leakage bugs" is a claim about maybe 5% of the code that actually touches the document.

And the fun part: an LLM confidently inventing a limitation-of-liability clause that isn't in the document is a bad output you catch on review. A shared worker leaking client A's merger term sheet into the temp directory client B's job reads from ten seconds later is a bad output nobody catches on review, because it never shows up in the output at all. It shows up months later, in discovery, in a very different kind of meeting.

A fresh, snapshot-restored microVM per document (or per matter)

The fix is the same one that applies to any workload where "run someone's code against someone's sensitive data" is the actual job: give each unit of work — one document, or one matter if you're batching a data room — its own hardware-isolated guest, and throw the guest away when the job finishes. This is a direct application of Firecracker's snapshot-restore model. A template guest is baked once with pdfplumber, your OCR toolchain, the diff engine, and the playbook clause library preloaded and warm. Every document-review job restores a fresh copy of that exact machine — same dependency versions, same warm interpreter, no memory of any job that ran before it — does its work, and is deleted. On PandaStack, that restore is the default creation path: p50 179ms, p99 around 203ms, with the Firecracker restore step itself around 49ms. The one-time cold boot that bakes the template is roughly 3 seconds, paid once, not per document.

The property that actually matters for privilege is not the speed, it's what the speed buys you: at that cost, "one microVM per document" is cheaper than the alternative of trying to prove a shared worker cleaned up correctly. The guest that processed client A's NDA doesn't get reused for client B's — it's gone. Its memory, its /tmp, its OCR intermediate files, its diff engine's working state, all of it stopped existing when the sandbox was killed. There's no cleanup routine to audit, because there's no state that survives long enough to need cleaning.

from pandastack import Sandbox
import json

def review_document_isolated(matter_id: str, doc_id: str, pdf_bytes: bytes) -> dict:
    """One document, one throwaway machine. The guest never holds a DMS
    credential, another matter's data, or a route to the public internet --
    it gets one PDF, does the extraction/OCR/diff work, and reports back.
    """
    sbx = Sandbox.create(
        template="legal-doc-review",   # baked with pdfplumber, OCR, playbook
        ttl_seconds=300,                # hard upper bound: no lingering copy
        metadata={
            # Matter and document IDs only. Never a client name, a party
            # name, or clause text -- metadata is queryable and shows up in
            # list APIs and operational logs, which is the last place you
            # want privileged text to leak.
            "matter": matter_id,
            "doc": doc_id,
            "class": "privileged",
        },
    )
    try:
        # Verify the egress denial from inside the guest before it ever
        # touches the document -- fail closed if this unexpectedly succeeds.
        probe = sbx.exec("curl -s --max-time 3 -o /dev/null -w '%{http_code}' https://example.com", timeout_seconds=5)
        if probe.exit_code == 0 and probe.stdout.strip() not in ("", "000"):
            raise RuntimeError(f"egress reachable from doc-review guest {sbx.id} -- refusing")

        sbx.filesystem.write("/work/in.pdf", pdf_bytes)
        result = sbx.exec("python3 /opt/review/review_contract.py /work/in.pdf", timeout_seconds=180)
        if result.exit_code != 0:
            raise RuntimeError(f"review failed: {result.stderr[-2000:]}")

        findings = json.loads(sbx.filesystem.read("/work/out/findings.json"))
        return {"doc_id": doc_id, "findings": findings, "guest_id": sbx.id}
    finally:
        # The document, the OCR intermediates, the diff state, and the
        # machine itself all stop existing at the same moment. No queue for
        # "cleanup worker," no cron job that has to run correctly forever.
        sbx.kill()

Network egress: deny by default, allowlist only the matter's DMS

The document-processing guest doesn't need the internet. It needs to read a file, run local extraction and diffing code, and either return a result or write it to one specific, pre-authorized location — the document management system or storage bucket for that matter. Everything else is attack surface with no business justification. An agent processing a confidential merger draft should not be able to phone home to a package registry, a telemetry endpoint, a webhook, or anywhere else, because if the extraction or OCR code has been tricked into making an outbound request (a malicious PDF with an embedded URL, a compromised dependency, an agent-generated script that does something the prompt didn't ask for), default-deny egress is what stands between that and an actual exfiltration event.

This is easiest to enforce correctly when the network boundary is per job rather than per fleet. Each sandbox gets its own dedicated network namespace — on PandaStack that's drawn from a pool of 16,384 pre-allocated /30 subnets per agent host — so the deny rule is scoped to that one guest's lifetime instead of being a shared firewall policy that some other workload also depends on and that someone eventually loosens "just for this one integration." The specific allowance a matter needs — read access to its folder in the DMS, write access to publish the redline back — should be an explicit, named exception you had to think about, not a default the whole fleet inherited.

Ephemeral by default, and an audit trail that doesn't require grepping logs for privileged text

Two more properties fall out of the same architecture almost for free. First, a hard TTL on the guest — kill it within minutes regardless of what happened — means there is never a lingering copy of privileged material sitting in a warm worker "in case the next job needs the same document." It doesn't. Every job gets its own fresh restore. Second, because the unit of work is one document per guest, your audit trail can name a machine and a job id instead of requiring anyone to search an interleaved worker log for a client name or a matter number — which is itself a small disclosure every time someone runs that search.

That matters for two audiences a law firm's ops team actually has to answer to. For e-discovery, "which system touched this document, when, and what did it do" needs to be a clean answer, not an archaeology project through shared worker logs where three other clients' documents were being processed in the same time window. For malpractice-insurance purposes, being able to show that document processing happens in an isolated, ephemeral, network-locked-down environment — with a per-job record of exactly what ran — is the difference between "we have a reasonable technical control and here's the log entry" and "we believe our cleanup code works." Insurers and opposing counsel in a malpractice claim both prefer the first sentence.

One snapshot-specific detail worth building into the pipeline early: never snapshot a guest after it has touched a privileged document. Snapshot the clean, dependency-loaded template — before any client data enters it — and restore that for every job. A snapshot taken after the fact is a memory image that may contain the document itself, which means it inherits every control the original document needed. Same rule as PHI, same reasoning: the artifact that looks like infrastructure is sometimes the sensitive data.

Shared container fleet vs. per-matter microVM, side by side

  • Residue risk — Shared container fleet: temp files, OCR intermediates, and shared caches persist across jobs on a long-lived worker, so avoiding cross-client contamination depends on every cleanup path being correct, every time, including on crash. Per-matter microVM: the guest's memory and disk cease to exist together at teardown; residue is bounded by the guest's lifetime, not by a cleanup routine someone has to get right.
  • Network posture — Shared container fleet: egress policy is usually written once for the whole worker deployment, so an allowance one integration needs becomes an allowance every job's process can reach. Per-matter microVM: a dedicated network namespace per sandbox makes default-deny the scoped default, with the DMS/storage allowlist an explicit per-matter exception rather than a fleet-wide rule.
  • Blast radius on a parser bug — Shared container fleet: a memory-corruption bug in a PDF or OCR library executes inside a process that has already handled, or will next handle, other clients' documents, and shares a kernel with every other worker on the host. Per-matter microVM: a hardware-virtualized guest with its own kernel; a successful exploit owns a throwaway machine holding exactly one document, with no route out.
  • Auditability — Shared container fleet: attributing "what touched this document" means correlating timestamps across an interleaved worker log, often by searching for the client's own confidential text. Per-matter microVM: one job, one guest id, one log stream — the audit entry names a machine, not a client name.
  • Cost and latency — Shared container fleet: near-zero marginal cost per job, warm workers, no restore step — which is exactly why teams default to it and only reconsider after an incident. Per-matter microVM: a restore-based create adds roughly 179ms p50 on PandaStack, which is well under the seconds a real extraction-plus-OCR-plus-diff job already takes, so the isolation is close to free relative to the job itself.

What this does not replace

Be precise about what the isolation boundary buys you, because it's easy to oversell. Per-matter microVM isolation stops one client's document-processing job from contaminating another's environment, and it stops a compromised parser from reaching the internet or your DMS credentials wholesale. It does not stop the model from confidently asserting that a limitation-of-liability clause caps damages at twelve months of fees when the actual clause says eighteen — that's a model-quality and human-review problem, and no amount of hardware isolation fixes an LLM's talent for sounding sure of something it made up. It doesn't replace your conflicts-checking process, your engagement-letter language about AI tooling, or a lawyer actually reading the redline before it goes out. And it doesn't turn a vendor into a business associate you don't still need a data-processing agreement with. What it does is make the technical half of "we did not let your confidential documents mix with anyone else's" something you can point to a job id and a killed sandbox for, instead of something you have to ask the on-call engineer to swear to.

The billable-hour joke writes itself, but the real trade is a good one: a few hundred milliseconds of restore time per document is cheap insurance against the version of this story that ends with your firm's name in someone else's malpractice filing.

Frequently asked questions

Why isn't calling an LLM API with a PDF attachment enough for AI contract review?

Because the useful parts of contract review — extracting clause boundaries with byte-accurate positions, running OCR on scanned exhibits that have no text layer, structurally diffing a draft against a playbook template, and cross-referencing extracted terms against a document management system — are deterministic, imperative operations that an LLM API call doesn't do reliably or at all. A production pipeline runs real code (PDF parsers like pdfplumber or PyPDF2, OCR engines, diff tooling) against the client's actual document, usually inside whatever container the agent's tool-execution loop happens to be running in. That code execution step, not the model call, is where confidentiality risk and parser-exploitation risk actually live.

How can one law firm's contract data leak into another firm's session in a multi-tenant legal-tech platform?

Almost never through a bug in the vendor's own business logic — usually through residue left by the libraries that do the actual document processing. PDF and OCR tooling write temp files to shared directories, long-lived worker processes keep decoded document buffers in memory across many jobs, and shared package or dependency caches can retain artifacts from an agent-generated script. On a shared container fleet, avoiding cross-tenant contamination depends on every one of those cleanup paths being correct every single time, including when a job crashes on a malformed exhibit. A bug in a PDF library or a crash handler that discloses more than intended is exactly the kind of failure that doesn't show up in the output — it shows up months later, in discovery.

What does per-matter microVM isolation actually guarantee that a shared container fleet doesn't?

It guarantees that the machine which processed one document never existed before that job started and stops existing when it finishes — so there is no shared /tmp, no shared package cache, no long-lived process memory, and no filesystem for a subsequent job to accidentally read. Because it restores from a Firecracker snapshot rather than cold-booting, this is fast enough to do per document: on PandaStack a restore-based create is p50 179ms and p99 around 203ms, so the isolation adds well under a second to a job that already takes seconds to minutes for extraction, OCR, and diffing. The guarantee is structural — the prior job's data can't leak because the machine it lived in has been deleted — rather than dependent on a cleanup routine running correctly.

Should a contract-review agent's sandbox have internet access?

No, by default it should have none. The document-processing guest needs to read the input document, run local extraction and diff code, and write a result to one pre-authorized destination — typically the specific matter's folder in the firm's document management system or storage bucket — and nothing else. Default-deny egress, with that one destination as an explicit allowlisted exception, is what limits the damage if a malicious PDF, a compromised dependency, or an agent-generated script tries to make an unexpected outbound request. Per-sandbox network namespaces make this enforceable per job rather than as one shared firewall policy the whole fleet has to live with.

Does isolating document processing in microVMs satisfy attorney-client privilege or malpractice-insurance requirements on its own?

No — it's a technical control that supports those requirements, not a substitute for the legal and procedural work behind them. It doesn't replace conflicts checking, engagement-letter disclosures about AI tooling, a lawyer actually reviewing the agent's output before it's relied on, or a data-processing agreement with the vendor. What it does provide is a clean, verifiable answer to "could this client's document have mixed with another client's": a per-job guest with its own audit entry, network denial, and hard TTL, versus a shared worker fleet where the honest answer depends on trusting every dependency's cleanup code. That distinction is exactly what matters for e-discovery requests and for an insurer or opposing counsel asking what technical controls were actually in place.

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.